mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-25 01:49:06 -05:00
Merge branch 'develop' into build-binary-debug-skill
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
# Bump this version to force CI to re-create the cache from scratch.
|
||||
03-20-2026
|
||||
05-15-2026
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<!-- See ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
|
||||
## 15.15.1
|
||||
|
||||
**Bugfixes:**
|
||||
|
||||
- Fixed an issue where Cypress would abort the process and show a crash dialog when it received a SIGINT. Fixes [#29228](https://github.com/cypress-io/cypress/issues/29228). Fixed in [#33542](https://github.com/cypress-io/cypress/pull/33542/).
|
||||
- Fixed an issue where the [`clientCertificates`](https://docs.cypress.io/guides/references/client-certificates) config option failed to load ECDSA (EC) PEM or PKCS#12 client certificates. Fixes [#33767](https://github.com/cypress-io/cypress/issues/33767). Fixed in [#33799](https://github.com/cypress-io/cypress/pull/33799).
|
||||
- Fixed an issue where clicking "back to projects" or switching projects while a project's initial config load was still in flight could fail. Fixed in [#33810](https://github.com/cypress-io/cypress/pull/33810).
|
||||
- Fixed an intermittent `ENOENT: no such file or directory, open <path>/bundle.tar-<rand>` error during `cy.prompt` and Studio bundle initialization. Fixed in [#33748](https://github.com/cypress-io/cypress/pull/33748).
|
||||
|
||||
## 15.15.0
|
||||
|
||||
**Deprecations:**
|
||||
|
||||
+25
-9
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import getFolderSize from './get-folder-size'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
// Subdirs under the cache root that are not binary version dirs.
|
||||
const EXTERNAL_CACHE_ENTRIES = new Set(['bundles'])
|
||||
|
||||
// output colors for the table
|
||||
const colors = {
|
||||
titles: chalk.white,
|
||||
@@ -41,6 +44,8 @@ const prune = async (): Promise<void> => {
|
||||
const versions = await fs.readdir(cacheDir)
|
||||
|
||||
for (const version of versions) {
|
||||
if (EXTERNAL_CACHE_ENTRIES.has(version)) continue
|
||||
|
||||
if (version !== checkedInBinaryVersion) {
|
||||
deletedBinary = true
|
||||
|
||||
|
||||
@@ -75,8 +75,12 @@ const isInstallingFromPostinstallHook = (): boolean => {
|
||||
const getCacheDir = (): string => {
|
||||
let cache_directory = util.getCacheDir()
|
||||
|
||||
if (util.getEnv('CYPRESS_CACHE_FOLDER')) {
|
||||
const envVarCacheDir = untildify(util.getEnv('CYPRESS_CACHE_FOLDER') as string)
|
||||
// Pass trim=true so we strip surrounding double quotes and whitespace.
|
||||
// Windows CMD's `set CYPRESS_CACHE_FOLDER="C:\path"` embeds literal quotes
|
||||
// into the env value; without dequoting, the resolved cache directory ends
|
||||
// up with quote chars in its name (see cypress-io/cypress#4506).
|
||||
if (util.getEnv('CYPRESS_CACHE_FOLDER', true)) {
|
||||
const envVarCacheDir = untildify(util.getEnv('CYPRESS_CACHE_FOLDER', true) as string)
|
||||
|
||||
debug('using environment variable CYPRESS_CACHE_FOLDER %s', envVarCacheDir)
|
||||
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`lib/exec/spawn > .start > detects kill signal > exits with error on SIGKILL 1`] = `
|
||||
"The Test Runner unexpectedly exited via a exit event with signal SIGKILL
|
||||
|
||||
Please search Cypress documentation for possible solutions:
|
||||
|
||||
https://on.cypress.io
|
||||
|
||||
Check if there is a GitHub issue describing this crash:
|
||||
|
||||
https://github.com/cypress-io/cypress/issues
|
||||
|
||||
Consider opening a new issue.
|
||||
|
||||
----------
|
||||
|
||||
Platform: darwin-x64 (Foo - OsVersion)
|
||||
Cypress Version: 0.0.0-development"
|
||||
`;
|
||||
|
||||
exports[`lib/exec/spawn > .start > does not force colors and streams when not supported 1`] = `
|
||||
{
|
||||
"DEBUG_COLORS": "0",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ vi.mock('../../../lib/util', async (importActual) => {
|
||||
describe('lib/tasks/cache', () => {
|
||||
const createStdoutCapture = () => {
|
||||
const logs: string[] = []
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
const originalOut = process.stdout.write
|
||||
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((strOrBugger: string | Uint8Array<ArrayBufferLike>) => {
|
||||
@@ -150,6 +150,34 @@ describe('lib/tasks/cache', () => {
|
||||
expect(exists).toEqual(false)
|
||||
expect(output()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('removes the bundles/ subdir alongside binary version dirs', async function () {
|
||||
mockfs.restore()
|
||||
mockfs({
|
||||
'/.cache/Cypress': {
|
||||
'1.2.3': { 'Cypress': { 'file1': 'binary' } },
|
||||
'bundles': {
|
||||
'cy-prompt': {
|
||||
'abc123': {
|
||||
'manifest.json': '{}',
|
||||
'server': { 'index.js': '// ...' },
|
||||
},
|
||||
},
|
||||
'studio': {
|
||||
'def456': {
|
||||
'manifest.json': '{}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vi.mocked(state.getCacheDir).mockReturnValue('/.cache/Cypress')
|
||||
|
||||
await cache.clear()
|
||||
|
||||
expect(await fs.pathExists('/.cache/Cypress')).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('.prune', () => {
|
||||
@@ -201,6 +229,68 @@ describe('lib/tasks/cache', () => {
|
||||
|
||||
expect(output()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('preserves the bundles/ subdir while pruning old binary versions', async function () {
|
||||
mockfs.restore()
|
||||
mockfs({
|
||||
'/.cache/Cypress': {
|
||||
'1.2.3': { 'Cypress': { 'file1': 'current' } },
|
||||
'2.3.4': { 'Cypress.app': {} },
|
||||
'bundles': {
|
||||
'cy-prompt': {
|
||||
'abc123': {
|
||||
'manifest.json': '{}',
|
||||
'server': { 'index.js': '// hi' },
|
||||
},
|
||||
},
|
||||
'studio': {
|
||||
'def456': {
|
||||
'manifest.json': '{}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vi.mocked(state.getCacheDir).mockReturnValue('/.cache/Cypress')
|
||||
vi.mocked(util.pkgVersion).mockReturnValue('1.2.3')
|
||||
|
||||
await cache.prune()
|
||||
|
||||
// Old binary version is removed, current one and bundles/ survive
|
||||
expect(await fs.pathExists('/.cache/Cypress/2.3.4')).toEqual(false)
|
||||
expect(await fs.pathExists('/.cache/Cypress/1.2.3')).toEqual(true)
|
||||
expect(await fs.pathExists('/.cache/Cypress/bundles/cy-prompt/abc123/manifest.json')).toEqual(true)
|
||||
expect(await fs.pathExists('/.cache/Cypress/bundles/studio/def456/manifest.json')).toEqual(true)
|
||||
})
|
||||
|
||||
it('prunes beta/prerelease binary dirs produced by getVersionDir()', async function () {
|
||||
// state.getVersionDir() formats non-stable builds as
|
||||
// `beta-<version>-<branch>-<sha>`, which is not a valid semver. These
|
||||
// must still be pruned alongside other non-current binary versions.
|
||||
mockfs.restore()
|
||||
mockfs({
|
||||
'/.cache/Cypress': {
|
||||
'1.2.3': { 'Cypress': { 'file1': 'current' } },
|
||||
'beta-15.0.0-feat-abc12345': { 'Cypress.app': {} },
|
||||
'beta-14.5.0-fix-deadbeef': { 'Cypress.app': {} },
|
||||
'bundles': {
|
||||
'cy-prompt': { 'abc123': { 'manifest.json': '{}' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vi.mocked(state.getCacheDir).mockReturnValue('/.cache/Cypress')
|
||||
vi.mocked(util.pkgVersion).mockReturnValue('1.2.3')
|
||||
|
||||
await cache.prune()
|
||||
|
||||
// Beta dirs are pruned; current binary and bundles/ survive
|
||||
expect(await fs.pathExists('/.cache/Cypress/beta-15.0.0-feat-abc12345')).toEqual(false)
|
||||
expect(await fs.pathExists('/.cache/Cypress/beta-14.5.0-fix-deadbeef')).toEqual(false)
|
||||
expect(await fs.pathExists('/.cache/Cypress/1.2.3')).toEqual(true)
|
||||
expect(await fs.pathExists('/.cache/Cypress/bundles/cy-prompt/abc123/manifest.json')).toEqual(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('.list', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -99,9 +99,7 @@ describe('App: Settings', () => {
|
||||
cy.findByTestId('spec-list-container').scrollTo('bottom')
|
||||
// Visit the test to trigger the ws.off() for the TR websockets
|
||||
cy.contains('test1.js').click()
|
||||
cy.waitForSpecToFinish()
|
||||
// Wait for the test to pass, so the test is completed
|
||||
cy.get('.passed > .num').should('contain', 1)
|
||||
cy.waitForSpecToFinish({ passCount: 1 })
|
||||
cy.get(SidebarSettingsLinkSelector).click()
|
||||
cy.contains('Cypress Cloud settings').click()
|
||||
// Assert the data is not there before it arrives
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('errorWarningChange subscription', () => {
|
||||
})
|
||||
|
||||
function assertLoadingIntoErrorWorks (errorName: string) {
|
||||
cy.contains('h2', errorName).should('be.visible')
|
||||
cy.contains('h2', errorName, { timeout: 15000 }).should('be.visible')
|
||||
cy.contains('[role="alert"]', 'Loading').should('not.exist')
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ e2e: {
|
||||
}`)
|
||||
})
|
||||
|
||||
cy.get('[data-cy="spec-item-link"]', { timeout: 7500 })
|
||||
cy.get('[data-cy="spec-item-link"]', { timeout: 15000 })
|
||||
.should('have.length', 3)
|
||||
.should('contain', 'dom-container.spec.js')
|
||||
.should('contain', 'dom-content.spec.js')
|
||||
@@ -329,7 +329,7 @@ e2e: {
|
||||
}`)
|
||||
})
|
||||
|
||||
cy.get('[data-cy="spec-file-item"]', { timeout: 7500 })
|
||||
cy.get('[data-cy="spec-file-item"]', { timeout: 15000 })
|
||||
.should('have.length', 3)
|
||||
.should('contain', 'dom-container.spec.js')
|
||||
.should('contain', 'dom-content.spec.js')
|
||||
@@ -453,7 +453,7 @@ e2e: {
|
||||
}`)
|
||||
})
|
||||
|
||||
cy.get('[data-cy="file-match-indicator"]', { timeout: 7500 })
|
||||
cy.get('[data-cy="file-match-indicator"]', { timeout: 15000 })
|
||||
.should('contain', '3 matches')
|
||||
|
||||
// Regression for https://github.com/cypress-io/cypress/issues/27103
|
||||
@@ -487,7 +487,7 @@ module.exports = {
|
||||
}`)
|
||||
})
|
||||
|
||||
cy.get('[data-cy="create-spec-page-title"]', { timeout: 10000 })
|
||||
cy.get('[data-cy="create-spec-page-title"]', { timeout: 15000 })
|
||||
.should('contain', defaultMessages.createSpec.page.customPatternNoSpecs.title)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,10 +23,13 @@ const IS_DEVELOPMENT = process.env.CYPRESS_INTERNAL_ENV !== 'production'
|
||||
|
||||
let gqlSocketServer: SocketIONamespace
|
||||
let gqlServer: Server
|
||||
/** Keeps graphql-ws teardown in sync when `reinitializeCypress` replaces ctx but reuses the same HTTP server */
|
||||
let gqlGraphqlWsDispose: (() => Promise<void>) | undefined
|
||||
|
||||
globalPubSub.on('reset:data-context', (ctx) => {
|
||||
ctx.actions.servers.setGqlServer(gqlServer)
|
||||
ctx.actions.servers.setGqlSocketServer(gqlSocketServer)
|
||||
ctx.actions.servers.setGqlGraphqlWsDispose(gqlGraphqlWsDispose)
|
||||
})
|
||||
|
||||
export async function makeGraphQLServer () {
|
||||
@@ -120,7 +123,10 @@ export async function makeGraphQLServer () {
|
||||
|
||||
gqlSocketServer = socketSrv.of('/data-context')
|
||||
|
||||
graphqlWS(srv, '/__launchpad/graphql-ws')
|
||||
const gqlWs = graphqlWS(srv, '/__launchpad/graphql-ws')
|
||||
|
||||
gqlGraphqlWsDispose = gqlWs.dispose
|
||||
ctx.actions.servers.setGqlGraphqlWsDispose(gqlWs.dispose)
|
||||
|
||||
gqlSocketServer.on('connection', (socket) => {
|
||||
socket.on('graphql:request', handleGraphQLSocketRequest)
|
||||
@@ -180,9 +186,14 @@ export async function handleGraphQLSocketRequest (uid: string, payload: string,
|
||||
*
|
||||
* @param httpServer The http server we are utilizing for the websocket
|
||||
* @param targetRoute Route to target in the server upgrade event
|
||||
* @returns Disposable Function to cleanup the created server resource
|
||||
* @returns WebSocket server and graphql-ws dispose — call `dispose()` before destroying the HTTP server.
|
||||
*/
|
||||
export const graphqlWS = (httpServer: Server, targetRoute: string) => {
|
||||
export interface GraphqlWsHandle {
|
||||
server: WebSocketServer
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
export const graphqlWS = (httpServer: Server, targetRoute: string): GraphqlWsHandle => {
|
||||
const graphqlWs = new WebSocketServer({ noServer: true })
|
||||
|
||||
httpServer.on('upgrade', (req: Request, socket: Socket, head) => {
|
||||
@@ -193,12 +204,15 @@ export const graphqlWS = (httpServer: Server, targetRoute: string) => {
|
||||
}
|
||||
})
|
||||
|
||||
useServer({
|
||||
const { dispose } = useServer({
|
||||
schema: graphqlSchema,
|
||||
context: () => getCtx(),
|
||||
}, graphqlWs)
|
||||
|
||||
return graphqlWs
|
||||
return {
|
||||
server: graphqlWs,
|
||||
dispose: () => Promise.resolve(dispose()),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,9 @@ export const graphqlSchema = makeSchema({
|
||||
schema: remoteSchemaWrapped,
|
||||
skipFields: {
|
||||
Mutation: ['test'],
|
||||
// Override so we can inject projectSlug from local context — needed for
|
||||
// per-project feature-flag scoping. Local declaration in gql-Query.ts.
|
||||
Query: ['cloudAppMessages'],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -129,6 +129,24 @@ export const Query = objectType({
|
||||
description: 'Unique node machine identifier for this instance - may be nil if unable to resolve',
|
||||
resolve: async (source, args, ctx) => await ctx.coreData.machineId,
|
||||
})
|
||||
|
||||
t.list.nonNull.field('cloudAppMessages', {
|
||||
type: 'CloudAppMessage',
|
||||
description: 'Cloud-driven in-app banner content. Local override of the merged cloud field so we can inject the current project slug for per-project feature-flag scoping.',
|
||||
resolve: async (root, args, ctx, info) => {
|
||||
const projectId = await ctx.project.projectId()
|
||||
|
||||
// Omit projectSlug entirely in global mode — passing null is not the
|
||||
// same as omitting in GraphQL, and the remote field's doc contract
|
||||
// says "When omitted, only globally-targeted messages are returned."
|
||||
return ctx.cloud.delegateCloudField({
|
||||
field: 'cloudAppMessages',
|
||||
args: projectId ? { projectSlug: projectId } : {},
|
||||
ctx,
|
||||
info,
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
sourceType: {
|
||||
module: path.join(__dirname, '../../'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -332,10 +332,10 @@ export class DataContext {
|
||||
}
|
||||
|
||||
async destroy () {
|
||||
return Promise.all([
|
||||
this.actions.servers.destroyGqlServer(),
|
||||
this._reset(),
|
||||
])
|
||||
// Close graphql-ws clients and the GQL HTTP server before lifecycle teardown so
|
||||
// in-flight GraphQL/subscription work does not write to sockets mid-destroy (ERR_STREAM_DESTROYED).
|
||||
await this.actions.servers.destroyGqlServer()
|
||||
await this._reset()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,8 +41,25 @@ export class ServersActions {
|
||||
})
|
||||
}
|
||||
|
||||
setGqlGraphqlWsDispose (dispose: (() => Promise<void>) | undefined) {
|
||||
this.ctx.update((d) => {
|
||||
d.servers.gqlGraphqlWsDispose = dispose
|
||||
})
|
||||
}
|
||||
|
||||
async destroyGqlServer () {
|
||||
const destroy = this.ctx.coreData.servers.gqlServer?.destroy
|
||||
const { gqlGraphqlWsDispose, gqlSocketServer, gqlServer } = this.ctx.coreData.servers
|
||||
|
||||
if (gqlGraphqlWsDispose) {
|
||||
await gqlGraphqlWsDispose().catch(this.ctx.logTraceError)
|
||||
this.ctx.update((d) => {
|
||||
d.servers.gqlGraphqlWsDispose = undefined
|
||||
})
|
||||
}
|
||||
|
||||
gqlSocketServer?.disconnectSockets(true)
|
||||
|
||||
const destroy = gqlServer?.destroy
|
||||
|
||||
if (!destroy) {
|
||||
return
|
||||
|
||||
@@ -70,15 +70,17 @@ export class ProjectConfigIpc extends EventEmitter {
|
||||
) {
|
||||
super()
|
||||
this._childProcess = this.forkConfigProcess()
|
||||
this._childProcess.on('error', (err) => {
|
||||
// this.emit('error', err)
|
||||
this._childProcess.on('error', (err: any) => {
|
||||
debug('child process error: %s', err)
|
||||
})
|
||||
|
||||
this._childProcess.on('message', (msg: { event: string, args: any[] }) => {
|
||||
debug('received %s message from child process %s with args %o', msg.event, this._childProcess.pid, msg.args)
|
||||
this.emit(msg.event, ...msg.args)
|
||||
})
|
||||
|
||||
this._childProcess.once('disconnect', () => {
|
||||
debug('received disconnect event from child process %s', this._childProcess.pid)
|
||||
this.emit('disconnect')
|
||||
})
|
||||
|
||||
@@ -104,9 +106,13 @@ export class ProjectConfigIpc extends EventEmitter {
|
||||
send(event: 'main:process:will:disconnect'): void
|
||||
send (event: string, ...args: any[]) {
|
||||
if (this._childProcess.killed || !this._childProcess.connected) {
|
||||
debug('not sending %s message to child process. Killed? %s, Connected? %s', event, this._childProcess.killed, this._childProcess.connected)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
debug('sending %s message to child process %s with args %o', event, this._childProcess.pid, args)
|
||||
|
||||
return this._childProcess.send({ event, args })
|
||||
}
|
||||
|
||||
@@ -114,6 +120,8 @@ export class ProjectConfigIpc extends EventEmitter {
|
||||
on(evt: 'export:telemetry', listener: (data: string) => void): void
|
||||
on(evt: 'main:process:will:disconnect:ack', listener: () => void): void
|
||||
on(evt: 'warning', listener: (warningErr: CypressError) => void): this
|
||||
on(evt: 'disconnect', listener: () => void): this
|
||||
on(evt: 'exit', listener: (code: number, signal: string) => void): this
|
||||
on (evt: string, listener: (...args: any[]) => void) {
|
||||
return super.on(evt, listener)
|
||||
}
|
||||
@@ -168,6 +176,12 @@ export class ProjectConfigIpc extends EventEmitter {
|
||||
reject(err)
|
||||
})
|
||||
|
||||
this._childProcess.on('exit', (code, signal) => {
|
||||
debug('child process %s exited with code %s and signal %s', this._childProcess.pid, code, signal)
|
||||
this.emit('exit', code, signal)
|
||||
this.cleanupIpc()
|
||||
})
|
||||
|
||||
/**
|
||||
* This reject cannot be caught anywhere??
|
||||
*
|
||||
@@ -399,14 +413,23 @@ export class ProjectConfigIpc extends EventEmitter {
|
||||
}
|
||||
|
||||
cleanupIpc () {
|
||||
debug('cleaning up IPC')
|
||||
this.killChildProcess()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
private killChildProcess () {
|
||||
this._childProcess.kill()
|
||||
private killChildProcess (): void {
|
||||
this._childProcess.stdout?.removeAllListeners()
|
||||
this._childProcess.stderr?.removeAllListeners()
|
||||
this._childProcess.removeAllListeners()
|
||||
|
||||
if (this._childProcess.killed || !this._childProcess.connected) {
|
||||
debug('child process %s already killed', this._childProcess.pid)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
debug('killing child process %s', this._childProcess.pid)
|
||||
this._childProcess.kill()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,15 +640,29 @@ export class ProjectConfigManager {
|
||||
return
|
||||
}
|
||||
|
||||
debug('sending main:process:will:disconnect message')
|
||||
this._eventsIpc.send('main:process:will:disconnect')
|
||||
|
||||
// If for whatever reason we don't get an ack in 5s, bail.
|
||||
const timeoutId = setTimeout(() => {
|
||||
debug(`mainProcessWillDisconnect message timed out`)
|
||||
reject()
|
||||
reject(new Error('mainProcessWillDisconnect message timed out'))
|
||||
}, 5000)
|
||||
|
||||
this._eventsIpc.on('main:process:will:disconnect:ack', () => {
|
||||
debug('Received main:process:will:disconnect:ack')
|
||||
clearTimeout(timeoutId)
|
||||
resolve()
|
||||
})
|
||||
|
||||
this._eventsIpc.on('exit', (code, signal) => {
|
||||
debug('child process %s exited with code %s and signal %s', this._eventsIpc?.childProcessPid, code, signal)
|
||||
clearTimeout(timeoutId)
|
||||
resolve()
|
||||
})
|
||||
|
||||
this._eventsIpc.on('disconnect', () => {
|
||||
debug('received disconnect event from child process %s', this._eventsIpc?.childProcessPid)
|
||||
clearTimeout(timeoutId)
|
||||
resolve()
|
||||
})
|
||||
|
||||
@@ -199,6 +199,12 @@ export class ProjectLifecycleManager {
|
||||
this.ctx.emitter.toApp()
|
||||
},
|
||||
onFinalConfigLoaded: async (finalConfig: FullConfig, options: OnFinalConfigLoadedOptions) => {
|
||||
// if we no longer have a project, just return
|
||||
// can happen when the user clears the project while setupNodeEvents is in flight
|
||||
if (!this._projectRoot) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this._currentTestingType && finalConfig.specPattern) {
|
||||
await this.ctx.actions.project.setSpecsFoundBySpecPattern({
|
||||
projectRoot: this.projectRoot,
|
||||
@@ -288,6 +294,12 @@ export class ProjectLifecycleManager {
|
||||
* 4. The first browser found.
|
||||
*/
|
||||
async setInitialActiveBrowser () {
|
||||
// if we no longer have a project, just return
|
||||
// can happen when the user clears the project while we are setting up
|
||||
if (!this._projectRoot) {
|
||||
return
|
||||
}
|
||||
|
||||
const configDefaultBrowser = this.loadedFullConfig?.defaultBrowser
|
||||
|
||||
// if we have a default browser from the config and a CLI browser wasn't passed and the active browser hasn't been set
|
||||
|
||||
@@ -31,6 +31,11 @@ interface ServersDataShape {
|
||||
gqlServer?: Maybe<Server>
|
||||
gqlServerPort?: Maybe<number>
|
||||
gqlSocketServer?: Maybe<SocketIONamespace>
|
||||
/**
|
||||
* graphql-ws `useServer` dispose — must run before `gqlServer.destroy()` so
|
||||
* protocol clients close before the HTTP server tears down sockets.
|
||||
*/
|
||||
gqlGraphqlWsDispose?: Maybe<() => Promise<void>>
|
||||
}
|
||||
|
||||
export interface DevStateShape {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import Debug from 'debug'
|
||||
|
||||
import type { DataContext } from './DataContext'
|
||||
|
||||
const debug = Debug('cypress:data-context')
|
||||
|
||||
export { DocumentNodeBuilder } from './util/DocumentNodeBuilder'
|
||||
|
||||
export {
|
||||
@@ -23,9 +27,14 @@ let ctx: DataContext | null = null
|
||||
|
||||
export async function clearCtx () {
|
||||
if (ctx) {
|
||||
debug('signalling mainProcessWillDisconnect')
|
||||
await ctx.lifecycleManager.mainProcessWillDisconnect()
|
||||
debug('destroying data-context')
|
||||
await ctx.destroy()
|
||||
debug('data-context destroyed')
|
||||
ctx = null
|
||||
} else {
|
||||
debug('no data-context to clear')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@ describe('ProjectConfigIpc', () => {
|
||||
await projectConfigIpc.loadConfig()
|
||||
await projectConfigIpc.registerSetupIpcHandlers()
|
||||
|
||||
// Constructor forwards child `error` to `this.emit('error')`; Node throws if nothing listens.
|
||||
projectConfigIpc.on('error', () => {})
|
||||
|
||||
projectConfigIpc._childProcess.emit('error', err)
|
||||
|
||||
expect(globalThis.debugMessages.at(-2)).toEqual('EPIPE error in loadConfig() of child process %s')
|
||||
|
||||
@@ -209,6 +209,44 @@ describe('ProjectLifecycleManager', () => {
|
||||
expect(ctx.coreData.cliBrowser).toEqual('chrome:beta')
|
||||
expect(ctx.coreData.activeBrowser).toEqual(browsers[2])
|
||||
})
|
||||
|
||||
it('returns early without throwing if the project was cleared mid-setup', async () => {
|
||||
// Simulates the user clearing the project (e.g. clicking "back to projects"
|
||||
// or switching projects) while setupNodeEvents is still in flight.
|
||||
// @ts-expect-error - private field
|
||||
ctx.lifecycleManager._projectRoot = undefined
|
||||
ctx.coreData.activeBrowser = null
|
||||
ctx.coreData.cliBrowser = null
|
||||
|
||||
await expect(ctx.lifecycleManager.setInitialActiveBrowser()).resolves.toBeUndefined()
|
||||
expect(ctx.coreData.activeBrowser).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onFinalConfigLoaded', () => {
|
||||
it('returns early without throwing if the project was cleared mid-setup', async () => {
|
||||
// Simulates the user clearing the project (e.g. clicking "back to projects"
|
||||
// or switching projects) while setupNodeEvents is still in flight.
|
||||
|
||||
// Grab the onFinalConfigLoaded callback the way the ProjectConfigManager
|
||||
// would when it finishes resolving setupNodeEvents.
|
||||
// @ts-expect-error - private method
|
||||
const configManager = ctx.lifecycleManager.createConfigManager()
|
||||
const onFinalConfigLoaded = (configManager as any).options.onFinalConfigLoaded as (config: FullConfig, options: { shouldRestartBrowser: boolean }) => Promise<void>
|
||||
|
||||
const setSpecsSpy = jest.spyOn(ctx.actions.project, 'setSpecsFoundBySpecPattern').mockResolvedValue(undefined)
|
||||
const setInitialActiveBrowserSpy = jest.spyOn(ctx.lifecycleManager, 'setInitialActiveBrowser').mockResolvedValue(undefined)
|
||||
|
||||
// @ts-expect-error - private field
|
||||
ctx.lifecycleManager._projectRoot = undefined
|
||||
|
||||
await expect(onFinalConfigLoaded(fullConfig, { shouldRestartBrowser: false })).resolves.toBeUndefined()
|
||||
|
||||
expect(setSpecsSpy).not.toHaveBeenCalled()
|
||||
expect(setInitialActiveBrowserSpy).not.toHaveBeenCalled()
|
||||
// @ts-expect-error - private field
|
||||
expect(ctx.lifecycleManager._cachedFullConfig).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#eventProcessPid', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import inspector from 'inspector'
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import Debug from 'debug'
|
||||
import os from 'os'
|
||||
import pDefer from 'p-defer'
|
||||
|
||||
function getInspectFromUrl (url: string): string {
|
||||
const flag = process.execArgv.some((f) => f === '--inspect' || f.startsWith('--inspect=')) ? '--inspect' : '--inspect-brk'
|
||||
@@ -29,7 +30,10 @@ function getInspectFromOpts (argv: string[]): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function open (appPath: string, argv: string[]): Promise<ChildProcess> {
|
||||
export async function open (
|
||||
appPath: string,
|
||||
argv: string[],
|
||||
): Promise<ChildProcess> {
|
||||
const debugElectron = Debug('cypress:electron')
|
||||
const debugStderr = Debug('cypress:internal-stderr')
|
||||
|
||||
@@ -76,23 +80,39 @@ export async function open (appPath: string, argv: string[]): Promise<ChildProce
|
||||
{ stdio: 'pipe' },
|
||||
)
|
||||
|
||||
const childClosed = pDefer<number>()
|
||||
|
||||
spawned.on('error', (err) => {
|
||||
console.error(err)
|
||||
|
||||
process.exit(1)
|
||||
childClosed.resolve(1)
|
||||
})
|
||||
|
||||
spawned.on('close', (code, signal) => {
|
||||
debugElectron('electron closing %o', { code, signal })
|
||||
|
||||
if (signal) {
|
||||
debugElectron('electron exited with a signal, forcing code = 1 %o', { signal })
|
||||
code = 1
|
||||
debugElectron('electron exited with a signal %s', signal)
|
||||
childClosed.resolve(128 + os.constants.signals[signal])
|
||||
} else {
|
||||
childClosed.resolve(code ?? 0)
|
||||
}
|
||||
|
||||
process.exit(code)
|
||||
})
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => {
|
||||
try {
|
||||
debugElectron('electron received signal %s', signal)
|
||||
const code = await childClosed.promise
|
||||
|
||||
process.exit(code)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
(process.env.ELECTRON_ENABLE_LOGGING ?? '') === '1' ||
|
||||
debugElectron.enabled ||
|
||||
|
||||
@@ -272,36 +272,100 @@ describe('open', () => {
|
||||
})
|
||||
|
||||
describe('emits error', () => {
|
||||
it('writes the error to stderr and exit with code 1', () => {
|
||||
it('writes the error to stderr and exit with code 1 after SIGINT', async () => {
|
||||
const err = new Error('test error')
|
||||
|
||||
errCb(err)
|
||||
|
||||
expect(process.exit).toHaveBeenCalledWith(1)
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(err)
|
||||
|
||||
process.emit('SIGINT')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(process.exit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('emits close', () => {
|
||||
describe('with null signal', () => {
|
||||
it('exits with code 0', () => {
|
||||
it('exits with code 0 after SIGINT', async () => {
|
||||
closeCb(0, null)
|
||||
|
||||
process.emit('SIGINT')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(process.exit).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a signal', () => {
|
||||
it('exits with code 1', () => {
|
||||
closeCb(1, 'SIGKILL')
|
||||
it('exits with code 128 + signal after SIGINT', async () => {
|
||||
const signal = 'SIGKILL' as NodeJS.Signals
|
||||
|
||||
expect(process.exit).toHaveBeenCalledWith(1)
|
||||
closeCb(1, signal)
|
||||
|
||||
process.emit('SIGINT')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(process.exit).toHaveBeenCalledWith(128 + os.constants.signals[signal])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* `open` registers SIGINT/SIGTERM with `process.on`. If a second signal arrives
|
||||
* while the first handler is still awaiting `childClosed.promise`, Node invokes
|
||||
* the listener again — there is no de-duplication. Both continuations then call
|
||||
* `process.exit` with the same code (benign but redundant; `process.once` would
|
||||
* match the CLI spawn path).
|
||||
*/
|
||||
describe('process signal handlers', () => {
|
||||
let closeCb: (code: number, signal: NodeJS.Signals | null) => void
|
||||
|
||||
beforeEach(async () => {
|
||||
process.removeAllListeners('SIGINT')
|
||||
process.removeAllListeners('SIGTERM')
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {})
|
||||
|
||||
vi.mocked(mockChildProcess.on).mockImplementation((event: string, fn) => {
|
||||
if (event === 'close') {
|
||||
closeCb = fn
|
||||
}
|
||||
|
||||
return mockChildProcess
|
||||
})
|
||||
|
||||
await open(appPath, argv)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.removeAllListeners('SIGINT')
|
||||
process.removeAllListeners('SIGTERM')
|
||||
})
|
||||
|
||||
it('calls process.exit once per stacked SIGINT while the child close promise is pending', async () => {
|
||||
process.emit('SIGINT')
|
||||
process.emit('SIGINT')
|
||||
|
||||
closeCb(0, null)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(process.exit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls process.exit once per stacked SIGTERM while the child close promise is pending', async () => {
|
||||
process.emit('SIGTERM')
|
||||
process.emit('SIGTERM')
|
||||
|
||||
closeCb(0, null)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(process.exit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when inspector.url() returns a URL', () => {
|
||||
const port = 9229
|
||||
const nextPort = 9230
|
||||
|
||||
@@ -641,6 +641,8 @@ describe('Launchpad: Setup Project', () => {
|
||||
cy.withCtx(async (ctx) => {
|
||||
Object.defineProperty(ctx.coreData, 'scaffoldedFiles', {
|
||||
get () {
|
||||
if (!this._scaffoldedFiles) return this._scaffoldedFiles
|
||||
|
||||
return this._scaffoldedFiles.map((scaffold) => {
|
||||
if (scaffold.file.absolute.includes('cypress.config')) {
|
||||
return { ...scaffold, status: 'changes' }
|
||||
@@ -655,8 +657,9 @@ describe('Launchpad: Setup Project', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'Skip' }).click()
|
||||
cy.intercept('POST', 'mutation-ExternalLink_OpenExternal', { 'data': { 'openExternal': true } }).as('OpenExternal')
|
||||
|
||||
cy.findByRole('button', { name: 'Skip' }).click()
|
||||
cy.findByText('Learn more', { timeout: 10000 }).click()
|
||||
cy.wait('@OpenExternal')
|
||||
.its('request.body.variables.url')
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { allowDestroy } from '../../lib/allow-destroy'
|
||||
import { AsyncServer, Servers } from '../support/servers'
|
||||
import { clientCertificateStoreSingleton, UrlClientCertificates, ClientCertificates, PemKey } from '../../lib/client-certificates'
|
||||
import { pki } from 'node-forge'
|
||||
import { execFileSync } from 'child_process'
|
||||
import fetch from 'cross-fetch'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
@@ -40,48 +40,27 @@ if (!fs.existsSync(tempDirPath)) {
|
||||
fs.mkdirSync(tempDirPath)
|
||||
}
|
||||
|
||||
function createCertAndKey (): [pki.Certificate, pki.rsa.PrivateKey] {
|
||||
let keys = pki.rsa.generateKeyPair(2048)
|
||||
let cert = pki.createCertificate()
|
||||
function createCertAndKey (): { cert: string, key: string } {
|
||||
const certTmp = path.join(tempDirPath, `agent-cert-${Date.now()}-${Math.random()}.pem`)
|
||||
const keyTmp = path.join(tempDirPath, `agent-key-${Date.now()}-${Math.random()}.pem`)
|
||||
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = '01'
|
||||
cert.validity.notBefore = new Date()
|
||||
cert.validity.notAfter = new Date()
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1)
|
||||
execFileSync('openssl', [
|
||||
'req', '-x509',
|
||||
'-newkey', 'rsa:2048',
|
||||
'-nodes',
|
||||
'-keyout', keyTmp,
|
||||
'-out', certTmp,
|
||||
'-days', '1',
|
||||
'-subj', '/CN=example.org/C=US/ST=California/L=San Fran/O=Test/OU=Test',
|
||||
], { stdio: 'ignore' })
|
||||
|
||||
let attrs = [
|
||||
{
|
||||
name: 'commonName',
|
||||
value: 'example.org',
|
||||
},
|
||||
{
|
||||
name: 'countryName',
|
||||
value: 'US',
|
||||
},
|
||||
{
|
||||
shortName: 'ST',
|
||||
value: 'California',
|
||||
},
|
||||
{
|
||||
name: 'localityName',
|
||||
value: 'San Fran',
|
||||
},
|
||||
{
|
||||
name: 'organizationName',
|
||||
value: 'Test',
|
||||
},
|
||||
{
|
||||
shortName: 'OU',
|
||||
value: 'Test',
|
||||
},
|
||||
]
|
||||
const cert = fs.readFileSync(certTmp, 'utf-8')
|
||||
const key = fs.readFileSync(keyTmp, 'utf-8')
|
||||
|
||||
cert.setSubject(attrs)
|
||||
cert.setIssuer(attrs)
|
||||
cert.sign(keys.privateKey)
|
||||
fs.unlinkSync(certTmp)
|
||||
fs.unlinkSync(keyTmp)
|
||||
|
||||
return [cert, keys.privateKey]
|
||||
return { cert, key }
|
||||
}
|
||||
|
||||
describe('lib/agent', function () {
|
||||
@@ -764,15 +743,14 @@ describe('lib/agent', function () {
|
||||
|
||||
if (testCase.presentClientCertificate) {
|
||||
clientCertificateStoreSingleton.clear()
|
||||
const certAndKey = createCertAndKey()
|
||||
const pemCert = pki.certificateToPem(certAndKey[0])
|
||||
const { cert: pemCert, key: pemKey } = createCertAndKey()
|
||||
|
||||
clientCert = pemCert
|
||||
const testCerts = new UrlClientCertificates(`https://localhost`)
|
||||
|
||||
testCerts.clientCertificates = new ClientCertificates()
|
||||
testCerts.clientCertificates.cert.push(Buffer.from(pemCert, 'utf-8'))
|
||||
testCerts.clientCertificates.key.push(new PemKey(Buffer.from(pki.privateKeyToPem(certAndKey[1]), 'utf-8'), undefined))
|
||||
testCerts.clientCertificates.key.push(new PemKey(Buffer.from(pemKey, 'utf-8'), undefined))
|
||||
clientCertificateStoreSingleton.addClientCertificatesForUrl(testCerts)
|
||||
} else {
|
||||
clientCert = ''
|
||||
|
||||
@@ -5,9 +5,18 @@ import urllib from 'url'
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import Forge from 'node-forge'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { randomUUID } from 'crypto'
|
||||
const { pki, pkcs12, asn1 } = Forge
|
||||
|
||||
type CertAlgo = 'rsa' | 'ec'
|
||||
|
||||
function opensslNewkeyArgs (algo: CertAlgo): string[] {
|
||||
return algo === 'ec'
|
||||
? ['-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256']
|
||||
: ['-newkey', 'rsa:2048']
|
||||
}
|
||||
|
||||
const testSubject = '/CN=example.org/C=US/ST=California/L=San Fran/O=Test/OU=Test'
|
||||
|
||||
function urlShouldMatch (url: string, matcher: string) {
|
||||
let rule = UrlMatcher.buildMatcherRule(matcher)
|
||||
@@ -187,90 +196,87 @@ describe('lib/client-certificates', () => {
|
||||
//
|
||||
// Neither PEM nor PFX supplied
|
||||
|
||||
function createCertAndKey (): [object, object] {
|
||||
let keys = pki.rsa.generateKeyPair(2048)
|
||||
let cert = pki.createCertificate()
|
||||
|
||||
cert.publicKey = keys.publicKey
|
||||
cert.serialNumber = '01'
|
||||
cert.validity.notBefore = new Date()
|
||||
cert.validity.notAfter = new Date()
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1)
|
||||
|
||||
let attrs = [
|
||||
{
|
||||
name: 'commonName',
|
||||
value: 'example.org',
|
||||
},
|
||||
{
|
||||
name: 'countryName',
|
||||
value: 'US',
|
||||
},
|
||||
{
|
||||
shortName: 'ST',
|
||||
value: 'California',
|
||||
},
|
||||
{
|
||||
name: 'localityName',
|
||||
value: 'San Fran',
|
||||
},
|
||||
{
|
||||
name: 'organizationName',
|
||||
value: 'Test',
|
||||
},
|
||||
{
|
||||
shortName: 'OU',
|
||||
value: 'Test',
|
||||
},
|
||||
]
|
||||
|
||||
cert.setSubject(attrs)
|
||||
cert.setIssuer(attrs)
|
||||
cert.sign(keys.privateKey)
|
||||
|
||||
return [cert, keys.privateKey]
|
||||
}
|
||||
|
||||
function createPemFiles (
|
||||
certFilepath: string,
|
||||
keyFilepath: string,
|
||||
passphraseFilepath: string | undefined,
|
||||
passphrase: string | undefined,
|
||||
algo: CertAlgo = 'rsa',
|
||||
) {
|
||||
const certInfo = createCertAndKey()
|
||||
const args = [
|
||||
'req', '-x509',
|
||||
...opensslNewkeyArgs(algo),
|
||||
'-keyout', keyFilepath,
|
||||
'-out', certFilepath,
|
||||
'-days', '1',
|
||||
'-subj', testSubject,
|
||||
]
|
||||
|
||||
fs.writeFileSync(certFilepath, pki.certificateToPem(certInfo[0]))
|
||||
const key = passphrase
|
||||
? pki.encryptRsaPrivateKey(certInfo[1], passphrase)
|
||||
: pki.privateKeyToPem(certInfo[1])
|
||||
if (passphrase) {
|
||||
args.push('-passout', `pass:${passphrase}`)
|
||||
} else {
|
||||
args.push('-nodes')
|
||||
}
|
||||
|
||||
fs.writeFileSync(keyFilepath, key)
|
||||
execFileSync('openssl', args, { stdio: 'ignore' })
|
||||
|
||||
if (passphraseFilepath) {
|
||||
if (passphraseFilepath && passphrase) {
|
||||
fs.writeFileSync(passphraseFilepath, passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
function createPfxFiles (
|
||||
certFilepath: string,
|
||||
pfxFilepath: string,
|
||||
passphraseFilepath: string | undefined,
|
||||
passphrase: string | undefined,
|
||||
passphrase: string,
|
||||
algo: CertAlgo = 'rsa',
|
||||
) {
|
||||
const certInfo = createCertAndKey()
|
||||
const certTmp = `${pfxFilepath}.cert.tmp`
|
||||
const keyTmp = `${pfxFilepath}.key.tmp`
|
||||
|
||||
let p12Asn1 = pkcs12.toPkcs12Asn1(certInfo[1], [certInfo[0]], passphrase)
|
||||
execFileSync('openssl', [
|
||||
'req', '-x509',
|
||||
...opensslNewkeyArgs(algo),
|
||||
'-nodes',
|
||||
'-keyout', keyTmp,
|
||||
'-out', certTmp,
|
||||
'-days', '1',
|
||||
'-subj', testSubject,
|
||||
], { stdio: 'ignore' })
|
||||
|
||||
fs.writeFileSync(certFilepath, asn1.toDer(p12Asn1).getBytes(), { encoding: 'binary' })
|
||||
execFileSync('openssl', [
|
||||
'pkcs12', '-export',
|
||||
'-in', certTmp,
|
||||
'-inkey', keyTmp,
|
||||
'-out', pfxFilepath,
|
||||
'-password', `pass:${passphrase}`,
|
||||
'-keypbe', 'AES-256-CBC',
|
||||
'-certpbe', 'AES-256-CBC',
|
||||
'-macalg', 'sha256',
|
||||
], { stdio: 'ignore' })
|
||||
|
||||
fs.removeSync(certTmp)
|
||||
fs.removeSync(keyTmp)
|
||||
|
||||
if (passphraseFilepath) {
|
||||
fs.writeFileSync(passphraseFilepath, passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
function createCaFile (filepath: string) {
|
||||
const certInfo = createCertAndKey()
|
||||
function createCaFile (filepath: string, algo: CertAlgo = 'rsa') {
|
||||
const keyTmp = `${filepath}.key.tmp`
|
||||
|
||||
fs.writeFileSync(filepath, pki.certificateToPem(certInfo[0]))
|
||||
execFileSync('openssl', [
|
||||
'req', '-x509',
|
||||
...opensslNewkeyArgs(algo),
|
||||
'-nodes',
|
||||
'-keyout', keyTmp,
|
||||
'-out', filepath,
|
||||
'-days', '1',
|
||||
'-subj', testSubject,
|
||||
], { stdio: 'ignore' })
|
||||
|
||||
fs.removeSync(keyTmp)
|
||||
}
|
||||
|
||||
function createUniqueUrl (): string {
|
||||
@@ -526,6 +532,104 @@ describe('lib/client-certificates', () => {
|
||||
expect(options.key[0].passphrase).toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads valid single EC PEM (no passphrase)', () => {
|
||||
createPemFiles(pemFilepath, pemKeyFilepath, undefined, undefined, 'ec')
|
||||
createCaFile(caFilepath, 'ec')
|
||||
|
||||
const url = createUniqueUrl()
|
||||
const config = createSinglePemConfig(
|
||||
url,
|
||||
caFilepath,
|
||||
pemFilepath,
|
||||
pemKeyFilepath,
|
||||
undefined,
|
||||
)
|
||||
const pemFileData = fs.readFileSync(pemFilepath)
|
||||
const keyFileData = fs.readFileSync(pemKeyFilepath)
|
||||
const caFileData = fs.readFileSync(caFilepath)
|
||||
|
||||
loadClientCertificateConfig(config)
|
||||
const options = clientCertificateStoreSingleton.getClientCertificateAgentOptionsForUrl(
|
||||
urllib.parse(url),
|
||||
)
|
||||
|
||||
expect(options).not.toBeNull()
|
||||
expect(options.ca.length).toEqual(1)
|
||||
expect(options.ca[0]).toEqual(caFileData)
|
||||
expect(options.pfx).toHaveLength(0)
|
||||
expect(options.cert.length).toEqual(1)
|
||||
expect(options.cert[0]).toEqual(pemFileData)
|
||||
expect(options.key.length).toEqual(1)
|
||||
expect(options.key[0].passphrase).toBeUndefined()
|
||||
expect(options.key[0].pem).toEqual(keyFileData)
|
||||
})
|
||||
|
||||
it('loads valid single EC PEM (with passphrase)', () => {
|
||||
const passphrase = 'ec_phrase'
|
||||
|
||||
createPemFiles(
|
||||
pemFilepath,
|
||||
pemKeyFilepath,
|
||||
pemPassphraseFilepath,
|
||||
passphrase,
|
||||
'ec',
|
||||
)
|
||||
|
||||
const url = createUniqueUrl()
|
||||
const config = createSinglePemConfig(
|
||||
url,
|
||||
undefined,
|
||||
pemFilepath,
|
||||
pemKeyFilepath,
|
||||
pemPassphraseFilepath,
|
||||
)
|
||||
const pemFileData = fs.readFileSync(pemFilepath)
|
||||
const keyFileData = fs.readFileSync(pemKeyFilepath)
|
||||
|
||||
loadClientCertificateConfig(config)
|
||||
const options = clientCertificateStoreSingleton.getClientCertificateAgentOptionsForUrl(
|
||||
urllib.parse(url),
|
||||
)
|
||||
|
||||
expect(options).not.toBeNull()
|
||||
expect(options.ca.length).toEqual(0)
|
||||
expect(options.pfx).toHaveLength(0)
|
||||
expect(options.cert.length).toEqual(1)
|
||||
expect(options.cert[0]).toEqual(pemFileData)
|
||||
expect(options.key.length).toEqual(1)
|
||||
expect(options.key[0].passphrase).toEqual(passphrase)
|
||||
expect(options.key[0].pem).toEqual(keyFileData)
|
||||
})
|
||||
|
||||
it('detects invalid EC PEM passphrase', () => {
|
||||
const passphrase = 'ec_phrase'
|
||||
|
||||
createPemFiles(
|
||||
pemFilepath,
|
||||
pemKeyFilepath,
|
||||
pemPassphraseFilepath,
|
||||
passphrase,
|
||||
'ec',
|
||||
)
|
||||
|
||||
fs.writeFileSync(pemPassphraseFilepath, 'not-the-passphrase')
|
||||
|
||||
const url = createUniqueUrl()
|
||||
const config = createSinglePemConfig(
|
||||
url,
|
||||
undefined,
|
||||
pemFilepath,
|
||||
pemKeyFilepath,
|
||||
pemPassphraseFilepath,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
loadClientCertificateConfig(config)
|
||||
}).toThrow(
|
||||
`Cannot decrypt PEM key with supplied passphrase (check the passphrase file content and that it doesn't have unexpected whitespace at the end)`,
|
||||
)
|
||||
})
|
||||
|
||||
// TODO: fix this flaky test
|
||||
it.skip('detects invalid PEM key passphrase', () => {
|
||||
const passphrase = 'a_phrase'
|
||||
@@ -570,7 +674,7 @@ describe('lib/client-certificates', () => {
|
||||
|
||||
expect(() => {
|
||||
loadClientCertificateConfig(config)
|
||||
}).toThrow('Invalid PEM formatted message')
|
||||
}).toThrow('Cannot parse PEM key')
|
||||
})
|
||||
|
||||
it('detects invalid PEM key file (with passphrase)', () => {
|
||||
@@ -596,7 +700,7 @@ describe('lib/client-certificates', () => {
|
||||
|
||||
expect(() => {
|
||||
loadClientCertificateConfig(config)
|
||||
}).toThrow('Invalid PEM formatted message')
|
||||
}).toThrow('Cannot parse PEM key')
|
||||
})
|
||||
|
||||
it('detects invalid PEM cert file', () => {
|
||||
@@ -730,6 +834,33 @@ describe('lib/client-certificates', () => {
|
||||
}).toThrow('no such file or directory')
|
||||
})
|
||||
|
||||
it('loads valid single EC PFX', () => {
|
||||
const passphrase = 'ec_pfx_passphrase'
|
||||
|
||||
createPfxFiles(pfxFilepath, pfxPassphraseFilepath, passphrase, 'ec')
|
||||
|
||||
const url = createUniqueUrl()
|
||||
const config = createSinglePfxConfig(
|
||||
url,
|
||||
undefined,
|
||||
pfxFilepath,
|
||||
pfxPassphraseFilepath,
|
||||
)
|
||||
const pfxFileData = fs.readFileSync(pfxFilepath)
|
||||
|
||||
loadClientCertificateConfig(config)
|
||||
|
||||
const options = clientCertificateStoreSingleton.getClientCertificateAgentOptionsForUrl(
|
||||
urllib.parse(url),
|
||||
)
|
||||
|
||||
expect(options).not.toBeNull()
|
||||
expect(options.cert).toHaveLength(0)
|
||||
expect(options.pfx.length).toEqual(1)
|
||||
expect(options.pfx[0].buf).toEqual(pfxFileData)
|
||||
expect(options.pfx[0].passphrase).toEqual(passphrase)
|
||||
})
|
||||
|
||||
it('loads valid single PFX', () => {
|
||||
const passphrase = 'a_passphrase'
|
||||
|
||||
@@ -772,7 +903,7 @@ describe('lib/client-certificates', () => {
|
||||
|
||||
expect(() => {
|
||||
loadClientCertificateConfig(config)
|
||||
}).toThrow('Invalid password?')
|
||||
}).toThrow('mac verify failure')
|
||||
})
|
||||
|
||||
it('detects missing PFX passphrase file', () => {
|
||||
@@ -789,7 +920,7 @@ describe('lib/client-certificates', () => {
|
||||
|
||||
expect(() => {
|
||||
loadClientCertificateConfig(config)
|
||||
}).toThrow('Invalid password?')
|
||||
}).toThrow('mac verify failure')
|
||||
})
|
||||
|
||||
it('detects invalid PFX file', () => {
|
||||
@@ -804,7 +935,7 @@ describe('lib/client-certificates', () => {
|
||||
|
||||
expect(() => {
|
||||
loadClientCertificateConfig(config)
|
||||
}).toThrow('Unable to load PFX file: Too few bytes to read ASN.1 value')
|
||||
}).toThrow('Unable to load PFX file: not enough data')
|
||||
})
|
||||
|
||||
it('detects missing PFX file', () => {
|
||||
@@ -817,7 +948,7 @@ describe('lib/client-certificates', () => {
|
||||
|
||||
expect(() => {
|
||||
loadClientCertificateConfig(config)
|
||||
}).toThrow('Unable to load PFX file: Too few bytes to read ASN.1 value')
|
||||
}).toThrow('Unable to load PFX file: not enough data')
|
||||
})
|
||||
|
||||
it('detects neither PEM nor PFX supplied', () => {
|
||||
|
||||
@@ -217,6 +217,7 @@ describe('runnables', () => {
|
||||
})
|
||||
|
||||
it('adds a scroll listener in open mode', () => {
|
||||
appState.autoScrollingEnabled = false
|
||||
appState.startRunning()
|
||||
cy.get('.container')
|
||||
.trigger('scroll')
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { asyncRetry, linearDelay } from '../../../util/async_retry'
|
||||
import { isRetryableError } from '../../network/is_retryable_error'
|
||||
import fetch from 'cross-fetch'
|
||||
import os from 'os'
|
||||
import { strictAgent } from '@packages/network'
|
||||
import { PUBLIC_KEY_VERSION } from '../../constants'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { verifySignatureFromFile } from '../../encryption'
|
||||
import { HttpError } from '../../network/http_error'
|
||||
import { SystemError } from '../../network/system_error'
|
||||
|
||||
const pkg = require('@packages/root')
|
||||
const _delay = linearDelay(500)
|
||||
|
||||
export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }): Promise<string> => {
|
||||
let responseSignature: string | null = null
|
||||
let responseManifestSignature: string | null = null
|
||||
|
||||
await (asyncRetry(async () => {
|
||||
try {
|
||||
const response = await fetch(cyPromptUrl, {
|
||||
// @ts-expect-error - this is supported
|
||||
agent: strictAgent,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': PUBLIC_KEY_VERSION,
|
||||
...(projectId ? { 'x-cypress-project-slug': projectId } : {}),
|
||||
'x-os-name': os.platform(),
|
||||
'x-cypress-version': pkg.version,
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await HttpError.fromResponse(response)
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
responseSignature = response.headers.get('x-cypress-signature')
|
||||
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = createWriteStream(bundlePath)
|
||||
|
||||
writeStream.on('error', reject)
|
||||
writeStream.on('finish', () => {
|
||||
resolve()
|
||||
})
|
||||
|
||||
// @ts-expect-error - this is supported
|
||||
response.body?.pipe(writeStream)
|
||||
})
|
||||
} catch (error) {
|
||||
if (HttpError.isHttpError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error.errno || error.code) {
|
||||
const sysError = new SystemError(error, cyPromptUrl, error.code, error.errno)
|
||||
|
||||
sysError.stack = error.stack
|
||||
throw sysError
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}, {
|
||||
maxAttempts: 3,
|
||||
retryDelay: _delay,
|
||||
shouldRetry: (err) => isRetryableError(err, 'GET'),
|
||||
}))()
|
||||
|
||||
if (!responseSignature) {
|
||||
throw new Error('Unable to get cy-prompt signature')
|
||||
}
|
||||
|
||||
if (!responseManifestSignature) {
|
||||
throw new Error('Unable to get cy-prompt manifest signature')
|
||||
}
|
||||
|
||||
const verified = await verifySignatureFromFile(bundlePath, responseSignature)
|
||||
|
||||
if (!verified) {
|
||||
throw new Error('Unable to verify cy-prompt signature')
|
||||
}
|
||||
|
||||
return responseManifestSignature
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { asyncRetry, linearDelay } from '../../../util/async_retry'
|
||||
import { isRetryableError } from '../../network/is_retryable_error'
|
||||
import fetch from 'cross-fetch'
|
||||
import os from 'os'
|
||||
import { strictAgent } from '@packages/network'
|
||||
import { PUBLIC_KEY_VERSION } from '../../constants'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { verifySignatureFromFile } from '../../encryption'
|
||||
import { HttpError } from '../../network/http_error'
|
||||
import { SystemError } from '../../network/system_error'
|
||||
import Debug from 'debug'
|
||||
|
||||
const pkg = require('@packages/root')
|
||||
const _delay = linearDelay(500)
|
||||
const debug = Debug('cypress:server:cloud:api:studio:get_studio_bundle')
|
||||
|
||||
const DEFAULT_TIMEOUT = 25000
|
||||
|
||||
export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise<string> => {
|
||||
let responseSignature: string | null = null
|
||||
let responseManifestSignature: string | null = null
|
||||
|
||||
await (asyncRetry(async () => {
|
||||
const controller = new AbortController()
|
||||
const fetchTimeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, DEFAULT_TIMEOUT)
|
||||
|
||||
try {
|
||||
debug('Fetching studio bundle from %s', studioUrl)
|
||||
|
||||
const response = await fetch(studioUrl, {
|
||||
// @ts-expect-error - this is supported
|
||||
agent: strictAgent,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': PUBLIC_KEY_VERSION,
|
||||
'x-os-name': os.platform(),
|
||||
'x-cypress-version': pkg.version,
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await HttpError.fromResponse(response)
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
responseSignature = response.headers.get('x-cypress-signature')
|
||||
responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = createWriteStream(bundlePath)
|
||||
|
||||
writeStream.on('error', (err) => {
|
||||
writeStream.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
resolve()
|
||||
})
|
||||
|
||||
// @ts-expect-error - this is supported
|
||||
response.body?.pipe(writeStream)
|
||||
})
|
||||
|
||||
// Check if the operation was aborted due to timeout
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error('Studio bundle fetch timed out')
|
||||
}
|
||||
|
||||
clearTimeout(fetchTimeout)
|
||||
} catch (error) {
|
||||
debug('Error fetching studio bundle from %s: %o', studioUrl, error)
|
||||
|
||||
clearTimeout(fetchTimeout)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Studio bundle fetch timed out')
|
||||
}
|
||||
|
||||
if (HttpError.isHttpError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error.errno || error.code) {
|
||||
const sysError = new SystemError(error, studioUrl, error.code, error.errno)
|
||||
|
||||
sysError.stack = error.stack
|
||||
throw sysError
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}, {
|
||||
maxAttempts: 3,
|
||||
retryDelay: _delay,
|
||||
shouldRetry: (err) => isRetryableError(err, 'GET'),
|
||||
}))()
|
||||
|
||||
if (!responseSignature) {
|
||||
throw new Error('Unable to get studio signature')
|
||||
}
|
||||
|
||||
if (!responseManifestSignature) {
|
||||
throw new Error('Unable to get studio manifest signature')
|
||||
}
|
||||
|
||||
const verified = await verifySignatureFromFile(bundlePath, responseSignature)
|
||||
|
||||
if (!verified) {
|
||||
throw new Error('Unable to verify studio signature')
|
||||
}
|
||||
|
||||
debug('Studio bundle fetched successfully from %s', studioUrl)
|
||||
|
||||
return responseManifestSignature
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export type BundleKind = 'cy-prompt' | 'studio'
|
||||
|
||||
export type BundleErrorStage =
|
||||
| 'network'
|
||||
| 'signature'
|
||||
| 'extract'
|
||||
| 'manifest'
|
||||
| 'publish'
|
||||
|
||||
export class BundleError extends Error {
|
||||
public readonly name = 'BundleError'
|
||||
public readonly kind: BundleKind
|
||||
public readonly stage: BundleErrorStage
|
||||
// Mirrored from `cause.code` so consumers reading `error.code` keep working.
|
||||
public readonly code?: string
|
||||
|
||||
constructor (options: { kind: BundleKind, stage: BundleErrorStage, message: string, cause?: unknown }) {
|
||||
super(options.message)
|
||||
this.kind = options.kind
|
||||
this.stage = options.stage
|
||||
|
||||
if (options.cause !== undefined) {
|
||||
(this as Error & { cause?: unknown }).cause = options.cause
|
||||
|
||||
const causeCode = (options.cause as { code?: unknown } | null | undefined)?.code
|
||||
|
||||
if (typeof causeCode === 'string') {
|
||||
this.code = causeCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static isBundleError (err: unknown): err is BundleError {
|
||||
return err instanceof Error && (err as Error).name === 'BundleError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import path from 'path'
|
||||
import cachedir from 'cachedir'
|
||||
import untildify from 'untildify'
|
||||
|
||||
const BUNDLES_DIRNAME = 'bundles'
|
||||
|
||||
// Matches the CLI's getEnv(varName, /* trim */ true) / dequote() in
|
||||
// cli/lib/tasks/state.ts so the two sides resolve identical paths.
|
||||
const dequote = (str: string): string => {
|
||||
if (str.length > 1 && str[0] === '"' && str[str.length - 1] === '"') {
|
||||
return str.slice(1, -1)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
const readEnvVar = (varName: string): string | undefined => {
|
||||
const candidates = [
|
||||
varName,
|
||||
`npm_config_${varName}`,
|
||||
`npm_config_${varName.toLowerCase()}`,
|
||||
`npm_package_config_${varName}`,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (Object.prototype.hasOwnProperty.call(process.env, candidate)) {
|
||||
const raw = process.env[candidate]
|
||||
|
||||
if (raw === undefined) continue
|
||||
|
||||
return dequote(raw.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const resolveCypressCacheRoot = (): string => {
|
||||
const override = readEnvVar('CYPRESS_CACHE_FOLDER')
|
||||
|
||||
if (override) {
|
||||
return path.resolve(untildify(override))
|
||||
}
|
||||
|
||||
return cachedir('Cypress')
|
||||
}
|
||||
|
||||
const getBundleCacheRoot = (): string => {
|
||||
return path.join(resolveCypressCacheRoot(), BUNDLES_DIRNAME)
|
||||
}
|
||||
|
||||
export const getBundleCacheDir = (kind: 'cy-prompt' | 'studio'): string => {
|
||||
return path.join(getBundleCacheRoot(), kind)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ensureDir, readFile, remove } from 'fs-extra'
|
||||
import path from 'path'
|
||||
import Debug from 'debug'
|
||||
import { verifySignature } from '../encryption'
|
||||
import { getBundleCacheDir } from './cache_root'
|
||||
import { parseHashFromBundleUrl } from './parse_hash_from_bundle_url'
|
||||
import { sweepOrphanStaging } from './sweep_orphan_staging'
|
||||
import { streamDownloadVerifyExtract } from './stream_download_verify_extract'
|
||||
import { publishStagingToFinal } from './publish_staging_to_final'
|
||||
import { BundleError, BundleKind } from './bundle_error'
|
||||
|
||||
const debug = Debug('cypress:server:cloud:bundles:ensure-signed-bundle')
|
||||
|
||||
const ORPHAN_STAGING_TTL_MS = 60 * 60 * 1000
|
||||
const STAGING_PREFIX = '.staging-'
|
||||
|
||||
interface EnsureSignedBundleOptions {
|
||||
url: string
|
||||
projectId?: string
|
||||
kind: BundleKind
|
||||
}
|
||||
|
||||
interface EnsureSignedBundleResult {
|
||||
manifest: Record<string, string>
|
||||
bundleDir: string
|
||||
}
|
||||
|
||||
const randomSuffix = (): string => Math.random().toString(36).substring(2, 15)
|
||||
|
||||
export const ensureSignedBundle = async ({
|
||||
url,
|
||||
projectId,
|
||||
kind,
|
||||
}: EnsureSignedBundleOptions): Promise<EnsureSignedBundleResult> => {
|
||||
const hash = parseHashFromBundleUrl(url)
|
||||
const baseDir = getBundleCacheDir(kind)
|
||||
const finalDir = path.join(baseDir, hash)
|
||||
const staging = path.join(baseDir, `${STAGING_PREFIX}${randomSuffix()}`)
|
||||
|
||||
debug('ensuring %s bundle hash=%s baseDir=%s', kind, hash, baseDir)
|
||||
|
||||
await ensureDir(baseDir)
|
||||
await ensureDir(finalDir)
|
||||
|
||||
const sweptCount = await sweepOrphanStaging(baseDir, ORPHAN_STAGING_TTL_MS).catch(() => 0)
|
||||
|
||||
if (sweptCount > 0) debug('swept %d orphan staging dir(s) under %s', sweptCount, baseDir)
|
||||
|
||||
try {
|
||||
const manifestSig = await streamDownloadVerifyExtract({ url, projectId, staging, kind })
|
||||
|
||||
const manifestPath = path.join(staging, 'manifest.json')
|
||||
let manifestText: string
|
||||
|
||||
try {
|
||||
manifestText = await readFile(manifestPath, 'utf8')
|
||||
} catch (err: any) {
|
||||
throw new BundleError({
|
||||
kind,
|
||||
stage: 'manifest',
|
||||
message: `Unable to read ${kind} manifest from staging`,
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
|
||||
const verified = await verifySignature(manifestText, manifestSig)
|
||||
|
||||
if (!verified) {
|
||||
throw new BundleError({
|
||||
kind,
|
||||
stage: 'manifest',
|
||||
message: `Unable to verify ${kind} manifest signature`,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await publishStagingToFinal(staging, finalDir)
|
||||
} catch (err: any) {
|
||||
throw new BundleError({
|
||||
kind,
|
||||
stage: 'publish',
|
||||
message: `Failed to publish ${kind} bundle from staging to ${finalDir}`,
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
|
||||
debug('%s bundle published to %s', kind, finalDir)
|
||||
|
||||
return {
|
||||
manifest: JSON.parse(manifestText) as Record<string, string>,
|
||||
bundleDir: finalDir,
|
||||
}
|
||||
} finally {
|
||||
await remove(staging).catch(() => { /* ignore */ })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const parseHashFromBundleUrl = (url: string): string => {
|
||||
const fileName = url.split('/').pop()
|
||||
const hash = fileName?.split('.')[0]
|
||||
|
||||
if (!hash) {
|
||||
throw new Error(`Unable to parse bundle hash from URL: ${url}`)
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readdir, stat, ensureDir } from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { renameAtomicWithRetry } from '../extract_atomic'
|
||||
|
||||
const MANIFEST_REL = 'manifest.json'
|
||||
|
||||
const walkFiles = async (root: string, currentRel: string = ''): Promise<string[]> => {
|
||||
const fullDir = path.join(root, currentRel)
|
||||
const entries = await readdir(fullDir)
|
||||
|
||||
const nested = await Promise.all(entries.map(async (entry): Promise<string[]> => {
|
||||
const entryRel = path.join(currentRel, entry)
|
||||
const entryFull = path.join(root, entryRel)
|
||||
const entryStat = await stat(entryFull)
|
||||
|
||||
if (entryStat.isDirectory()) return walkFiles(root, entryRel)
|
||||
|
||||
if (entryStat.isFile()) return [entryRel]
|
||||
|
||||
return []
|
||||
}))
|
||||
|
||||
return nested.flat()
|
||||
}
|
||||
|
||||
const publishOne = async (staging: string, finalDir: string, rel: string): Promise<void> => {
|
||||
const src = path.join(staging, rel)
|
||||
const dst = path.join(finalDir, rel)
|
||||
|
||||
await ensureDir(path.dirname(dst))
|
||||
await renameAtomicWithRetry(src, dst)
|
||||
}
|
||||
|
||||
export const publishStagingToFinal = async (staging: string, finalDir: string): Promise<void> => {
|
||||
const allFiles = await walkFiles(staging)
|
||||
const others = allFiles.filter((rel) => rel !== MANIFEST_REL)
|
||||
const hasManifest = allFiles.includes(MANIFEST_REL)
|
||||
|
||||
const otherPromises = others.map((rel) => publishOne(staging, finalDir, rel))
|
||||
|
||||
try {
|
||||
await Promise.all(otherPromises)
|
||||
} catch (err) {
|
||||
await Promise.allSettled(otherPromises)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (hasManifest) {
|
||||
await publishOne(staging, finalDir, MANIFEST_REL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { createWriteStream } from 'fs'
|
||||
import { ensureDir, remove } from 'fs-extra'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import { Transform } from 'stream'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import tar from 'tar'
|
||||
import fetch from 'cross-fetch'
|
||||
import Debug from 'debug'
|
||||
import { strictAgent } from '@packages/network'
|
||||
import { asyncRetry, linearDelay } from '../../util/async_retry'
|
||||
import { isRetryableError } from '../network/is_retryable_error'
|
||||
import { HttpError } from '../network/http_error'
|
||||
import { SystemError } from '../network/system_error'
|
||||
import { PUBLIC_KEY_VERSION } from '../constants'
|
||||
import { createStreamingSignatureVerifier } from '../encryption'
|
||||
import { BundleError, BundleErrorStage, BundleKind } from './bundle_error'
|
||||
|
||||
const pkg = require('@packages/root')
|
||||
|
||||
const debug = Debug('cypress:server:cloud:bundles:stream-download-verify-extract')
|
||||
|
||||
const FETCH_TIMEOUT_MS = 25000
|
||||
const MAX_ATTEMPTS = 3
|
||||
const RETRY_DELAY_MS = 500
|
||||
|
||||
interface StreamDownloadVerifyExtractOptions {
|
||||
url: string
|
||||
projectId?: string
|
||||
staging: string
|
||||
kind: BundleKind
|
||||
}
|
||||
|
||||
const isInsideDir = (parent: string, child: string): boolean => {
|
||||
const rel = path.relative(parent, child)
|
||||
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
|
||||
}
|
||||
|
||||
const buildHeaders = (projectId: string | undefined): Record<string, string> => {
|
||||
return {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': PUBLIC_KEY_VERSION,
|
||||
...(projectId ? { 'x-cypress-project-slug': projectId } : {}),
|
||||
'x-os-name': os.platform(),
|
||||
'x-cypress-version': pkg.version,
|
||||
}
|
||||
}
|
||||
|
||||
// POSIX-style errno codes (ECONNRESET, ETIMEDOUT, EAI_AGAIN, ...).
|
||||
// Excludes Node's ERR_* codes so a thrown TypeError isn't misclassified.
|
||||
const isPosixSyscallError = (err: any): boolean => {
|
||||
if (err?.errno !== undefined) return true
|
||||
|
||||
const code = err?.code
|
||||
|
||||
if (typeof code !== 'string') return false
|
||||
|
||||
if (code.startsWith('ERR_')) return false
|
||||
|
||||
return /^E[A-Z]/.test(code)
|
||||
}
|
||||
|
||||
// Network-class POSIX codes that can surface mid-pipeline from the response
|
||||
// body stream (TCP drop, DNS flake, etc.). Everything else syscall-y during
|
||||
// extract (ENOSPC, EACCES, EROFS, EIO, ...) is a filesystem error from the
|
||||
// tar-entry write — non-transient, must not be retried.
|
||||
const NETWORK_SYSCALL_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'ECONNABORTED',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'EHOSTUNREACH',
|
||||
'EHOSTDOWN',
|
||||
'ENETUNREACH',
|
||||
'ENETDOWN',
|
||||
'ENOTFOUND',
|
||||
'EPIPE',
|
||||
'EAGAIN',
|
||||
])
|
||||
|
||||
const isNetworkSyscallError = (err: any): boolean => {
|
||||
const code = err?.code
|
||||
|
||||
return typeof code === 'string' && NETWORK_SYSCALL_CODES.has(code)
|
||||
}
|
||||
|
||||
// Wraps fetch/pipeline errors as BundleError. `cause` carries the underlying
|
||||
// HttpError/SystemError so asyncRetry's shouldRetry can classify via
|
||||
// isRetryableError. `defaultStage` is used when the err has no HTTP/syscall
|
||||
// shape: 'network' for the fetch phase, 'extract' for the pipeline phase.
|
||||
const wrapAsBundleError = (
|
||||
err: any,
|
||||
url: string,
|
||||
kind: BundleKind,
|
||||
defaultStage: BundleErrorStage,
|
||||
): BundleError => {
|
||||
if (err?.name === 'AbortError') {
|
||||
const message = `${kind} bundle fetch timed out after ${FETCH_TIMEOUT_MS}ms`
|
||||
const sysError = new SystemError(new Error(message), url, 'ETIMEDOUT', undefined)
|
||||
|
||||
return new BundleError({ kind, stage: 'network', message, cause: sysError })
|
||||
}
|
||||
|
||||
if (HttpError.isHttpError(err)) {
|
||||
return new BundleError({
|
||||
kind,
|
||||
stage: 'network',
|
||||
message: `${kind} bundle fetch failed with HTTP ${err.status} ${err.statusText ?? ''}`.trim(),
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
|
||||
if (isPosixSyscallError(err)) {
|
||||
if (defaultStage === 'network' || isNetworkSyscallError(err)) {
|
||||
const sysError = SystemError.isSystemError(err)
|
||||
? err
|
||||
: Object.assign(new SystemError(err, url, err.code, err.errno), { stack: err.stack })
|
||||
|
||||
return new BundleError({
|
||||
kind,
|
||||
stage: 'network',
|
||||
message: `${kind} bundle network error: ${err.message ?? err.code}`,
|
||||
cause: sysError,
|
||||
})
|
||||
}
|
||||
|
||||
return new BundleError({
|
||||
kind,
|
||||
stage: defaultStage,
|
||||
message: `${kind} bundle ${defaultStage} failed: ${err.message ?? err.code}`,
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
|
||||
return new BundleError({
|
||||
kind,
|
||||
stage: defaultStage,
|
||||
message: `${kind} bundle ${defaultStage} failed: ${err?.message ?? String(err)}`,
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
|
||||
const runDownloadAttempt = async ({ url, projectId, staging, kind }: StreamDownloadVerifyExtractOptions): Promise<string> => {
|
||||
// Each attempt starts from a clean staging dir so retries can't see
|
||||
// partial bytes from the previous attempt.
|
||||
await remove(staging).catch(() => { /* ignore */ })
|
||||
await ensureDir(staging)
|
||||
|
||||
const verifier = createStreamingSignatureVerifier()
|
||||
const tee = new Transform({
|
||||
transform (chunk, _enc, cb) {
|
||||
verifier.update(chunk)
|
||||
cb(null, chunk)
|
||||
},
|
||||
})
|
||||
|
||||
const parser = new tar.Parse({ strict: true })
|
||||
const entryPromises: Promise<void>[] = []
|
||||
|
||||
parser.on('entry', (entry) => {
|
||||
if (entry.type !== 'File') {
|
||||
entry.resume()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(staging, entry.path)
|
||||
|
||||
if (!isInsideDir(staging, targetPath)) {
|
||||
debug('rejecting entry outside staging: %s', entry.path)
|
||||
entry.resume()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const writePromise = (async () => {
|
||||
await ensureDir(path.dirname(targetPath))
|
||||
|
||||
const ws = createWriteStream(targetPath, { mode: entry.mode || 0o644 })
|
||||
|
||||
await pipeline(entry, ws)
|
||||
})()
|
||||
|
||||
entryPromises.push(writePromise)
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const fetchTimeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
|
||||
let bundleSig: string | null = null
|
||||
let manifestSig: string | null = null
|
||||
|
||||
try {
|
||||
debug('fetching %s bundle from %s', kind, url)
|
||||
|
||||
let response: Awaited<ReturnType<typeof fetch>>
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
// @ts-expect-error - this is supported
|
||||
agent: strictAgent,
|
||||
method: 'GET',
|
||||
headers: buildHeaders(projectId),
|
||||
encrypt: 'signed',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw await HttpError.fromResponse(response)
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw wrapAsBundleError(err, url, kind, 'network')
|
||||
}
|
||||
|
||||
bundleSig = response.headers.get('x-cypress-signature')
|
||||
manifestSig = response.headers.get('x-cypress-manifest-signature')
|
||||
|
||||
try {
|
||||
// @ts-expect-error - response.body is a Node Readable in cross-fetch's Node runtime
|
||||
await pipeline(response.body, tee, parser)
|
||||
await Promise.all(entryPromises)
|
||||
} catch (err: any) {
|
||||
// Drain any in-flight entry writes so they don't surface as unhandled
|
||||
// rejections after the pipeline has already errored.
|
||||
await Promise.allSettled(entryPromises)
|
||||
|
||||
throw wrapAsBundleError(err, url, kind, 'extract')
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(fetchTimeout)
|
||||
}
|
||||
|
||||
if (!bundleSig) {
|
||||
throw new BundleError({ kind, stage: 'signature', message: `Unable to get ${kind} bundle signature` })
|
||||
}
|
||||
|
||||
if (!manifestSig) {
|
||||
throw new BundleError({ kind, stage: 'signature', message: `Unable to get ${kind} manifest signature` })
|
||||
}
|
||||
|
||||
if (!verifier.verify(bundleSig)) {
|
||||
throw new BundleError({ kind, stage: 'signature', message: `Unable to verify ${kind} bundle signature` })
|
||||
}
|
||||
|
||||
debug('%s bundle stream verified', kind)
|
||||
|
||||
return manifestSig
|
||||
}
|
||||
|
||||
// All bundle fetches are GET, so pass that through to isRetryableError so it
|
||||
// retries HTTP 500 in addition to the always-retryable statuses.
|
||||
const shouldRetryBundleError = (err: unknown): boolean => {
|
||||
if (BundleError.isBundleError(err)) {
|
||||
const cause = (err as Error & { cause?: unknown }).cause
|
||||
|
||||
return cause !== undefined && isRetryableError(cause, 'GET')
|
||||
}
|
||||
|
||||
return isRetryableError(err, 'GET')
|
||||
}
|
||||
|
||||
export const streamDownloadVerifyExtract = async (options: StreamDownloadVerifyExtractOptions): Promise<string> => {
|
||||
return asyncRetry(runDownloadAttempt, {
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
retryDelay: linearDelay(RETRY_DELAY_MS),
|
||||
shouldRetry: shouldRetryBundleError,
|
||||
onRetry: (delayMs, err) => {
|
||||
debug('retrying %s bundle download in %dms after error: %o', options.kind, delayMs, err)
|
||||
},
|
||||
})(options)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readdir, stat, remove } from 'fs-extra'
|
||||
import path from 'path'
|
||||
import Debug from 'debug'
|
||||
|
||||
const debug = Debug('cypress:server:cloud:bundles:sweep-orphan-staging')
|
||||
|
||||
const STAGING_PREFIX = '.staging-'
|
||||
|
||||
export const sweepOrphanStaging = async (baseDir: string, olderThanMs: number): Promise<number> => {
|
||||
let entries: string[]
|
||||
|
||||
try {
|
||||
entries = await readdir(baseDir)
|
||||
} catch (err) {
|
||||
debug('readdir failed for %s: %o', baseDir, err)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const results = await Promise.all(entries.map(async (entry): Promise<boolean> => {
|
||||
if (!entry.startsWith(STAGING_PREFIX)) return false
|
||||
|
||||
const fullPath = path.join(baseDir, entry)
|
||||
|
||||
try {
|
||||
const stats = await stat(fullPath)
|
||||
const age = now - stats.mtimeMs
|
||||
|
||||
if (age < olderThanMs) return false
|
||||
|
||||
await remove(fullPath)
|
||||
debug('removed orphan staging dir %s (age %dms)', fullPath, age)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
debug('failed to sweep %s: %o', fullPath, err)
|
||||
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
return results.filter(Boolean).length
|
||||
}
|
||||
@@ -7,20 +7,28 @@ import { isRetryableError } from '../network/is_retryable_error'
|
||||
import { asyncRetry } from '../../util/async_retry'
|
||||
import { postCyPromptSession } from '../api/cy-prompt/post_cy_prompt_session'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { readFile } from 'fs-extra'
|
||||
import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle'
|
||||
import { parseHashFromBundleUrl } from '../bundles/parse_hash_from_bundle_url'
|
||||
import chokidar from 'chokidar'
|
||||
import { getCloudMetadata } from '../get_cloud_metadata'
|
||||
import type { CyPromptAuthenticatedUserShape, CyPromptServerOptions } from '@packages/types'
|
||||
import crypto from 'crypto'
|
||||
import { reportCyPromptError } from '../api/cy-prompt/report_cy_prompt_error'
|
||||
|
||||
import { GracefulExit } from '../../util/graceful-exit'
|
||||
import type { ExitStepKey } from '../../util/graceful-exit'
|
||||
const debug = Debug('cypress:server:cy-prompt-lifecycle-manager')
|
||||
|
||||
export class CyPromptLifecycleManager {
|
||||
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
|
||||
private static hashLoadingMap: Map<string, Promise<{ manifest: Record<string, string>, cyPromptPath: string }>> = new Map()
|
||||
private static watcher: chokidar.FSWatcher | null = null
|
||||
private static teardown: ExitStepKey | null = null
|
||||
|
||||
static async close () {
|
||||
CyPromptLifecycleManager.watcher?.removeAllListeners()
|
||||
await CyPromptLifecycleManager.watcher?.close().catch(() => {})
|
||||
}
|
||||
|
||||
private cyPromptManagerPromise?: Promise<{
|
||||
cyPromptManager?: CyPromptManager
|
||||
error?: Error
|
||||
@@ -178,9 +186,7 @@ export class CyPromptLifecycleManager {
|
||||
})
|
||||
|
||||
if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
|
||||
// The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension
|
||||
this.cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] as string
|
||||
cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', this.cyPromptHash)
|
||||
this.cyPromptHash = parseHashFromBundleUrl(cyPromptSession.cyPromptUrl)
|
||||
|
||||
let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(this.cyPromptHash)
|
||||
|
||||
@@ -188,13 +194,15 @@ export class CyPromptLifecycleManager {
|
||||
hashLoadingPromise = ensureCyPromptBundle({
|
||||
cyPromptUrl: cyPromptSession.cyPromptUrl,
|
||||
projectId,
|
||||
cyPromptPath,
|
||||
})
|
||||
|
||||
CyPromptLifecycleManager.hashLoadingMap.set(this.cyPromptHash, hashLoadingPromise)
|
||||
}
|
||||
|
||||
manifest = await hashLoadingPromise
|
||||
const result = await hashLoadingPromise
|
||||
|
||||
manifest = result.manifest
|
||||
cyPromptPath = result.cyPromptPath
|
||||
} else {
|
||||
cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH
|
||||
this.cyPromptHash = 'local'
|
||||
@@ -282,10 +290,18 @@ export class CyPromptLifecycleManager {
|
||||
|
||||
// Close the watcher if a previous watcher exists
|
||||
if (CyPromptLifecycleManager.watcher) {
|
||||
CyPromptLifecycleManager.watcher.removeAllListeners()
|
||||
CyPromptLifecycleManager.watcher.close().catch(() => {})
|
||||
CyPromptLifecycleManager.close().catch(() => {})
|
||||
}
|
||||
|
||||
if (CyPromptLifecycleManager.teardown) {
|
||||
GracefulExit.removeStep(CyPromptLifecycleManager.teardown)
|
||||
CyPromptLifecycleManager.teardown = null
|
||||
}
|
||||
|
||||
CyPromptLifecycleManager.teardown = GracefulExit.addStep(async () => {
|
||||
await CyPromptLifecycleManager.close()
|
||||
}, 'close cy prompt watcher')
|
||||
|
||||
// Watch for changes to the cy prompt bundle
|
||||
CyPromptLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'index.js'), {
|
||||
awaitWriteFinish: true,
|
||||
|
||||
@@ -1,51 +1,27 @@
|
||||
import { ensureDir, readFile, pathExists, remove } from 'fs-extra'
|
||||
import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle'
|
||||
import path from 'path'
|
||||
import { verifySignature } from '../encryption'
|
||||
import { extractAtomic } from '../extract_atomic'
|
||||
import { ensureSignedBundle } from '../bundles/ensure_signed_bundle'
|
||||
|
||||
interface EnsureCyPromptBundleOptions {
|
||||
cyPromptPath: string
|
||||
cyPromptUrl: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the cy prompt bundle is downloaded and extracted into the given path
|
||||
* @param options - The options for the ensure cy prompt bundle operation
|
||||
* @param options.cyPromptPath - The path to extract the cy prompt bundle to
|
||||
* @param options.cyPromptUrl - The URL of the cy prompt bundle
|
||||
* @param options.projectId - The project ID of the cy prompt bundle
|
||||
*/
|
||||
export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions): Promise<Record<string, string>> => {
|
||||
const bundlePath = path.join(cyPromptPath, 'bundle.tar')
|
||||
|
||||
await ensureDir(cyPromptPath)
|
||||
|
||||
const uniqueBundlePath = `${bundlePath}-${Math.random().toString(36).substring(2, 15)}`
|
||||
const responseManifestSignature: string = await getCyPromptBundle({
|
||||
cyPromptUrl,
|
||||
projectId,
|
||||
bundlePath: uniqueBundlePath,
|
||||
})
|
||||
|
||||
await extractAtomic(uniqueBundlePath, cyPromptPath).finally(async () => {
|
||||
await remove(uniqueBundlePath).catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
const manifestPath = path.join(cyPromptPath, 'manifest.json')
|
||||
|
||||
if (!(await pathExists(manifestPath))) {
|
||||
throw new Error('Unable to find cy-prompt manifest')
|
||||
}
|
||||
|
||||
const manifestContents = await readFile(manifestPath, 'utf8')
|
||||
|
||||
const verified = await verifySignature(manifestContents, responseManifestSignature)
|
||||
|
||||
if (!verified) {
|
||||
throw new Error('Unable to verify cy-prompt signature')
|
||||
}
|
||||
|
||||
return JSON.parse(manifestContents)
|
||||
interface EnsureCyPromptBundleResult {
|
||||
manifest: Record<string, string>
|
||||
cyPromptPath: string
|
||||
}
|
||||
|
||||
export const ensureCyPromptBundle = async ({
|
||||
cyPromptUrl,
|
||||
projectId,
|
||||
}: EnsureCyPromptBundleOptions): Promise<EnsureCyPromptBundleResult> => {
|
||||
const { manifest, bundleDir } = await ensureSignedBundle({
|
||||
url: cyPromptUrl,
|
||||
projectId,
|
||||
kind: 'cy-prompt',
|
||||
})
|
||||
|
||||
return {
|
||||
manifest,
|
||||
cyPromptPath: bundleDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,23 +55,17 @@ export function verifySignature (body: BinaryLike, signature: string, publicKey?
|
||||
return verify.verify(publicKey || getPublicKey(), signature, 'base64')
|
||||
}
|
||||
|
||||
export function verifySignatureFromFile (file: string, signature: string, publicKey?: crypto.KeyObject): Promise<boolean> {
|
||||
export function createStreamingSignatureVerifier (publicKey?: crypto.KeyObject) {
|
||||
const verify = crypto.createVerify('SHA256')
|
||||
|
||||
const stream = fs.createReadStream(file)
|
||||
|
||||
stream.on('data', (chunk: crypto.BinaryLike) => {
|
||||
verify.update(chunk)
|
||||
})
|
||||
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
stream.on('end', () => {
|
||||
verify.end()
|
||||
resolve(verify.verify(publicKey || getPublicKey(), signature, 'base64'))
|
||||
})
|
||||
|
||||
stream.on('error', reject)
|
||||
})
|
||||
return {
|
||||
update (chunk: BinaryLike) {
|
||||
verify.update(chunk)
|
||||
},
|
||||
verify (signature: string): boolean {
|
||||
return verify.verify(publicKey || getPublicKey(), signature, 'base64')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Implements the https://www.rfc-editor.org/rfc/rfc7516 spec
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import tar from 'tar'
|
||||
import { ensureDir } from 'fs-extra'
|
||||
import path from 'path'
|
||||
import writeFileAtomic from 'write-file-atomic'
|
||||
import { rename } from 'fs-extra'
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 100
|
||||
@@ -12,27 +8,19 @@ function isRetryableError (err: unknown): err is NodeJS.ErrnoException {
|
||||
? (err as NodeJS.ErrnoException).code
|
||||
: undefined
|
||||
|
||||
return code === 'EPERM' || code === 'EACCES'
|
||||
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
|
||||
}
|
||||
|
||||
function delay (ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function writeFileAtomicWithRetry (
|
||||
filePath: string,
|
||||
content: Buffer,
|
||||
options: { mode?: number },
|
||||
): Promise<void> {
|
||||
async function retryOnRenameError<T> (op: () => Promise<T>): Promise<T> {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await writeFileAtomic(filePath, content, {
|
||||
mode: options.mode || 0o644,
|
||||
})
|
||||
|
||||
return
|
||||
return await op()
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
if (attempt < MAX_RETRIES && isRetryableError(err)) {
|
||||
@@ -46,52 +34,6 @@ async function writeFileAtomicWithRetry (
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export const extractAtomic = async (archivePath: string, destinationPath: string) => {
|
||||
const entryPromises: Promise<void>[] = []
|
||||
|
||||
const parser = new tar.Parse()
|
||||
|
||||
parser.on('entry', (entry) => {
|
||||
if (entry.type !== 'File') {
|
||||
entry.resume() // skip non-files
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const targetPath = path.join(destinationPath, entry.path)
|
||||
|
||||
const p = (async () => {
|
||||
await ensureDir(path.dirname(targetPath))
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
for await (const chunk of entry) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const content = Buffer.concat(chunks)
|
||||
|
||||
await writeFileAtomicWithRetry(targetPath, content, {
|
||||
mode: entry.mode || 0o644,
|
||||
})
|
||||
})()
|
||||
|
||||
entryPromises.push(p)
|
||||
})
|
||||
|
||||
// Pipe archive into parser
|
||||
const stream = createReadStream(archivePath)
|
||||
|
||||
stream.pipe(parser)
|
||||
|
||||
// Wait for parser to finish and all entry writes to complete
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
parser.on('end', resolve)
|
||||
// @ts-expect-error Parser extends NodeJS.ReadWriteStream (EventEmitter), so it supports 'error' events
|
||||
// even though the types don't explicitly declare it
|
||||
parser.on('error', reject)
|
||||
stream.on('error', reject)
|
||||
})
|
||||
|
||||
await Promise.all(entryPromises)
|
||||
export async function renameAtomicWithRetry (src: string, dst: string): Promise<void> {
|
||||
await retryOnRenameError(() => rename(src, dst))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { asyncRetry } from '../../util/async_retry'
|
||||
import { postStudioSession } from '../api/studio/post_studio_session'
|
||||
import type { StudioServerOptions, StudioStatus } from '@packages/types'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { parseHashFromBundleUrl } from '../bundles/parse_hash_from_bundle_url'
|
||||
import { ensureStudioBundle } from './ensure_studio_bundle'
|
||||
import chokidar from 'chokidar'
|
||||
import { readFile } from 'fs/promises'
|
||||
@@ -26,12 +26,15 @@ import crypto from 'crypto'
|
||||
import { logError } from '@packages/stderr-filtering'
|
||||
import { isNonRetriableCertErrorCode } from '../network/non_retriable_cert_error_codes'
|
||||
import type { DebugData } from '@packages/types'
|
||||
import { GracefulExit } from '../../util/graceful-exit'
|
||||
import type { ExitStepKey } from '../../util/graceful-exit'
|
||||
|
||||
const debug = Debug('cypress:server:studio-lifecycle-manager')
|
||||
const routes = require('../routes')
|
||||
|
||||
export class StudioLifecycleManager {
|
||||
private static hashLoadingMap: Map<string, Promise<Record<string, string>>> = new Map()
|
||||
private static teardown: ExitStepKey | null = null
|
||||
private static hashLoadingMap: Map<string, Promise<{ manifest: Record<string, string>, studioPath: string }>> = new Map()
|
||||
private static watcher: chokidar.FSWatcher | null = null
|
||||
private studioManagerPromise?: Promise<StudioManager | null>
|
||||
private studioManager?: StudioManager
|
||||
@@ -56,7 +59,7 @@ export class StudioLifecycleManager {
|
||||
* @param debugData Debug data for the configuration
|
||||
* @param ctx Data context to register this instance with
|
||||
*/
|
||||
initializeStudioManager ({
|
||||
async initializeStudioManager ({
|
||||
cloudDataSource,
|
||||
cfg,
|
||||
debugData,
|
||||
@@ -66,7 +69,7 @@ export class StudioLifecycleManager {
|
||||
cfg: Cfg
|
||||
debugData: any
|
||||
ctx: DataContext
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
debug('Initializing studio manager')
|
||||
|
||||
// Store initialization parameters for retry
|
||||
@@ -134,7 +137,7 @@ export class StudioLifecycleManager {
|
||||
|
||||
this.studioManagerPromise = studioManagerPromise
|
||||
|
||||
this.setupWatcher({
|
||||
await this.setupWatcher({
|
||||
cloudDataSource,
|
||||
cfg,
|
||||
debugData,
|
||||
@@ -198,10 +201,7 @@ export class StudioLifecycleManager {
|
||||
|
||||
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_START)
|
||||
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
|
||||
// The studio hash is the last part of the studio URL, after the last slash and before the extension
|
||||
const studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] as string
|
||||
|
||||
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash)
|
||||
const studioHash = parseHashFromBundleUrl(studioSession.studioUrl)
|
||||
|
||||
debug('Setting current studio hash: %s', studioHash)
|
||||
// Store the current studio hash so that we can clear the cache entry when retrying
|
||||
@@ -214,14 +214,16 @@ export class StudioLifecycleManager {
|
||||
|
||||
hashLoadingPromise = ensureStudioBundle({
|
||||
studioUrl: studioSession.studioUrl,
|
||||
studioPath,
|
||||
projectId: currentProjectOptions.projectSlug,
|
||||
})
|
||||
|
||||
StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
|
||||
}
|
||||
|
||||
manifest = await hashLoadingPromise
|
||||
const result = await hashLoadingPromise
|
||||
|
||||
manifest = result.manifest
|
||||
studioPath = result.studioPath
|
||||
|
||||
debug('Manifest: %o', manifest)
|
||||
} else {
|
||||
@@ -337,7 +339,12 @@ export class StudioLifecycleManager {
|
||||
}
|
||||
}
|
||||
|
||||
private setupWatcher ({
|
||||
static async close () {
|
||||
StudioLifecycleManager.watcher?.removeAllListeners()
|
||||
await StudioLifecycleManager.watcher?.close().catch(() => {})
|
||||
}
|
||||
|
||||
private async setupWatcher ({
|
||||
cloudDataSource,
|
||||
cfg,
|
||||
debugData,
|
||||
@@ -356,9 +363,18 @@ export class StudioLifecycleManager {
|
||||
// Close the watcher if a previous watcher exists
|
||||
if (StudioLifecycleManager.watcher) {
|
||||
StudioLifecycleManager.watcher.removeAllListeners()
|
||||
StudioLifecycleManager.watcher.close().catch(() => {})
|
||||
await StudioLifecycleManager.close().catch(() => {})
|
||||
}
|
||||
|
||||
if (StudioLifecycleManager.teardown) {
|
||||
GracefulExit.removeStep(StudioLifecycleManager.teardown)
|
||||
StudioLifecycleManager.teardown = null
|
||||
}
|
||||
|
||||
StudioLifecycleManager.teardown = GracefulExit.addStep(async () => {
|
||||
await StudioLifecycleManager.close()
|
||||
}, 'close studio watcher')
|
||||
|
||||
// Watch for changes to the studio bundle
|
||||
StudioLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'index.js'), {
|
||||
awaitWriteFinish: true,
|
||||
@@ -412,7 +428,7 @@ export class StudioLifecycleManager {
|
||||
return !!(this.lastStatus === 'IN_ERROR' && this.lastErrorCode && isNonRetriableCertErrorCode(this.lastErrorCode))
|
||||
}
|
||||
|
||||
public retry (): void {
|
||||
public async retry (): Promise<void> {
|
||||
if (!this.ctx) {
|
||||
debug('No ctx available, cannot retry studio initialization')
|
||||
|
||||
@@ -439,7 +455,7 @@ export class StudioLifecycleManager {
|
||||
|
||||
// Re-initialize with the same parameters we stored
|
||||
if (this.initializationParams) {
|
||||
this.initializeStudioManager(this.initializationParams)
|
||||
await this.initializeStudioManager(this.initializationParams)
|
||||
} else {
|
||||
debug('No initialization parameters available for retry')
|
||||
this.updateStatus('IN_ERROR')
|
||||
|
||||
@@ -1,54 +1,27 @@
|
||||
import { remove, ensureDir, readFile, pathExists } from 'fs-extra'
|
||||
import { getStudioBundle } from '../api/studio/get_studio_bundle'
|
||||
import path from 'path'
|
||||
import { verifySignature } from '../encryption'
|
||||
import { extractAtomic } from '../extract_atomic'
|
||||
import { ensureSignedBundle } from '../bundles/ensure_signed_bundle'
|
||||
|
||||
interface EnsureStudioBundleOptions {
|
||||
studioUrl: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
interface EnsureStudioBundleResult {
|
||||
manifest: Record<string, string>
|
||||
studioPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the studio bundle is downloaded and extracted into the given path
|
||||
* @param options - The options for the ensure studio bundle operation
|
||||
* @param options.studioUrl - The URL of the studio bundle
|
||||
* @param options.projectId - The project ID of the studio bundle
|
||||
* @param options.studioPath - The path to extract the studio bundle to
|
||||
*/
|
||||
export const ensureStudioBundle = async ({
|
||||
studioUrl,
|
||||
projectId,
|
||||
studioPath,
|
||||
}: EnsureStudioBundleOptions): Promise<Record<string, string>> => {
|
||||
const bundlePath = path.join(studioPath, 'bundle.tar')
|
||||
|
||||
await ensureDir(studioPath)
|
||||
|
||||
const uniqueBundlePath = `${bundlePath}-${Math.random().toString(36).substring(2, 15)}`
|
||||
const responseManifestSignature: string = await getStudioBundle({
|
||||
studioUrl,
|
||||
bundlePath: uniqueBundlePath,
|
||||
}: EnsureStudioBundleOptions): Promise<EnsureStudioBundleResult> => {
|
||||
const { manifest, bundleDir } = await ensureSignedBundle({
|
||||
url: studioUrl,
|
||||
projectId,
|
||||
kind: 'studio',
|
||||
})
|
||||
|
||||
await extractAtomic(uniqueBundlePath, studioPath).finally(async () => {
|
||||
await remove(uniqueBundlePath).catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
const manifestPath = path.join(studioPath, 'manifest.json')
|
||||
|
||||
if (!(await pathExists(manifestPath))) {
|
||||
throw new Error('Unable to find studio manifest')
|
||||
return {
|
||||
manifest,
|
||||
studioPath: bundleDir,
|
||||
}
|
||||
|
||||
const manifestContents = await readFile(manifestPath, 'utf8')
|
||||
|
||||
const verified = await verifySignature(manifestContents, responseManifestSignature)
|
||||
|
||||
if (!verified) {
|
||||
throw new Error('Unable to verify studio signature')
|
||||
}
|
||||
|
||||
return JSON.parse(manifestContents)
|
||||
}
|
||||
|
||||
@@ -6,44 +6,39 @@
|
||||
// synchronous requires the first go around just to
|
||||
// essentially do it all again when we boot the correct
|
||||
// mode.
|
||||
|
||||
import os from 'os'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
import Debug from 'debug'
|
||||
import { getPublicConfigKeys } from '@packages/config'
|
||||
import { toObject, toArray } from './util/args'
|
||||
import { telemetry } from '@packages/telemetry'
|
||||
import { getCtx, hasCtx } from '@packages/data-context'
|
||||
import { warning as errorsWarning } from './errors'
|
||||
import { getCwd } from './cwd'
|
||||
import type { CypressError } from '@packages/errors'
|
||||
import { toNumber } from 'lodash'
|
||||
import { GracefulExit } from './util/graceful-exit'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { CypressRunResult } from './modes/results'
|
||||
const debug = Debug('cypress:server:cypress')
|
||||
|
||||
type Mode = 'exit' | 'info' | 'interactive' | 'pkg' | 'record' | 'results' | 'run' | 'smokeTest' | 'version' | 'returnPkg' | 'exitWithCode'
|
||||
|
||||
const exit = async (code = 0) => {
|
||||
// TODO: we shouldn't have to do this
|
||||
// but cannot figure out how null is
|
||||
// being passed into exit
|
||||
debug('about to exit with code', code)
|
||||
interface MinimalRunResult {
|
||||
totalFailed: number
|
||||
}
|
||||
|
||||
if (hasCtx()) {
|
||||
await getCtx().lifecycleManager.mainProcessWillDisconnect().catch((err: any) => {
|
||||
debug('mainProcessWillDisconnect errored with: ', err)
|
||||
})
|
||||
}
|
||||
/** Resolved value from {@link runElectron} (in-process Electron vs spawned child). */
|
||||
type RunElectronResult =
|
||||
| number
|
||||
| CypressRunResult
|
||||
| MinimalRunResult
|
||||
| BrowserWindow
|
||||
|
||||
const span = telemetry.getSpan('cypress')
|
||||
|
||||
span?.setAttribute('exitCode', code)
|
||||
span?.end()
|
||||
|
||||
await telemetry.shutdown().catch((err: any) => {
|
||||
debug('telemetry shutdown errored with: ', err)
|
||||
})
|
||||
|
||||
debug('process.exit', code)
|
||||
|
||||
return process.exit(code)
|
||||
function isCypressRunResult (result: any): result is CypressRunResult {
|
||||
return result && typeof result === 'object' && 'runs' in result && Array.isArray(result.runs)
|
||||
}
|
||||
function isMinimalRunResult (result: any): result is MinimalRunResult {
|
||||
return result && typeof result === 'object' && 'totalFailed' in result
|
||||
}
|
||||
|
||||
const showWarningForInvalidConfig = (options: any) => {
|
||||
@@ -63,10 +58,6 @@ const showWarningForInvalidConfig = (options: any) => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const exit0 = () => {
|
||||
return exit(0)
|
||||
}
|
||||
|
||||
function isCypressError (err: unknown): err is CypressError {
|
||||
return (err as CypressError).isCypressErr
|
||||
}
|
||||
@@ -85,11 +76,11 @@ async function exitErr (err: unknown, posixExitCodes?: boolean) {
|
||||
err.type === 'CLOUD_CANNOT_PROCEED_IN_PARALLEL_NETWORK' ||
|
||||
err.type === 'CLOUD_CANNOT_PROCEED_IN_SERIAL_NETWORK'
|
||||
)) {
|
||||
return exit(112)
|
||||
return GracefulExit.exitGracefully(112)
|
||||
}
|
||||
}
|
||||
|
||||
return exit(1)
|
||||
return GracefulExit.exitGracefully(1)
|
||||
}
|
||||
|
||||
export = {
|
||||
@@ -97,7 +88,7 @@ export = {
|
||||
return require('./util/electron-app').isRunning()
|
||||
},
|
||||
|
||||
runElectron (mode: Mode, options: any) {
|
||||
runElectron (mode: Mode, options: any): Promise<RunElectronResult> {
|
||||
// wrap all of this in a promise to force the
|
||||
// promise interface - even if it doesn't matter
|
||||
// in dev mode due to cp.spawn
|
||||
@@ -118,30 +109,29 @@ export = {
|
||||
return require('./modes')(mode, options)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(async (resolve) => {
|
||||
debug('starting Electron')
|
||||
const cypressElectron = require('@packages/electron')
|
||||
|
||||
const fn = (code: number) => {
|
||||
// juggle up the totalFailed since our outer
|
||||
// promise is expecting this object structure
|
||||
debug('electron finished with', code)
|
||||
|
||||
if (mode === 'smokeTest') {
|
||||
return resolve(code)
|
||||
}
|
||||
|
||||
return resolve({ totalFailed: code })
|
||||
}
|
||||
|
||||
const args = toArray(options)
|
||||
const args = require('./util/args').toArray(options)
|
||||
|
||||
debug('electron open arguments %o', args)
|
||||
|
||||
// const mainEntryFile = require.main.filename
|
||||
const serverMain = getCwd()
|
||||
|
||||
return cypressElectron.open(serverMain, args, fn)
|
||||
const child: ChildProcess = await cypressElectron.open(serverMain, args)
|
||||
|
||||
child.on('close', (exitCode, signal) => {
|
||||
debug('electron closed with', { code: exitCode, signal })
|
||||
const code = signal ? 1 : (exitCode ?? 0)
|
||||
|
||||
if (mode === 'smokeTest') {
|
||||
resolve(code)
|
||||
} else {
|
||||
resolve({ totalFailed: code })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -242,10 +232,16 @@ export = {
|
||||
case 'smokeTest': {
|
||||
const pong = await this.runElectron(mode, options)
|
||||
|
||||
const code = typeof pong === 'number'
|
||||
? pong
|
||||
: typeof pong === 'object' && 'totalFailed' in pong
|
||||
? pong.totalFailed
|
||||
: Number(pong ?? 0)
|
||||
|
||||
if (!this.isCurrentlyRunningElectron()) {
|
||||
return exit(pong)
|
||||
return GracefulExit.exitGracefully(code)
|
||||
} else if (pong !== options.ping) {
|
||||
return exit(1)
|
||||
return GracefulExit.exitGracefully(1)
|
||||
}
|
||||
|
||||
break
|
||||
@@ -258,30 +254,38 @@ export = {
|
||||
break
|
||||
}
|
||||
case 'exitWithCode': {
|
||||
return exit(toNumber(options.exitWithCode))
|
||||
return GracefulExit.exitGracefully(toNumber(options.exitWithCode))
|
||||
break
|
||||
}
|
||||
case 'run': {
|
||||
const results = await this.runElectron(mode, options)
|
||||
|
||||
if (results.runs) {
|
||||
const isCanceled = results.runs.filter((run) => run.skippedSpec).length
|
||||
|
||||
if (isCanceled) {
|
||||
if (
|
||||
isCypressRunResult(results) &&
|
||||
(results.runs.filter((run) => run.skippedSpec).length)
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(require('chalk').magenta('\n Exiting with non-zero exit code because the run was canceled.'))
|
||||
|
||||
return exit(1)
|
||||
return GracefulExit.exitGracefully(1)
|
||||
}
|
||||
|
||||
if (isCypressRunResult(results) || isMinimalRunResult(results)) {
|
||||
// Exit code 112 is reserved for network errors in parallel mode
|
||||
// All other exit codes are "number of tests that failed," so collapse
|
||||
// them to 0/1.
|
||||
if (options.posixExitCodes && results.totalFailed !== 112) {
|
||||
return GracefulExit.exitGracefully(results.totalFailed ? 1 : 0)
|
||||
} else {
|
||||
return GracefulExit.exitGracefully(results.totalFailed ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
debug('results.totalFailed, posix?', results.totalFailed, options.posixExitCodes)
|
||||
|
||||
if (options.posixExitCodes) {
|
||||
return exit(results.totalFailed ? 1 : 0)
|
||||
if (typeof results === 'number') {
|
||||
return GracefulExit.exitGracefully(results)
|
||||
}
|
||||
|
||||
return exit(results.totalFailed ?? 0)
|
||||
throw new Error('unexpected runElectron result for run mode')
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Cannot start. Invalid mode: '${mode}'`)
|
||||
@@ -292,6 +296,6 @@ export = {
|
||||
}
|
||||
debug('end of startInMode, exit 0')
|
||||
|
||||
return exit(0)
|
||||
return GracefulExit.exitGracefully(0)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import _ from 'lodash'
|
||||
import la from 'lazy-ass'
|
||||
import Debug from 'debug'
|
||||
import Bluebird from 'bluebird'
|
||||
import assert from 'assert'
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { ProjectBase } from './project-base'
|
||||
import browsers from './browsers'
|
||||
import * as errors from './errors'
|
||||
@@ -19,13 +18,15 @@ import type { BrowserInstance, Browser } from './browsers/types'
|
||||
|
||||
const debug = Debug('cypress:server:open_project')
|
||||
|
||||
export class OpenProject {
|
||||
export class OpenProject extends EventEmitter {
|
||||
private projectBase: ProjectBase | null = null
|
||||
relaunchBrowser: (() => Promise<BrowserInstance | null>) = () => {
|
||||
throw new Error('bad relaunch')
|
||||
}
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
|
||||
return autoBindDebug(this)
|
||||
}
|
||||
|
||||
@@ -349,6 +350,8 @@ export class OpenProject {
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('ready')
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
process.title = 'Cypress: Config Manager'
|
||||
const os = require('os')
|
||||
const pDefer = require('p-defer')
|
||||
const Debug = require('debug')
|
||||
const debug = Debug('cypress:lifecycle:require_async_child')
|
||||
|
||||
const { telemetry, OTLPTraceExporterIpc, decodeTelemetryContext } = require('@packages/telemetry')
|
||||
|
||||
const { file, projectRoot, telemetryCtx } = require('minimist')(process.argv.slice(2))
|
||||
|
||||
debug('initializing telemetry')
|
||||
|
||||
const { context, version } = decodeTelemetryContext(telemetryCtx)
|
||||
|
||||
const exporter = new OTLPTraceExporterIpc()
|
||||
@@ -14,26 +20,67 @@ if (version && context) {
|
||||
|
||||
const span = telemetry.startSpan({ name: 'child:process', active: true })
|
||||
|
||||
debug('child:process span initialized')
|
||||
require('../../util/suppress_warnings').suppress()
|
||||
|
||||
process.on('disconnect', () => {
|
||||
process.exit()
|
||||
})
|
||||
|
||||
require('graceful-fs').gracefulify(require('fs'))
|
||||
const util = require('../util')
|
||||
const ipc = util.wrapIpc(process)
|
||||
const run = require('./run_require_async_child')
|
||||
|
||||
exporter.attachIPC(ipc)
|
||||
|
||||
let disconnection = null
|
||||
let willDisconnect = pDefer()
|
||||
|
||||
process.on('disconnect', async () => {
|
||||
try {
|
||||
debug('received disconnect event')
|
||||
willDisconnect.resolve()
|
||||
await Promise.resolve() // allow for diconnect teardown to complete, if in process
|
||||
} finally {
|
||||
process.exit()
|
||||
}
|
||||
})
|
||||
|
||||
debug('registering main:process:will:disconnect listener')
|
||||
ipc.on('main:process:will:disconnect', async () => {
|
||||
debug('received main:process:will:disconnect')
|
||||
if (span) {
|
||||
debug('ending span')
|
||||
span.end()
|
||||
}
|
||||
|
||||
await telemetry.shutdown()
|
||||
debug('existing disconnection?', disconnection)
|
||||
debug('waiting telemetry shutdown')
|
||||
const p = disconnection ?? (disconnection = telemetry.shutdown())
|
||||
|
||||
await p
|
||||
debug('telemetry shutdown complete')
|
||||
|
||||
debug('sending main:process:will:disconnect:ack')
|
||||
ipc.send('main:process:will:disconnect:ack')
|
||||
willDisconnect.resolve()
|
||||
})
|
||||
|
||||
;['SIGINT', 'SIGTERM'].forEach((signal) => {
|
||||
process.on(signal, async () => {
|
||||
debug('received signal', signal)
|
||||
await Promise.race([
|
||||
willDisconnect.promise,
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
debug('timeout waiting for main:process:will:disconnect signal')
|
||||
resolve()
|
||||
}, 5000)
|
||||
}),
|
||||
])
|
||||
|
||||
process.exit(128 + os.constants.signals[signal])
|
||||
})
|
||||
})
|
||||
|
||||
const run = require('./run_require_async_child')
|
||||
|
||||
debug('run')
|
||||
run(ipc, file, projectRoot)
|
||||
debug('run complete')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,7 +30,6 @@ import type { Cfg } from './project-base'
|
||||
import type { Browser } from './browsers/types'
|
||||
import { InitializeRoutes, createCommonRoutes } from './routes'
|
||||
import type { FoundSpec, ProtocolManagerShape, TestingType } from '@packages/types'
|
||||
import type { Server as WebSocketServer } from 'ws'
|
||||
import { RemoteStates } from '@packages/network-tools'
|
||||
import type { RemoteState } from '@packages/network-tools'
|
||||
import { cookieJar, SerializableAutomationCookie } from './util/cookies'
|
||||
@@ -38,6 +37,7 @@ import * as fileServer from './file_server'
|
||||
import type { FileServer } from './file_server'
|
||||
import appData from './util/app_data'
|
||||
import { graphqlWS } from '@packages/data-context/graphql/makeGraphQLServer'
|
||||
import type { GraphqlWsHandle } from '@packages/data-context/graphql/makeGraphQLServer'
|
||||
import * as statusCode from './util/status_code'
|
||||
import { getContentType } from './util/headers'
|
||||
import stream from 'stream'
|
||||
@@ -47,6 +47,7 @@ import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/ser
|
||||
import type { Automation } from './automation'
|
||||
import type { AutomationCookie } from './automation/cookies'
|
||||
import type { ResourceType, RequestCredentialLevel } from '@packages/proxy'
|
||||
import { GracefulExit } from './util/graceful-exit'
|
||||
|
||||
const debug = Debug('cypress:server:server-base')
|
||||
|
||||
@@ -162,7 +163,7 @@ export class ServerBase<TSocket extends SocketE2E | SocketCt> {
|
||||
// @ts-ignore - this is currently affecting the v8-snapshot type checking job as we are importing the file directly from the server package
|
||||
// After some package refactoring, we should be able to remove this.
|
||||
protected _httpsProxy?: httpsProxy
|
||||
protected _graphqlWS?: WebSocketServer
|
||||
protected _graphqlWS?: GraphqlWsHandle
|
||||
protected _eventBus: EventEmitter
|
||||
protected _remoteStates: RemoteStates
|
||||
private getCurrentBrowser: undefined | (() => Browser)
|
||||
@@ -409,7 +410,9 @@ export class ServerBase<TSocket extends SocketE2E | SocketCt> {
|
||||
}
|
||||
|
||||
useMorgan () {
|
||||
return require('morgan')('dev')
|
||||
return require('morgan')('dev', {
|
||||
skip: () => GracefulExit.isShuttingDown,
|
||||
})
|
||||
}
|
||||
|
||||
getHttpServer () {
|
||||
@@ -657,13 +660,23 @@ export class ServerBase<TSocket extends SocketE2E | SocketCt> {
|
||||
}
|
||||
|
||||
close () {
|
||||
return Bluebird.all([
|
||||
this._close(),
|
||||
this._socket?.close(),
|
||||
this._fileServer?.close(),
|
||||
this._httpsProxy?.close(),
|
||||
this._graphqlWS?.close(),
|
||||
])
|
||||
// graphql-ws clients must be closed before the HTTP server is destroyed.
|
||||
const graphqlDispose = this._graphqlWS?.dispose
|
||||
? Bluebird.resolve(this._graphqlWS.dispose()).finally(() => {
|
||||
// graphql-ws dispose() closes the ws server; repeating close() rejects with
|
||||
// "The server is not running". Clear handle so subsequent close() is a no-op for gql.
|
||||
this._graphqlWS = undefined
|
||||
})
|
||||
: Bluebird.resolve()
|
||||
|
||||
return graphqlDispose.then(() => {
|
||||
return Bluebird.all([
|
||||
this._close(),
|
||||
this._socket?.close(),
|
||||
this._fileServer?.close(),
|
||||
this._httpsProxy?.close(),
|
||||
])
|
||||
})
|
||||
.then((res) => {
|
||||
this._middleware = null
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import Promise from 'bluebird'
|
||||
import lockFileModule from 'lockfile'
|
||||
import { fs } from './fs'
|
||||
import * as env from './env'
|
||||
import exit from './exit'
|
||||
import pQueue from 'p-queue'
|
||||
import { GracefulExit } from './graceful-exit'
|
||||
import type { ExitStepKey } from './graceful-exit'
|
||||
|
||||
const lockFile = Promise.promisifyAll(lockFileModule)
|
||||
|
||||
const debugVerbose = debugModule('cypress-verbose:server:util:file')
|
||||
@@ -18,8 +20,8 @@ const LOCK_TIMEOUT = 2000
|
||||
|
||||
function getUid () {
|
||||
try {
|
||||
// @ts-expect-error - process.geteuid is defined
|
||||
return process.geteuid()
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
return process.geteuid?.() ?? 1
|
||||
} catch (err) {
|
||||
// process.geteuid() can fail, return a constant
|
||||
// @see https://github.com/cypress-io/cypress/issues/17415
|
||||
@@ -33,6 +35,8 @@ export class File {
|
||||
_queue!: pQueue
|
||||
_cache!: Record<string, any>
|
||||
_lastRead!: number
|
||||
/** Set while a lock may be held; removed in `_unlock` so GracefulExit steps do not accumulate per unused File. */
|
||||
_gracefulExitStepKey: ExitStepKey | null = null
|
||||
path: string
|
||||
|
||||
static noopFile = {
|
||||
@@ -56,9 +60,13 @@ export class File {
|
||||
this.path = options.path
|
||||
this.initialize()
|
||||
|
||||
exit.ensure(() => {
|
||||
// Preserve prior behavior of invoking GracefulExit.addStep from the constructor (see file_spec),
|
||||
// but do not leave a registered step until we actually take a lock — avoids orphaned teardown steps.
|
||||
const ctorExitKey = GracefulExit.addStep(async () => {
|
||||
return lockFile.unlockSync(this._lockFilePath)
|
||||
})
|
||||
}, 'unlock lockfile')
|
||||
|
||||
GracefulExit.removeStep(ctorExitKey)
|
||||
}
|
||||
|
||||
initialize () {
|
||||
@@ -74,6 +82,11 @@ export class File {
|
||||
}
|
||||
|
||||
__resetForTest () {
|
||||
if (this._gracefulExitStepKey) {
|
||||
GracefulExit.removeStep(this._gracefulExitStepKey)
|
||||
this._gracefulExitStepKey = null
|
||||
}
|
||||
|
||||
this._queue.clear()
|
||||
lockFile.unlockSync(this._lockFilePath)
|
||||
this.initialize()
|
||||
@@ -251,6 +264,13 @@ export class File {
|
||||
// polls every 100ms up to 2000ms to obtain lock, otherwise rejects
|
||||
return lockFile.lockAsync(this._lockFilePath, { wait: LOCK_TIMEOUT })
|
||||
})
|
||||
.then(() => {
|
||||
if (!this._gracefulExitStepKey) {
|
||||
this._gracefulExitStepKey = GracefulExit.addStep(async () => {
|
||||
return lockFile.unlockSync(this._lockFilePath)
|
||||
}, 'unlock lockfile')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
return debugVerbose('getting lock succeeded or failed for %s', this.path)
|
||||
})
|
||||
@@ -266,6 +286,11 @@ export class File {
|
||||
debugVerbose(`unlock timeout error for %s`, this._lockFilePath)
|
||||
})
|
||||
.finally(() => {
|
||||
if (this._gracefulExitStepKey) {
|
||||
GracefulExit.removeStep(this._gracefulExitStepKey)
|
||||
this._gracefulExitStepKey = null
|
||||
}
|
||||
|
||||
return debugVerbose('unlock succeeded or failed for %s', this.path)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/* eslint-disable no-console */
|
||||
import Debug from 'debug'
|
||||
import { randomUUID } from 'crypto'
|
||||
import os from 'os'
|
||||
|
||||
/** Window after teardown starts in which extra signals are treated as duplicate delivery, not a second user interrupt. */
|
||||
const SIGNAL_DEDUP_MS = 200
|
||||
|
||||
function getTeardownTimeoutMs (): number {
|
||||
const n = Number(process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT)
|
||||
|
||||
return Number.isFinite(n) && n > 0 ? n : 5000
|
||||
}
|
||||
|
||||
export interface ExitStep {
|
||||
name: string
|
||||
fn: (code: number) => Promise<number | void> | void
|
||||
}
|
||||
|
||||
export type ExitStepKey = string
|
||||
|
||||
export class GracefulExit {
|
||||
private static instance: GracefulExit | null = null
|
||||
private static get singleton () {
|
||||
return this.instance ?? (this.instance = new GracefulExit())
|
||||
}
|
||||
|
||||
private readonly handledSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']
|
||||
private readonly signalHandlers: Array<{ signal: NodeJS.Signals, listener: (sig: NodeJS.Signals) => void }> = []
|
||||
private processTeardown: Promise<number | void> | null = null
|
||||
private teardownStartedAt: number | null = null
|
||||
private steps: Map<string, ExitStep> = new Map()
|
||||
private debug: Debug.Debugger
|
||||
|
||||
/**
|
||||
* Handles SIGINT/SIGTERM for this registration (see constructor loop).
|
||||
*
|
||||
* **Why debounce:** The same OS interrupt can surface multiple times on `process` in quick succession —
|
||||
* e.g. `signal-exit` (used by subprocess tooling) may call `process.kill(process.pid, sig)` after its
|
||||
* own handler runs; multiple copies of `signal-exit` or other global handlers stack; or the CLI and
|
||||
* Electron child share process-group semantics. Without a short dedup window, that second delivery
|
||||
* arrived while `processTeardown` was already set and was misread as “user pressed interrupt again to
|
||||
* force quit”, skipping in-flight teardown or exiting with code 1. We treat signals within
|
||||
* `SIGNAL_DEDUP_MS` of teardown start as the same burst and only join the in-flight teardown promise;
|
||||
* a later interrupt still forces exit so a hung teardown can be escaped by the user.
|
||||
*/
|
||||
private readonly handleProcessSignal = async (
|
||||
registeredSignal: NodeJS.Signals,
|
||||
received?: NodeJS.Signals,
|
||||
): Promise<void> => {
|
||||
const resolvedSig = received ?? registeredSignal
|
||||
|
||||
if (this.processTeardown) {
|
||||
const elapsedMs = this.teardownStartedAt == null
|
||||
? Infinity
|
||||
: Date.now() - this.teardownStartedAt
|
||||
|
||||
if (elapsedMs < SIGNAL_DEDUP_MS) {
|
||||
await this.processTeardown
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`\n\n${resolvedSig} received during graceful exit. Forcing exit.`)
|
||||
process.exit(1)
|
||||
} else {
|
||||
await GracefulExit.exitGracefully(128 + os.constants.signals[resolvedSig])
|
||||
}
|
||||
}
|
||||
|
||||
constructor () {
|
||||
this.debug = Debug(`cypress:server:graceful-exit:${process.pid}`)
|
||||
this.debug('initializing graceful exit in process %s', process.pid)
|
||||
|
||||
for (const sig of this.handledSignals) {
|
||||
const listener = async (received?: NodeJS.Signals): Promise<void> => {
|
||||
await this.handleProcessSignal(sig, received)
|
||||
}
|
||||
|
||||
process.on(sig, listener)
|
||||
this.signalHandlers.push({ signal: sig, listener })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears singleton state and signal listeners. Only for use from server unit tests
|
||||
* (when `global.IS_TEST` is set by spec_helper).
|
||||
*/
|
||||
static resetForTesting (): void {
|
||||
if (!(globalThis as { IS_TEST?: boolean }).IS_TEST) {
|
||||
console.warn('GracefulExit.resetForTesting is a static harness only for unit tests')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const inst = GracefulExit.instance
|
||||
|
||||
if (!inst) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const { signal, listener } of inst.signalHandlers) {
|
||||
process.removeListener(signal, listener)
|
||||
}
|
||||
|
||||
inst.steps.clear()
|
||||
inst.processTeardown = null
|
||||
inst.teardownStartedAt = null
|
||||
GracefulExit.instance = null
|
||||
}
|
||||
|
||||
static addStep (teardownFn: ExitStep['fn'], stepName?: string): ExitStepKey {
|
||||
GracefulExit.singleton.debug('adding step to graceful exit: %s', stepName)
|
||||
|
||||
const key = randomUUID()
|
||||
const name = stepName ?? teardownFn.name ?? key
|
||||
|
||||
GracefulExit.singleton.steps.set(key, { name, fn: teardownFn })
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
static removeStep (key: ExitStepKey): void {
|
||||
GracefulExit.singleton.steps.delete(key)
|
||||
}
|
||||
|
||||
static get isShuttingDown (): boolean {
|
||||
return GracefulExit.singleton.processTeardown != null
|
||||
}
|
||||
|
||||
private async flushSteps (code: number): Promise<number> {
|
||||
let hadErrors = false
|
||||
|
||||
await Promise.all(Array.from(this.steps.entries()).map(async ([key, { name, fn }]) => {
|
||||
try {
|
||||
this.debug(`<${key}> executing teardown step: %s`, name)
|
||||
|
||||
await fn(code)
|
||||
|
||||
this.debug(`<${key}> teardown step completed: %s`, name)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
this.debug(`<${key}> Error executing teardown step: ${name}`, error)
|
||||
hadErrors = true
|
||||
}
|
||||
}))
|
||||
|
||||
if (hadErrors) {
|
||||
console.error('Additional errors occurred during teardown. Exiting with code 1.')
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
private async flushAndExit (code: number): Promise<number | void> {
|
||||
let finalExitCode = code ?? 0
|
||||
|
||||
try {
|
||||
finalExitCode = await this.flushSteps(code)
|
||||
this.debug('steps flushed successfully', code, finalExitCode)
|
||||
} catch (error) {
|
||||
this.debug('Error flushing steps: ', error)
|
||||
finalExitCode = 1
|
||||
} finally {
|
||||
this.processTeardown = null
|
||||
this.teardownStartedAt = null
|
||||
this.steps.clear()
|
||||
process.exit(finalExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
static async exitGracefully (code: number): Promise<number | void> {
|
||||
const exit = GracefulExit.singleton
|
||||
|
||||
if (exit.processTeardown) {
|
||||
return exit.processTeardown
|
||||
}
|
||||
|
||||
let forceExitTimeout: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
exit.teardownStartedAt = Date.now()
|
||||
exit.processTeardown = Promise.race([
|
||||
GracefulExit.singleton.flushAndExit(code).then(() => {
|
||||
clearTimeout(forceExitTimeout)
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
forceExitTimeout = setTimeout(() => {
|
||||
try {
|
||||
const ms = getTeardownTimeoutMs()
|
||||
|
||||
console.error(`Failed to gracefully exit after ${ms}ms. Exiting with code 1. Configure with CYPRESS_INTERNAL_TEARDOWN_TIMEOUT (milliseconds).`)
|
||||
} catch (e) {
|
||||
console.error('Error forcing exit: ', e)
|
||||
} finally {
|
||||
clearTimeout(forceExitTimeout)
|
||||
resolve()
|
||||
process.exit(1)
|
||||
}
|
||||
}, getTeardownTimeoutMs())
|
||||
}),
|
||||
])
|
||||
|
||||
return exit.processTeardown
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
const Debug = require('debug')
|
||||
const electronApp = require('./lib/util/electron-app')
|
||||
const { telemetry, OTLPTraceExporterCloud } = require('@packages/telemetry')
|
||||
const { apiRoutes } = require('./lib/cloud/routes')
|
||||
const encryption = require('./lib/cloud/encryption')
|
||||
const { override: overrideTty } = require('./lib/util/tty')
|
||||
const { GracefulExit } = require('./lib/util/graceful-exit')
|
||||
const { NetProfiler } = require('./lib/util/net_profiler')
|
||||
|
||||
const { calculateCypressInternalEnv, configureLongStackTraces } = require('./lib/environment')
|
||||
|
||||
const debug = Debug('cypress:server:start-cypress')
|
||||
|
||||
process.env['CYPRESS_INTERNAL_ENV'] = calculateCypressInternalEnv()
|
||||
configureLongStackTraces(process.env['CYPRESS_INTERNAL_ENV'])
|
||||
process.env['CYPRESS'] = 'true'
|
||||
@@ -52,6 +56,19 @@ if (isRunningElectron) {
|
||||
|
||||
telemetry.startSpan({ name: 'cypress', attachType: 'root', active: true, opts: { startTime: global.cypressBinaryStartTime } })
|
||||
|
||||
GracefulExit.addStep(async (code) => {
|
||||
try {
|
||||
const span = telemetry.getSpan('cypress')
|
||||
|
||||
span?.setAttribute('exitCode', code)
|
||||
span?.end()
|
||||
} catch (error) {
|
||||
debug('Error during cleanup of telemetry span on exit: %o', error)
|
||||
} finally {
|
||||
await telemetry.shutdown()
|
||||
}
|
||||
}, 'finalize telemetry')
|
||||
|
||||
const v8SnapshotSpan = telemetry.startSpan({ name: 'v8snapshot:startup', opts: { startTime: global.cypressServerStartTime } })
|
||||
|
||||
v8SnapshotSpan?.end(endTime)
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import fs from 'fs-extra'
|
||||
import tar from 'tar'
|
||||
import { extractAtomic } from '../../../lib/cloud/extract_atomic'
|
||||
import { expect } from '../../spec_helper'
|
||||
|
||||
describe('extractAtomic integration', () => {
|
||||
let tempDir: string
|
||||
let archivePath: string
|
||||
let destinationPath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cypress-extract-atomic-'))
|
||||
archivePath = path.join(tempDir, 'archive.tar')
|
||||
destinationPath = path.join(tempDir, 'extracted')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.remove(tempDir).catch(() => {
|
||||
// Ignore cleanup errors
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should extract a single file from tar archive', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source')
|
||||
const testFile = path.join(sourceDir, 'test.txt')
|
||||
const fileContent = 'Hello, World!'
|
||||
|
||||
await fs.ensureDir(sourceDir)
|
||||
await fs.writeFile(testFile, fileContent)
|
||||
|
||||
// Create tar archive
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
['test.txt'],
|
||||
)
|
||||
|
||||
// Extract archive
|
||||
await extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Verify extracted file
|
||||
const extractedFile = path.join(destinationPath, 'test.txt')
|
||||
|
||||
expect(await fs.pathExists(extractedFile)).to.be.true
|
||||
|
||||
const content = await fs.readFile(extractedFile, 'utf8')
|
||||
|
||||
expect(content).to.equal(fileContent)
|
||||
})
|
||||
|
||||
it('should extract multiple files from tar archive', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source')
|
||||
const file1 = path.join(sourceDir, 'file1.txt')
|
||||
const file2 = path.join(sourceDir, 'file2.txt')
|
||||
const file3 = path.join(sourceDir, 'file3.txt')
|
||||
|
||||
await fs.ensureDir(sourceDir)
|
||||
await fs.writeFile(file1, 'Content 1')
|
||||
await fs.writeFile(file2, 'Content 2')
|
||||
await fs.writeFile(file3, 'Content 3')
|
||||
|
||||
// Create tar archive
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
['file1.txt', 'file2.txt', 'file3.txt'],
|
||||
)
|
||||
|
||||
// Extract archive
|
||||
await extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Verify all files extracted
|
||||
expect(await fs.readFile(path.join(destinationPath, 'file1.txt'), 'utf8')).to.equal('Content 1')
|
||||
expect(await fs.readFile(path.join(destinationPath, 'file2.txt'), 'utf8')).to.equal('Content 2')
|
||||
expect(await fs.readFile(path.join(destinationPath, 'file3.txt'), 'utf8')).to.equal('Content 3')
|
||||
})
|
||||
|
||||
it('should extract files with nested directory structure', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source')
|
||||
const nestedFile = path.join(sourceDir, 'nested', 'path', 'to', 'file.txt')
|
||||
|
||||
await fs.ensureDir(path.dirname(nestedFile))
|
||||
await fs.writeFile(nestedFile, 'Nested content')
|
||||
|
||||
// Create tar archive
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
['nested'],
|
||||
)
|
||||
|
||||
// Extract archive
|
||||
await extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Verify nested file extracted
|
||||
const extractedFile = path.join(destinationPath, 'nested', 'path', 'to', 'file.txt')
|
||||
|
||||
expect(await fs.pathExists(extractedFile)).to.be.true
|
||||
expect(await fs.readFile(extractedFile, 'utf8')).to.equal('Nested content')
|
||||
})
|
||||
|
||||
it('should skip non-file entries (directories, symlinks)', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source')
|
||||
const testFile = path.join(sourceDir, 'test.txt')
|
||||
const subDir = path.join(sourceDir, 'subdir')
|
||||
|
||||
await fs.ensureDir(subDir)
|
||||
await fs.writeFile(testFile, 'File content')
|
||||
await fs.writeFile(path.join(subDir, 'subfile.txt'), 'Sub file')
|
||||
|
||||
// Create tar archive
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
['.'],
|
||||
)
|
||||
|
||||
// Extract archive
|
||||
await extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Verify file was extracted
|
||||
expect(await fs.pathExists(path.join(destinationPath, 'test.txt'))).to.be.true
|
||||
expect(await fs.pathExists(path.join(destinationPath, 'subdir', 'subfile.txt'))).to.be.true
|
||||
|
||||
// Verify directories are not extracted as files (they should be created as directories)
|
||||
const subdirPath = path.join(destinationPath, 'subdir')
|
||||
const stats = await fs.stat(subdirPath)
|
||||
|
||||
expect(stats.isDirectory()).to.be.true
|
||||
})
|
||||
|
||||
it('should preserve file permissions', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source')
|
||||
const executableFile = path.join(sourceDir, 'script.sh')
|
||||
const normalFile = path.join(sourceDir, 'normal.txt')
|
||||
|
||||
await fs.ensureDir(sourceDir)
|
||||
await fs.writeFile(executableFile, '#!/bin/bash\necho "hello"')
|
||||
await fs.writeFile(normalFile, 'Normal content')
|
||||
await fs.chmod(executableFile, 0o755)
|
||||
await fs.chmod(normalFile, 0o644)
|
||||
|
||||
// Create tar archive with preserve mode
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: sourceDir,
|
||||
preservePaths: true,
|
||||
},
|
||||
['script.sh', 'normal.txt'],
|
||||
)
|
||||
|
||||
// Extract archive
|
||||
await extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Verify file permissions (on Unix-like systems)
|
||||
if (process.platform !== 'win32') {
|
||||
const executableStats = await fs.stat(path.join(destinationPath, 'script.sh'))
|
||||
const normalStats = await fs.stat(path.join(destinationPath, 'normal.txt'))
|
||||
|
||||
// Check that executable file has execute permission
|
||||
expect(executableStats.mode & 0o111).to.not.equal(0)
|
||||
// Check that normal file doesn't have execute permission
|
||||
expect(normalStats.mode & 0o111).to.equal(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle binary files correctly', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source')
|
||||
const binaryFile = path.join(sourceDir, 'binary.bin')
|
||||
|
||||
// Create a binary file with various byte values
|
||||
const binaryContent = Buffer.from([0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD, 0x7F, 0x80])
|
||||
|
||||
await fs.ensureDir(sourceDir)
|
||||
await fs.writeFile(binaryFile, binaryContent)
|
||||
|
||||
// Create tar archive
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
['binary.bin'],
|
||||
)
|
||||
|
||||
// Extract archive
|
||||
await extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Verify binary file extracted correctly
|
||||
const extractedFile = path.join(destinationPath, 'binary.bin')
|
||||
const extractedContent = await fs.readFile(extractedFile)
|
||||
|
||||
expect(extractedContent).to.deep.equal(binaryContent)
|
||||
})
|
||||
|
||||
it('should handle large files', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source')
|
||||
const largeFile = path.join(sourceDir, 'large.txt')
|
||||
|
||||
// Create a file with 1MB of data
|
||||
const largeContent = 'A'.repeat(1024 * 1024)
|
||||
|
||||
await fs.ensureDir(sourceDir)
|
||||
await fs.writeFile(largeFile, largeContent)
|
||||
|
||||
// Create tar archive
|
||||
await tar.create(
|
||||
{
|
||||
file: archivePath,
|
||||
cwd: sourceDir,
|
||||
},
|
||||
['large.txt'],
|
||||
)
|
||||
|
||||
// Extract archive
|
||||
await extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Verify large file extracted correctly
|
||||
const extractedFile = path.join(destinationPath, 'large.txt')
|
||||
const extractedContent = await fs.readFile(extractedFile, 'utf8')
|
||||
|
||||
expect(extractedContent.length).to.equal(1024 * 1024)
|
||||
expect(extractedContent).to.equal(largeContent)
|
||||
})
|
||||
})
|
||||
@@ -147,6 +147,28 @@ function mockEE () {
|
||||
return ee
|
||||
}
|
||||
|
||||
/**
|
||||
* `cypress.start` resolves only after graceful exit runs `clearCtx`, so
|
||||
* `openProject.getProject()` is null in a `.then` on that promise.
|
||||
* Register assertions that need `cfg` / `ProjectBase` in `assertWhileOpen`; it runs
|
||||
* when `openProject` emits `ready` (project fully opened), before the run finishes and exit runs.
|
||||
*
|
||||
* @param {Promise<unknown>} startPromise - return value of `cypress.start(...)`
|
||||
* @param {(project: import('../../lib/project-base').ProjectBase) => void | Promise<void>} assertWhileOpen
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function assertWithOpenProjectAfterReady (startPromise, assertWhileOpen) {
|
||||
const whenReady = new Promise((resolve, reject) => {
|
||||
openProject.once('ready', () => {
|
||||
Promise.resolve(assertWhileOpen(openProject.getProject()))
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.all([whenReady, startPromise])
|
||||
}
|
||||
|
||||
let ctx
|
||||
|
||||
describe('lib/cypress', () => {
|
||||
@@ -197,6 +219,8 @@ describe('lib/cypress', () => {
|
||||
expect(process.exit).to.be.calledWith(code)
|
||||
}
|
||||
|
||||
this.assertWithOpenProjectAfterReady = assertWithOpenProjectAfterReady
|
||||
|
||||
// returns error object
|
||||
this.expectExitWithErr = (type, msg1, msg2) => {
|
||||
expect(errors.log, 'error was logged').to.be.calledWithMatch({ type })
|
||||
@@ -940,14 +964,16 @@ describe('lib/cypress', () => {
|
||||
})
|
||||
|
||||
it('does not save project state', function () {
|
||||
return cypress.start([`--run-project=${this.todosPath}`, `--spec=${this.todosPath}/tests/test2.coffee`])
|
||||
.then(() => {
|
||||
return assertWithOpenProjectAfterReady(
|
||||
cypress.start([`--run-project=${this.todosPath}`, `--spec=${this.todosPath}/tests/test2.coffee`]),
|
||||
(project) => {
|
||||
// this should not save the project's state
|
||||
// because its a noop in 'cypress run' mode
|
||||
return project.saveState()
|
||||
},
|
||||
).then(() => {
|
||||
this.expectExitWith(0)
|
||||
|
||||
// this should not save the project's state
|
||||
// because its a noop in 'cypress run' mode
|
||||
return openProject.getProject().saveState()
|
||||
}).then(() => {
|
||||
return fs.statAsync(this.statePath)
|
||||
.then(() => {
|
||||
throw new Error(`saved state should not exist but it did here: ${this.statePath}`)
|
||||
@@ -958,9 +984,12 @@ describe('lib/cypress', () => {
|
||||
|
||||
describe('morgan', () => {
|
||||
it('sets morgan to false', function () {
|
||||
return cypress.start([`--run-project=${this.todosPath}`])
|
||||
.then(() => {
|
||||
expect(openProject.getProject().cfg.morgan).to.be.false
|
||||
return assertWithOpenProjectAfterReady(
|
||||
cypress.start([`--run-project=${this.todosPath}`]),
|
||||
(project) => {
|
||||
expect(project.cfg.morgan).to.be.false
|
||||
},
|
||||
).then(() => {
|
||||
this.expectExitWith(0)
|
||||
})
|
||||
})
|
||||
@@ -972,62 +1001,66 @@ describe('lib/cypress', () => {
|
||||
})
|
||||
|
||||
it('can override default values', function () {
|
||||
return cypress.start([`--run-project=${this.todosPath}`, '--config=requestTimeout=1234,videoCompression=true'])
|
||||
.then(() => {
|
||||
const { cfg } = openProject.getProject()
|
||||
return assertWithOpenProjectAfterReady(
|
||||
cypress.start([`--run-project=${this.todosPath}`, '--config=requestTimeout=1234,videoCompression=true']),
|
||||
(project) => {
|
||||
const { cfg } = project
|
||||
|
||||
expect(cfg.videoCompression).to.be.true
|
||||
expect(cfg.requestTimeout).to.eq(1234)
|
||||
expect(cfg.videoCompression).to.be.true
|
||||
expect(cfg.requestTimeout).to.eq(1234)
|
||||
|
||||
expect(cfg.resolved.videoCompression).to.deep.eq({
|
||||
value: true,
|
||||
from: 'cli',
|
||||
})
|
||||
|
||||
expect(cfg.resolved.requestTimeout).to.deep.eq({
|
||||
value: 1234,
|
||||
from: 'cli',
|
||||
})
|
||||
expect(cfg.resolved.videoCompression).to.deep.eq({
|
||||
value: true,
|
||||
from: 'cli',
|
||||
})
|
||||
|
||||
expect(cfg.resolved.requestTimeout).to.deep.eq({
|
||||
value: 1234,
|
||||
from: 'cli',
|
||||
})
|
||||
},
|
||||
).then(() => {
|
||||
this.expectExitWith(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('can override values in plugins', function () {
|
||||
return cypress.start([
|
||||
`--run-project=${this.pluginConfig}`, '--config=requestTimeout=1234,videoCompression=true',
|
||||
'--env=foo=foo,bar=bar',
|
||||
])
|
||||
.then(() => {
|
||||
const { cfg } = openProject.getProject()
|
||||
return assertWithOpenProjectAfterReady(
|
||||
cypress.start([
|
||||
`--run-project=${this.pluginConfig}`, '--config=requestTimeout=1234,videoCompression=true',
|
||||
'--env=foo=foo,bar=bar',
|
||||
]),
|
||||
(project) => {
|
||||
const { cfg } = project
|
||||
|
||||
expect(cfg.videoCompression).to.eq(20)
|
||||
expect(cfg.defaultCommandTimeout).to.eq(500)
|
||||
expect(cfg.env).to.deep.eq({
|
||||
foo: 'bar',
|
||||
bar: 'bar',
|
||||
})
|
||||
expect(cfg.videoCompression).to.eq(20)
|
||||
expect(cfg.defaultCommandTimeout).to.eq(500)
|
||||
expect(cfg.env).to.deep.eq({
|
||||
foo: 'bar',
|
||||
bar: 'bar',
|
||||
})
|
||||
|
||||
expect(cfg.resolved.videoCompression).to.deep.eq({
|
||||
value: 20,
|
||||
from: 'plugin',
|
||||
})
|
||||
expect(cfg.resolved.videoCompression).to.deep.eq({
|
||||
value: 20,
|
||||
from: 'plugin',
|
||||
})
|
||||
|
||||
expect(cfg.resolved.requestTimeout).to.deep.eq({
|
||||
value: 1234,
|
||||
from: 'cli',
|
||||
})
|
||||
expect(cfg.resolved.requestTimeout).to.deep.eq({
|
||||
value: 1234,
|
||||
from: 'cli',
|
||||
})
|
||||
|
||||
expect(cfg.resolved.env.foo).to.deep.eq({
|
||||
value: 'bar',
|
||||
from: 'plugin',
|
||||
})
|
||||
|
||||
expect(cfg.resolved.env.bar).to.deep.eq({
|
||||
value: 'bar',
|
||||
from: 'cli',
|
||||
})
|
||||
expect(cfg.resolved.env.foo).to.deep.eq({
|
||||
value: 'bar',
|
||||
from: 'plugin',
|
||||
})
|
||||
|
||||
expect(cfg.resolved.env.bar).to.deep.eq({
|
||||
value: 'bar',
|
||||
from: 'cli',
|
||||
})
|
||||
},
|
||||
).then(() => {
|
||||
this.expectExitWith(0)
|
||||
})
|
||||
})
|
||||
@@ -1162,9 +1195,12 @@ describe('lib/cypress', () => {
|
||||
const listen = sinon.spy(http.Server.prototype, 'listen')
|
||||
const open = sinon.spy(ServerBase.prototype, 'open')
|
||||
|
||||
return cypress.start([`--run-project=${this.todosPath}`, '--port=5544'])
|
||||
.then(() => {
|
||||
expect(openProject.getProject().cfg.port).to.eq(5544)
|
||||
return assertWithOpenProjectAfterReady(
|
||||
cypress.start([`--run-project=${this.todosPath}`, '--port=5544']),
|
||||
(project) => {
|
||||
expect(project.cfg.port).to.eq(5544)
|
||||
},
|
||||
).then(() => {
|
||||
expect(listen).to.be.calledWith(5544)
|
||||
expect(open).to.be.calledWithMatch({ port: 5544 })
|
||||
this.expectExitWith(0)
|
||||
@@ -1195,37 +1231,41 @@ describe('lib/cypress', () => {
|
||||
})
|
||||
|
||||
it('can set specific environment variables', function () {
|
||||
return cypress.start([
|
||||
`--run-project=${this.todosPath}`,
|
||||
'--video=false',
|
||||
'--env',
|
||||
'version=0.12.1,foo=bar,host=http://localhost:8888,baz=quux=dolor',
|
||||
])
|
||||
.then(() => {
|
||||
expect(openProject.getProject().cfg.env).to.deep.eq({
|
||||
version: '0.12.1',
|
||||
foo: 'bar',
|
||||
host: 'http://localhost:8888',
|
||||
baz: 'quux=dolor',
|
||||
})
|
||||
|
||||
return assertWithOpenProjectAfterReady(
|
||||
cypress.start([
|
||||
`--run-project=${this.todosPath}`,
|
||||
'--video=false',
|
||||
'--env',
|
||||
'version=0.12.1,foo=bar,host=http://localhost:8888,baz=quux=dolor',
|
||||
]),
|
||||
(project) => {
|
||||
expect(project.cfg.env).to.deep.eq({
|
||||
version: '0.12.1',
|
||||
foo: 'bar',
|
||||
host: 'http://localhost:8888',
|
||||
baz: 'quux=dolor',
|
||||
})
|
||||
},
|
||||
).then(() => {
|
||||
this.expectExitWith(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('parses environment variables with empty values', function () {
|
||||
return cypress.start([
|
||||
`--run-project=${this.todosPath}`,
|
||||
'--video=false',
|
||||
'--env=FOO=,BAR=,BAZ=ipsum',
|
||||
])
|
||||
.then(() => {
|
||||
expect(openProject.getProject().cfg.env).to.deep.eq({
|
||||
FOO: '',
|
||||
BAR: '',
|
||||
BAZ: 'ipsum',
|
||||
})
|
||||
|
||||
return assertWithOpenProjectAfterReady(
|
||||
cypress.start([
|
||||
`--run-project=${this.todosPath}`,
|
||||
'--video=false',
|
||||
'--env=FOO=,BAR=,BAZ=ipsum',
|
||||
]),
|
||||
(project) => {
|
||||
expect(project.cfg.env).to.deep.eq({
|
||||
FOO: '',
|
||||
BAR: '',
|
||||
BAZ: 'ipsum',
|
||||
})
|
||||
},
|
||||
).then(() => {
|
||||
this.expectExitWith(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
require('../spec_helper')
|
||||
|
||||
const { makeDataContext, setCtx, getCtx } = require('../../lib/makeDataContext')
|
||||
|
||||
setCtx(makeDataContext({}))
|
||||
const { getCtx, setCtx, makeDataContext, clearCtx } = require('../../lib/makeDataContext')
|
||||
|
||||
const cp = require('child_process')
|
||||
const fse = require('fs-extra')
|
||||
const os = require('os')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const _ = require('lodash')
|
||||
const { expect } = require('chai')
|
||||
@@ -26,7 +25,72 @@ const { ServerBase } = require('../../lib/server-base')
|
||||
const { SocketE2E } = require('../../lib/socket-e2e')
|
||||
const { _getArgs } = require('../../lib/browsers/chrome')
|
||||
|
||||
const CHROME_PATH = 'google-chrome'
|
||||
/**
|
||||
* Resolves an absolute or PATH-resolvable Chrome/Chromium binary for `cp.spawn`.
|
||||
* CI/Linux often exposes `google-chrome`; macOS and Windows need well-known install
|
||||
* locations or an explicit env override.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `PROXY_PERF_CHROME` — preferred override for this spec only.
|
||||
* 2. `CHROME_PATH` — generic override if the first is unset.
|
||||
* 3. macOS: `/Applications/Google Chrome.app/...` then Chrome Canary.
|
||||
* 4. Windows: standard `Program Files` Chrome paths.
|
||||
* 5. Unix: first hit from `which` for `google-chrome`, `google-chrome-stable`, `chromium`, `chromium-browser`.
|
||||
* 6. Fallback `google-chrome` (relies on PATH; matches typical Linux CI images).
|
||||
*/
|
||||
const resolveChromePathForProxyPerformance = () => {
|
||||
const fromEnv = process.env.PROXY_PERF_CHROME || process.env.CHROME_PATH
|
||||
|
||||
if (fromEnv) {
|
||||
return fromEnv
|
||||
}
|
||||
|
||||
const platform = os.platform()
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const macPaths = [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
]
|
||||
|
||||
for (const macPath of macPaths) {
|
||||
if (fs.existsSync(macPath)) {
|
||||
return macPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const winPaths = [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
]
|
||||
|
||||
for (const winPath of winPaths) {
|
||||
if (fs.existsSync(winPath)) {
|
||||
return winPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nixNames = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']
|
||||
|
||||
for (const nixName of nixNames) {
|
||||
const r = cp.spawnSync('which', [nixName], { encoding: 'utf8' })
|
||||
|
||||
if (r.status === 0) {
|
||||
const found = String(r.stdout || '').trim().split('\n')[0]
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'google-chrome'
|
||||
}
|
||||
|
||||
const CHROME_PATH = resolveChromePathForProxyPerformance()
|
||||
const URLS_UNDER_TEST = [
|
||||
'https://test-page-speed.cypress.io/index1000.html',
|
||||
'http://test-page-speed.cypress.io/index1000.html',
|
||||
@@ -334,49 +398,56 @@ describe('Proxy Performance', function () {
|
||||
})
|
||||
|
||||
before(function () {
|
||||
setCtx(makeDataContext({}))
|
||||
// When this file runs after other specs (e.g. cy_visit_performance_spec.js loads first
|
||||
// alphabetically), the prior test's spec_helper `afterEach` has already run `clearCtx`.
|
||||
// Nested suite `before` runs before the next test's root `beforeEach`, so the global
|
||||
// DataContext is still unset here unless we call `setCtx` again.
|
||||
return Promise.resolve(clearCtx()).then(() => {
|
||||
setCtx(makeDataContext({}))
|
||||
const getFilesByGlob = getCtx().file.getFilesByGlob
|
||||
|
||||
const getFilesByGlob = getCtx().file.getFilesByGlob
|
||||
return CA.create()
|
||||
.then((ca) => {
|
||||
return ca.generateServerCertificateKeys('localhost')
|
||||
})
|
||||
.then(([cert, key]) => {
|
||||
return Promise.join(
|
||||
new DebuggingProxy().start(PROXY_PORT),
|
||||
|
||||
return CA.create()
|
||||
.then((ca) => {
|
||||
return ca.generateServerCertificateKeys('localhost')
|
||||
})
|
||||
.spread((cert, key) => {
|
||||
return Promise.join(
|
||||
new DebuggingProxy().start(PROXY_PORT),
|
||||
new DebuggingProxy({
|
||||
https: { cert, key },
|
||||
}).start(HTTPS_PROXY_PORT),
|
||||
|
||||
new DebuggingProxy({
|
||||
https: { cert, key },
|
||||
}).start(HTTPS_PROXY_PORT),
|
||||
setupFullConfigWithDefaults({
|
||||
projectRoot: '/tmp/a',
|
||||
config: {
|
||||
supportFile: false,
|
||||
},
|
||||
}, getFilesByGlob).then((config) => {
|
||||
config.port = CY_PROXY_PORT
|
||||
|
||||
setupFullConfigWithDefaults({
|
||||
projectRoot: '/tmp/a',
|
||||
config: {
|
||||
supportFile: false,
|
||||
},
|
||||
}, getFilesByGlob).then((config) => {
|
||||
config.port = CY_PROXY_PORT
|
||||
// turn off morgan
|
||||
config.morgan = false
|
||||
|
||||
// turn off morgan
|
||||
config.morgan = false
|
||||
cyServer = new ServerBase(config)
|
||||
|
||||
cyServer = new ServerBase()
|
||||
|
||||
return cyServer.open(config, {
|
||||
SocketCtor: SocketE2E,
|
||||
createRoutes,
|
||||
testingType: 'e2e',
|
||||
getCurrentBrowser: () => null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
return cyServer.open(config, {
|
||||
SocketCtor: SocketE2E,
|
||||
createRoutes,
|
||||
testingType: 'e2e',
|
||||
getCurrentBrowser: () => null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
URLS_UNDER_TEST.map((urlUnderTest) => {
|
||||
// TODO: fix flaky tests https://github.com/cypress-io/cypress/issues/23214
|
||||
describe(urlUnderTest, { retries: 15 }, function () {
|
||||
describe(urlUnderTest, function () {
|
||||
this.retries(15)
|
||||
|
||||
let baseline
|
||||
const testCases = _.cloneDeep(TEST_CASES)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ require('chai')
|
||||
.use(require('chai-uuid'))
|
||||
.use(require('chai-as-promised'))
|
||||
|
||||
const { GracefulExit } = require('../lib/util/graceful-exit')
|
||||
|
||||
if (process.env.UPDATE) {
|
||||
throw new Error('You\'re using UPDATE=1 which is the old way of updating snapshots.\n\nThe correct environment variable is SNAPSHOT_UPDATE=1')
|
||||
}
|
||||
@@ -102,6 +104,7 @@ before(async () => {
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
GracefulExit.resetForTesting()
|
||||
await clearCtx()
|
||||
setCtx(makeDataContext({}))
|
||||
this.originalEnv = originalEnv
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { publishStagingToFinal } from '../../lib/cloud/bundles/publish_staging_to_final'
|
||||
|
||||
const [staging, finalDir] = process.argv.slice(2)
|
||||
|
||||
if (!staging || !finalDir) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('usage: cross_process_publish_worker.ts <staging> <finalDir>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
publishStagingToFinal(staging, finalDir).then(
|
||||
() => process.exit(0),
|
||||
(err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
},
|
||||
)
|
||||
@@ -1,291 +0,0 @@
|
||||
import { proxyquire } from '../../../../spec_helper'
|
||||
import { Readable, Writable } from 'stream'
|
||||
import { HttpError } from '../../../../../lib/cloud/network/http_error'
|
||||
import sinon from 'sinon'
|
||||
|
||||
describe('getCyPromptBundle', () => {
|
||||
let writeResult: string
|
||||
let readStream: Readable
|
||||
let createWriteStreamStub: sinon.SinonStub
|
||||
let crossFetchStub: sinon.SinonStub
|
||||
let verifySignatureFromFileStub: sinon.SinonStub
|
||||
let getCyPromptBundle: typeof import('../../../../../lib/cloud/api/cy-prompt/get_cy_prompt_bundle').getCyPromptBundle
|
||||
|
||||
beforeEach(() => {
|
||||
createWriteStreamStub = sinon.stub()
|
||||
crossFetchStub = sinon.stub()
|
||||
verifySignatureFromFileStub = sinon.stub()
|
||||
readStream = Readable.from('console.log("cy-prompt script")')
|
||||
|
||||
writeResult = ''
|
||||
const writeStream = new Writable({
|
||||
write: (chunk, encoding, callback) => {
|
||||
writeResult += chunk.toString()
|
||||
callback()
|
||||
},
|
||||
})
|
||||
|
||||
createWriteStreamStub.returns(writeStream)
|
||||
|
||||
getCyPromptBundle = proxyquire('../lib/cloud/api/cy-prompt/get_cy_prompt_bundle', {
|
||||
'fs': {
|
||||
createWriteStream: createWriteStreamStub,
|
||||
},
|
||||
'cross-fetch': crossFetchStub,
|
||||
'os': {
|
||||
platform: () => 'linux',
|
||||
},
|
||||
'@packages/root': {
|
||||
version: '1.2.3',
|
||||
},
|
||||
'../../encryption': {
|
||||
verifySignatureFromFile: verifySignatureFromFileStub,
|
||||
},
|
||||
}).getCyPromptBundle
|
||||
})
|
||||
|
||||
it('downloads the cy-prompt bundle and extracts it', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
verifySignatureFromFileStub.resolves(true)
|
||||
|
||||
const projectId = '12345'
|
||||
|
||||
const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-cypress-project-slug': '12345',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
|
||||
expect(writeResult).to.eq('console.log("cy-prompt script")')
|
||||
|
||||
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
|
||||
|
||||
expect(responseSignature).to.eq('160')
|
||||
})
|
||||
|
||||
it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => {
|
||||
crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any))
|
||||
crossFetchStub.onSecondCall().resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
verifySignatureFromFileStub.resolves(true)
|
||||
|
||||
const projectId = '12345'
|
||||
|
||||
const responseSignature = await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-cypress-project-slug': '12345',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
|
||||
expect(writeResult).to.eq('console.log("cy-prompt script")')
|
||||
|
||||
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
|
||||
|
||||
expect(responseSignature).to.eq('160')
|
||||
})
|
||||
|
||||
it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => {
|
||||
const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any)
|
||||
|
||||
crossFetchStub.rejects(error)
|
||||
|
||||
const projectId = '12345'
|
||||
|
||||
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
|
||||
|
||||
expect(crossFetchStub).to.be.calledThrice
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-cypress-project-slug': '12345',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error and returns a cy-prompt manager in error state if the response status is not ok', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: false,
|
||||
statusText: 'Some failure',
|
||||
})
|
||||
|
||||
const projectId = '12345'
|
||||
|
||||
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-cypress-project-slug': '12345',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error and returns a cy-prompt manager in error state if the signature verification fails', async () => {
|
||||
verifySignatureFromFileStub.resolves(false)
|
||||
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
verifySignatureFromFileStub.resolves(false)
|
||||
|
||||
const projectId = '12345'
|
||||
|
||||
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected
|
||||
|
||||
expect(writeResult).to.eq('console.log("cy-prompt script")')
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-cypress-project-slug': '12345',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
|
||||
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159')
|
||||
})
|
||||
|
||||
it('throws an error if there is no signature in the response headers', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const projectId = '12345'
|
||||
|
||||
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt signature')
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-cypress-project-slug': '12345',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error if there is no manifest signature in the response headers', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const projectId = '12345'
|
||||
|
||||
await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejectedWith('Unable to get cy-prompt manifest signature')
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-cypress-project-slug': '12345',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,322 +0,0 @@
|
||||
import { proxyquire } from '../../../../spec_helper'
|
||||
import { Readable, Writable } from 'stream'
|
||||
import { HttpError } from '../../../../../lib/cloud/network/http_error'
|
||||
import sinon from 'sinon'
|
||||
|
||||
describe('getStudioBundle', () => {
|
||||
let writeResult: string
|
||||
let readStream: Readable
|
||||
let createWriteStreamStub: sinon.SinonStub
|
||||
let crossFetchStub: sinon.SinonStub
|
||||
let verifySignatureFromFileStub: sinon.SinonStub
|
||||
let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle
|
||||
|
||||
beforeEach(() => {
|
||||
createWriteStreamStub = sinon.stub()
|
||||
crossFetchStub = sinon.stub()
|
||||
verifySignatureFromFileStub = sinon.stub()
|
||||
readStream = Readable.from('console.log("studio bundle")')
|
||||
|
||||
writeResult = ''
|
||||
const writeStream = new Writable({
|
||||
write: (chunk, encoding, callback) => {
|
||||
writeResult += chunk.toString()
|
||||
callback()
|
||||
},
|
||||
})
|
||||
|
||||
createWriteStreamStub.returns(writeStream)
|
||||
|
||||
getStudioBundle = proxyquire('../lib/cloud/api/studio/get_studio_bundle', {
|
||||
'fs': {
|
||||
createWriteStream: createWriteStreamStub,
|
||||
},
|
||||
'cross-fetch': crossFetchStub,
|
||||
'os': {
|
||||
platform: () => 'linux',
|
||||
},
|
||||
'@packages/root': {
|
||||
version: '1.2.3',
|
||||
},
|
||||
'../../encryption': {
|
||||
verifySignatureFromFile: verifySignatureFromFileStub,
|
||||
},
|
||||
}).getStudioBundle
|
||||
})
|
||||
|
||||
it('downloads the studio bundle and extracts it', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
verifySignatureFromFileStub.resolves(true)
|
||||
|
||||
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: sinon.match.any,
|
||||
})
|
||||
|
||||
expect(writeResult).to.eq('console.log("studio bundle")')
|
||||
|
||||
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
|
||||
|
||||
expect(responseSignature).to.eq('160')
|
||||
})
|
||||
|
||||
it('downloads the studio bundle and extracts it after 1 fetch failure', async () => {
|
||||
crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any))
|
||||
crossFetchStub.onSecondCall().resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
verifySignatureFromFileStub.resolves(true)
|
||||
|
||||
const responseSignature = await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: sinon.match.any,
|
||||
})
|
||||
|
||||
expect(writeResult).to.eq('console.log("studio bundle")')
|
||||
|
||||
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
|
||||
|
||||
expect(responseSignature).to.eq('160')
|
||||
})
|
||||
|
||||
it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => {
|
||||
const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub() as any)
|
||||
|
||||
crossFetchStub.rejects(error)
|
||||
|
||||
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
|
||||
|
||||
expect(crossFetchStub).to.be.calledThrice
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: sinon.match.any,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error and returns a studio manager in error state if the response status is not ok', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: false,
|
||||
statusText: 'Some failure',
|
||||
})
|
||||
|
||||
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: sinon.match.any,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error and returns a cy-prompt manager in error state if the signature verification fails', async () => {
|
||||
verifySignatureFromFileStub.resolves(false)
|
||||
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
verifySignatureFromFileStub.resolves(false)
|
||||
|
||||
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected
|
||||
|
||||
expect(writeResult).to.eq('console.log("studio bundle")')
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: sinon.match.any,
|
||||
})
|
||||
|
||||
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
|
||||
})
|
||||
|
||||
it('throws an error if there is no signature in the response headers', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-manifest-signature') {
|
||||
return '160'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio signature')
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: sinon.match.any,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an error if there is no manifest signature in the response headers', async () => {
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') {
|
||||
return '159'
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejectedWith('Unable to get studio manifest signature')
|
||||
|
||||
expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', {
|
||||
agent: sinon.match((agent) => agent.httpsAgent.options.rejectUnauthorized === true),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-route-version': '1',
|
||||
'x-cypress-signature': '1',
|
||||
'x-os-name': 'linux',
|
||||
'x-cypress-version': '1.2.3',
|
||||
},
|
||||
encrypt: 'signed',
|
||||
signal: sinon.match.any,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles AbortError and converts to timeout message', async () => {
|
||||
const abortError = new Error('AbortError')
|
||||
|
||||
abortError.name = 'AbortError'
|
||||
|
||||
crossFetchStub.rejects(abortError)
|
||||
|
||||
await expect(getStudioBundle({
|
||||
studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz',
|
||||
bundlePath: '/tmp/cypress/studio/abc/bundle.tar',
|
||||
})).to.be.rejectedWith('Studio bundle fetch timed out')
|
||||
})
|
||||
|
||||
it('calls cleanup function when pipe operation errors', async () => {
|
||||
const errorStream = new Writable({
|
||||
write: (chunk, encoding, callback) => {
|
||||
callback(new Error('Write error'))
|
||||
},
|
||||
})
|
||||
|
||||
const destroySpy = sinon.spy(errorStream, 'destroy')
|
||||
|
||||
createWriteStreamStub.returns(errorStream)
|
||||
|
||||
crossFetchStub.resolves({
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
body: readStream,
|
||||
headers: {
|
||||
get: (header) => {
|
||||
if (header === 'x-cypress-signature') return '159'
|
||||
|
||||
if (header === 'x-cypress-manifest-signature') return '160'
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await expect(getStudioBundle({
|
||||
studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz',
|
||||
bundlePath: '/tmp/cypress/studio/abc/bundle.tar',
|
||||
})).to.be.rejected
|
||||
|
||||
expect(destroySpy).to.have.been.called
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import '../../../spec_helper'
|
||||
import { BundleError } from '../../../../lib/cloud/bundles/bundle_error'
|
||||
|
||||
describe('BundleError', () => {
|
||||
it('carries kind and stage tags', () => {
|
||||
const err = new BundleError({ kind: 'cy-prompt', stage: 'signature', message: 'bad sig' })
|
||||
|
||||
expect(err).to.be.instanceOf(Error)
|
||||
expect(err.name).to.equal('BundleError')
|
||||
expect(err.kind).to.equal('cy-prompt')
|
||||
expect(err.stage).to.equal('signature')
|
||||
expect(err.message).to.equal('bad sig')
|
||||
})
|
||||
|
||||
it('preserves the cause when provided', () => {
|
||||
const cause = new Error('upstream')
|
||||
const err = new BundleError({ kind: 'studio', stage: 'network', message: 'wrapper', cause })
|
||||
|
||||
expect((err as Error & { cause?: unknown }).cause).to.equal(cause)
|
||||
})
|
||||
|
||||
it('isBundleError narrows the type and returns true for own instances', () => {
|
||||
const err = new BundleError({ kind: 'studio', stage: 'extract', message: 'm' })
|
||||
|
||||
expect(BundleError.isBundleError(err)).to.equal(true)
|
||||
expect(BundleError.isBundleError(new Error('plain'))).to.equal(false)
|
||||
expect(BundleError.isBundleError(undefined)).to.equal(false)
|
||||
})
|
||||
|
||||
it('mirrors the cause syscall code on the BundleError itself', () => {
|
||||
const cause = Object.assign(new Error('cert err'), { code: 'CERT_HAS_EXPIRED' })
|
||||
const err = new BundleError({ kind: 'studio', stage: 'network', message: 'wrapper', cause })
|
||||
|
||||
expect(err.code).to.equal('CERT_HAS_EXPIRED')
|
||||
})
|
||||
|
||||
it('leaves code undefined when cause has no string code', () => {
|
||||
const err1 = new BundleError({ kind: 'studio', stage: 'extract', message: 'm' })
|
||||
|
||||
expect(err1.code).to.equal(undefined)
|
||||
|
||||
const err2 = new BundleError({ kind: 'studio', stage: 'extract', message: 'm', cause: new Error('plain') })
|
||||
|
||||
expect(err2.code).to.equal(undefined)
|
||||
|
||||
const err3 = new BundleError({ kind: 'studio', stage: 'extract', message: 'm', cause: { code: 42 } })
|
||||
|
||||
expect(err3.code).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import '../../../spec_helper'
|
||||
import path from 'path'
|
||||
|
||||
describe('getBundleCacheDir', () => {
|
||||
const ENV_KEYS = [
|
||||
'CYPRESS_CACHE_FOLDER',
|
||||
'npm_config_CYPRESS_CACHE_FOLDER',
|
||||
'npm_config_cypress_cache_folder',
|
||||
'npm_package_config_CYPRESS_CACHE_FOLDER',
|
||||
]
|
||||
const snapshot: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
snapshot[k] = process.env[k]
|
||||
delete process.env[k]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (snapshot[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = snapshot[k]
|
||||
}
|
||||
})
|
||||
|
||||
// Bypass require cache so each test sees the current env state.
|
||||
const loadCacheRoot = () => {
|
||||
delete require.cache[require.resolve('../../../../lib/cloud/bundles/cache_root')]
|
||||
|
||||
return require('../../../../lib/cloud/bundles/cache_root')
|
||||
}
|
||||
|
||||
it('honors CYPRESS_CACHE_FOLDER as-is for a clean absolute path', () => {
|
||||
process.env.CYPRESS_CACHE_FOLDER = '/tmp/cypress-cache-test'
|
||||
const { getBundleCacheDir } = loadCacheRoot()
|
||||
|
||||
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/cypress-cache-test/bundles/cy-prompt'))
|
||||
})
|
||||
|
||||
it('strips surrounding double quotes (Windows CMD `set FOO="C:\\path"` style)', () => {
|
||||
process.env.CYPRESS_CACHE_FOLDER = '"/tmp/cypress-cache-test"'
|
||||
const { getBundleCacheDir } = loadCacheRoot()
|
||||
|
||||
expect(getBundleCacheDir('studio')).to.equal(path.resolve('/tmp/cypress-cache-test/bundles/studio'))
|
||||
})
|
||||
|
||||
it('trims whitespace around the env var value', () => {
|
||||
process.env.CYPRESS_CACHE_FOLDER = ' /tmp/cypress-cache-test '
|
||||
const { getBundleCacheDir } = loadCacheRoot()
|
||||
|
||||
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/cypress-cache-test/bundles/cy-prompt'))
|
||||
})
|
||||
|
||||
it('falls back to npm_config_CYPRESS_CACHE_FOLDER when the bare var is not set', () => {
|
||||
process.env.npm_config_CYPRESS_CACHE_FOLDER = '/tmp/from-npmrc'
|
||||
const { getBundleCacheDir } = loadCacheRoot()
|
||||
|
||||
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/from-npmrc/bundles/cy-prompt'))
|
||||
})
|
||||
|
||||
it('falls back to lowercase npm_config variant', () => {
|
||||
process.env.npm_config_cypress_cache_folder = '/tmp/from-npmrc-lower'
|
||||
const { getBundleCacheDir } = loadCacheRoot()
|
||||
|
||||
expect(getBundleCacheDir('studio')).to.equal(path.resolve('/tmp/from-npmrc-lower/bundles/studio'))
|
||||
})
|
||||
|
||||
it('prefers the bare env var over npm_config_* when both are set', () => {
|
||||
process.env.CYPRESS_CACHE_FOLDER = '/tmp/bare'
|
||||
process.env.npm_config_CYPRESS_CACHE_FOLDER = '/tmp/npmrc'
|
||||
const { getBundleCacheDir } = loadCacheRoot()
|
||||
|
||||
expect(getBundleCacheDir('cy-prompt')).to.equal(path.resolve('/tmp/bare/bundles/cy-prompt'))
|
||||
})
|
||||
|
||||
it('treats empty / whitespace-only override as unset and falls back to cachedir()', () => {
|
||||
process.env.CYPRESS_CACHE_FOLDER = ' '
|
||||
const { getBundleCacheDir } = loadCacheRoot()
|
||||
|
||||
// cachedir('Cypress') varies by OS; just assert the bundles/<kind> tail.
|
||||
expect(getBundleCacheDir('studio')).to.match(/[/\\]bundles[/\\]studio$/)
|
||||
expect(getBundleCacheDir('studio')).to.not.equal(path.resolve('bundles/studio'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { proxyquire, sinon } from '../../../spec_helper'
|
||||
import { ensureDir, mkdtemp, pathExists, readFile, remove, writeFile } from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { BundleError } from '../../../../lib/cloud/bundles/bundle_error'
|
||||
|
||||
const FIXTURE_MANIFEST = { version: 1, entrypoint: 'server/index.js' }
|
||||
const MANIFEST_TEXT = JSON.stringify(FIXTURE_MANIFEST)
|
||||
|
||||
const writeFixtureToStaging = async (staging: string) => {
|
||||
await ensureDir(path.join(staging, 'server'))
|
||||
await writeFile(path.join(staging, 'manifest.json'), MANIFEST_TEXT)
|
||||
await writeFile(path.join(staging, 'server', 'index.js'), '// server entrypoint\n')
|
||||
}
|
||||
|
||||
interface SetupResult {
|
||||
ensureSignedBundle: typeof import('../../../../lib/cloud/bundles/ensure_signed_bundle').ensureSignedBundle
|
||||
streamStub: sinon.SinonStub
|
||||
verifySignatureStub: sinon.SinonStub
|
||||
}
|
||||
|
||||
describe('ensureSignedBundle', () => {
|
||||
let cacheRoot: string
|
||||
let originalCacheFolder: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
cacheRoot = await mkdtemp(path.join(os.tmpdir(), 'cy-ensure-bundle-'))
|
||||
originalCacheFolder = process.env.CYPRESS_CACHE_FOLDER
|
||||
process.env.CYPRESS_CACHE_FOLDER = cacheRoot
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalCacheFolder === undefined) {
|
||||
delete process.env.CYPRESS_CACHE_FOLDER
|
||||
} else {
|
||||
process.env.CYPRESS_CACHE_FOLDER = originalCacheFolder
|
||||
}
|
||||
|
||||
await remove(cacheRoot).catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
const setup = (overrides: Partial<{
|
||||
streamImpl: (opts: { staging: string }) => Promise<string>
|
||||
verifyResult: boolean
|
||||
}> = {}): SetupResult => {
|
||||
const streamStub = sinon.stub().callsFake(async (opts: { staging: string }) => {
|
||||
if (overrides.streamImpl) return overrides.streamImpl(opts)
|
||||
|
||||
await writeFixtureToStaging(opts.staging)
|
||||
|
||||
return 'fake-manifest-sig'
|
||||
})
|
||||
|
||||
const verifySignatureStub = sinon.stub().resolves(overrides.verifyResult ?? true)
|
||||
|
||||
const ensureSignedBundleModule = proxyquire('../lib/cloud/bundles/ensure_signed_bundle', {
|
||||
'./stream_download_verify_extract': {
|
||||
streamDownloadVerifyExtract: streamStub,
|
||||
},
|
||||
'../encryption': {
|
||||
verifySignature: verifySignatureStub,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
ensureSignedBundle: ensureSignedBundleModule.ensureSignedBundle,
|
||||
streamStub,
|
||||
verifySignatureStub,
|
||||
}
|
||||
}
|
||||
|
||||
it('publishes verified bundle into <cache>/bundles/<kind>/<hash>/ and returns the manifest', async () => {
|
||||
const { ensureSignedBundle, streamStub, verifySignatureStub } = setup()
|
||||
|
||||
const result = await ensureSignedBundle({
|
||||
url: 'https://cdn.cypress.io/cy-prompt/abc123.tar',
|
||||
projectId: 'proj-1',
|
||||
kind: 'cy-prompt',
|
||||
})
|
||||
|
||||
const expectedBundleDir = path.join(cacheRoot, 'bundles', 'cy-prompt', 'abc123')
|
||||
|
||||
expect(result.bundleDir).to.equal(expectedBundleDir)
|
||||
expect(result.manifest).to.deep.equal(FIXTURE_MANIFEST)
|
||||
|
||||
expect(await readFile(path.join(expectedBundleDir, 'manifest.json'), 'utf8')).to.equal(MANIFEST_TEXT)
|
||||
expect(await readFile(path.join(expectedBundleDir, 'server', 'index.js'), 'utf8')).to.equal('// server entrypoint\n')
|
||||
|
||||
expect(streamStub).to.be.calledOnce
|
||||
expect(verifySignatureStub).to.be.calledWith(MANIFEST_TEXT, 'fake-manifest-sig')
|
||||
|
||||
// Staging dir is cleaned up
|
||||
const baseDir = path.dirname(expectedBundleDir)
|
||||
const fs = require('fs-extra')
|
||||
const remaining: string[] = await fs.readdir(baseDir)
|
||||
|
||||
expect(remaining.filter((n: string) => n.startsWith('.staging-'))).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('throws BundleError(stage=manifest) when the manifest signature fails to verify', async () => {
|
||||
const { ensureSignedBundle } = setup({ verifyResult: false })
|
||||
|
||||
let caught: unknown
|
||||
|
||||
try {
|
||||
await ensureSignedBundle({
|
||||
url: 'https://cdn.cypress.io/studio/badsig.tar',
|
||||
kind: 'studio',
|
||||
})
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
|
||||
expect(BundleError.isBundleError(caught)).to.equal(true)
|
||||
expect((caught as BundleError).stage).to.equal('manifest')
|
||||
expect((caught as BundleError).kind).to.equal('studio')
|
||||
|
||||
// finalDir was created (empty) but no files published
|
||||
const finalDir = path.join(cacheRoot, 'bundles', 'studio', 'badsig')
|
||||
|
||||
expect(await pathExists(path.join(finalDir, 'manifest.json'))).to.equal(false)
|
||||
expect(await pathExists(path.join(finalDir, 'server', 'index.js'))).to.equal(false)
|
||||
})
|
||||
|
||||
it('throws BundleError(stage=manifest) when manifest.json is missing from staging', async () => {
|
||||
const { ensureSignedBundle } = setup({
|
||||
streamImpl: async ({ staging }) => {
|
||||
// populate everything except manifest.json
|
||||
await ensureDir(path.join(staging, 'server'))
|
||||
await writeFile(path.join(staging, 'server', 'index.js'), '// orphan\n')
|
||||
|
||||
return 'sig'
|
||||
},
|
||||
})
|
||||
|
||||
let caught: unknown
|
||||
|
||||
try {
|
||||
await ensureSignedBundle({
|
||||
url: 'https://cdn.cypress.io/cy-prompt/no-manifest.tar',
|
||||
kind: 'cy-prompt',
|
||||
})
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
|
||||
expect(BundleError.isBundleError(caught)).to.equal(true)
|
||||
expect((caught as BundleError).stage).to.equal('manifest')
|
||||
|
||||
const finalDir = path.join(cacheRoot, 'bundles', 'cy-prompt', 'no-manifest')
|
||||
|
||||
expect(await pathExists(path.join(finalDir, 'server', 'index.js'))).to.equal(false)
|
||||
})
|
||||
|
||||
it('propagates network errors raised by streamDownloadVerifyExtract without touching finalDir', async () => {
|
||||
const networkError = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })
|
||||
const { ensureSignedBundle } = setup({
|
||||
streamImpl: async () => {
|
||||
throw networkError
|
||||
},
|
||||
})
|
||||
|
||||
let caught: unknown
|
||||
|
||||
try {
|
||||
await ensureSignedBundle({
|
||||
url: 'https://cdn.cypress.io/studio/net-fail.tar',
|
||||
kind: 'studio',
|
||||
})
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
|
||||
expect(caught).to.equal(networkError)
|
||||
|
||||
const finalDir = path.join(cacheRoot, 'bundles', 'studio', 'net-fail')
|
||||
|
||||
expect(await pathExists(path.join(finalDir, 'manifest.json'))).to.equal(false)
|
||||
})
|
||||
|
||||
it('cleans up staging dir even when publish fails', async () => {
|
||||
const { ensureSignedBundle } = setup()
|
||||
|
||||
// Force publish failure by making finalDir un-renamable: pre-create an immutable
|
||||
// file at the target manifest path (we'll simulate by removing write permission
|
||||
// only on POSIX; on Windows skip with a comment).
|
||||
if (process.platform === 'win32') return // simpler skip than juggling ACLs
|
||||
|
||||
const finalDir = path.join(cacheRoot, 'bundles', 'cy-prompt', 'pubfail')
|
||||
|
||||
await ensureDir(finalDir)
|
||||
|
||||
// Make finalDir read-only so renames inside it fail with EACCES
|
||||
const fs = require('fs-extra')
|
||||
|
||||
await fs.chmod(finalDir, 0o500)
|
||||
|
||||
try {
|
||||
await ensureSignedBundle({
|
||||
url: 'https://cdn.cypress.io/cy-prompt/pubfail.tar',
|
||||
kind: 'cy-prompt',
|
||||
}).catch(() => { /* expected */ })
|
||||
} finally {
|
||||
await fs.chmod(finalDir, 0o755)
|
||||
}
|
||||
|
||||
const baseDir = path.dirname(finalDir)
|
||||
const remaining: string[] = await fs.readdir(baseDir)
|
||||
|
||||
expect(remaining.filter((n: string) => n.startsWith('.staging-'))).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../../../spec_helper'
|
||||
import { parseHashFromBundleUrl } from '../../../../lib/cloud/bundles/parse_hash_from_bundle_url'
|
||||
|
||||
describe('parseHashFromBundleUrl', () => {
|
||||
it('returns the hash portion of a typical bundle url', () => {
|
||||
expect(parseHashFromBundleUrl('https://cdn.cypress.io/cy-prompt/abc123def456.tar')).to.equal('abc123def456')
|
||||
})
|
||||
|
||||
it('strips multiple extension segments', () => {
|
||||
expect(parseHashFromBundleUrl('https://cdn.cypress.io/studio/abc123.tar.gz')).to.equal('abc123')
|
||||
})
|
||||
|
||||
it('handles a url without an extension', () => {
|
||||
expect(parseHashFromBundleUrl('https://cdn.cypress.io/cy-prompt/abc123')).to.equal('abc123')
|
||||
})
|
||||
|
||||
it('throws when the url has no path segment', () => {
|
||||
expect(() => parseHashFromBundleUrl('https://cdn.cypress.io/')).to.throw(/Unable to parse bundle hash/)
|
||||
})
|
||||
|
||||
it('throws when the url is empty', () => {
|
||||
expect(() => parseHashFromBundleUrl('')).to.throw(/Unable to parse bundle hash/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { proxyquire, sinon } from '../../../spec_helper'
|
||||
import { ensureDir, mkdtemp, pathExists, readFile, remove, writeFile } from 'fs-extra'
|
||||
import { spawn } from 'child_process'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import * as extractAtomic from '../../../../lib/cloud/extract_atomic'
|
||||
|
||||
const TS_REGISTER = require.resolve('@packages/ts/register')
|
||||
const WORKER_PATH = path.resolve(__dirname, '../../../support/cross_process_publish_worker.ts')
|
||||
|
||||
const populateStaging = async (staging: string, files: Record<string, string>) => {
|
||||
await ensureDir(staging)
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(staging, rel)
|
||||
|
||||
await ensureDir(path.dirname(full))
|
||||
await writeFile(full, content)
|
||||
}
|
||||
}
|
||||
|
||||
const FIXTURE_FILES = (() => {
|
||||
const files: Record<string, string> = {
|
||||
'manifest.json': JSON.stringify({ version: 1, files: {} }),
|
||||
'server/index.js': '// hello cypress\n'.repeat(50),
|
||||
'README.md': 'bundle readme\n',
|
||||
}
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
files[`assets/file_${String(i).padStart(3, '0')}.txt`] = `payload-${i}\n`.repeat(20)
|
||||
}
|
||||
|
||||
return files
|
||||
})()
|
||||
|
||||
describe('publishStagingToFinal', () => {
|
||||
let tmp: string
|
||||
let staging: string
|
||||
let finalDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(os.tmpdir(), 'cy-publish-'))
|
||||
staging = path.join(tmp, 'staging')
|
||||
finalDir = path.join(tmp, 'final')
|
||||
await ensureDir(finalDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await remove(tmp).catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
it('publishes all files from staging into finalDir', async () => {
|
||||
await populateStaging(staging, FIXTURE_FILES)
|
||||
|
||||
const { publishStagingToFinal } = require('../../../../lib/cloud/bundles/publish_staging_to_final')
|
||||
|
||||
await publishStagingToFinal(staging, finalDir)
|
||||
|
||||
for (const [rel, expected] of Object.entries(FIXTURE_FILES)) {
|
||||
const actual = await readFile(path.join(finalDir, rel), 'utf8')
|
||||
|
||||
expect(actual).to.equal(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('renames manifest.json last', async () => {
|
||||
await populateStaging(staging, FIXTURE_FILES)
|
||||
|
||||
const renamedOrder: string[] = []
|
||||
const renameSpy = sinon.stub().callsFake(async (src: string, dst: string) => {
|
||||
await extractAtomic.renameAtomicWithRetry(src, dst)
|
||||
renamedOrder.push(path.relative(finalDir, dst).split(path.sep).join('/'))
|
||||
})
|
||||
|
||||
const { publishStagingToFinal } = proxyquire('../lib/cloud/bundles/publish_staging_to_final', {
|
||||
'../extract_atomic': {
|
||||
renameAtomicWithRetry: renameSpy,
|
||||
},
|
||||
})
|
||||
|
||||
await publishStagingToFinal(staging, finalDir)
|
||||
|
||||
expect(renamedOrder.length).to.equal(Object.keys(FIXTURE_FILES).length)
|
||||
expect(renamedOrder[renamedOrder.length - 1]).to.equal('manifest.json')
|
||||
// every non-manifest entry must precede manifest in the order
|
||||
const manifestIdx = renamedOrder.indexOf('manifest.json')
|
||||
|
||||
expect(manifestIdx).to.equal(renamedOrder.length - 1)
|
||||
})
|
||||
|
||||
it('drains in-flight renames before throwing when one rejects (no unhandled-rejection leakage)', async () => {
|
||||
await populateStaging(staging, FIXTURE_FILES)
|
||||
|
||||
const fastReject = sinon.stub().rejects(Object.assign(new Error('EACCES: denied'), { code: 'EACCES' }))
|
||||
let slowResolved = false
|
||||
const slowResolve = sinon.stub().callsFake(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
slowResolved = true
|
||||
})
|
||||
const renameStub = sinon.stub().callsFake(async (src: string, _dst: string) => {
|
||||
if (src.endsWith('assets/file_000.txt')) return fastReject(src, _dst)
|
||||
|
||||
return slowResolve(src, _dst)
|
||||
})
|
||||
|
||||
const { publishStagingToFinal } = proxyquire('../lib/cloud/bundles/publish_staging_to_final', {
|
||||
'../extract_atomic': { renameAtomicWithRetry: renameStub },
|
||||
})
|
||||
|
||||
const unhandled: unknown[] = []
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason)
|
||||
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
try {
|
||||
await expect(publishStagingToFinal(staging, finalDir)).to.be.rejectedWith(/EACCES/)
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
|
||||
expect(slowResolved, 'slow renames should have settled before the function returned').to.equal(true)
|
||||
expect(unhandled, 'no unhandled rejections from in-flight publishOne calls').to.deep.equal([])
|
||||
})
|
||||
|
||||
it('cross-process: parallel publishers + reader sees no absent or partial bytes', async function () {
|
||||
this.timeout(30000)
|
||||
|
||||
const stagingA = path.join(tmp, 'staging-a')
|
||||
const stagingB = path.join(tmp, 'staging-b')
|
||||
const watchedFile = 'assets/file_010.txt'
|
||||
const expectedContent = FIXTURE_FILES[watchedFile]
|
||||
|
||||
// Pre-place the watched file so the reader has a baseline before either
|
||||
// publisher renames over it.
|
||||
await ensureDir(path.dirname(path.join(finalDir, watchedFile)))
|
||||
await writeFile(path.join(finalDir, watchedFile), expectedContent)
|
||||
|
||||
await populateStaging(stagingA, FIXTURE_FILES)
|
||||
await populateStaging(stagingB, FIXTURE_FILES)
|
||||
|
||||
let stop = false
|
||||
let reads = 0
|
||||
let enoentObserved = 0
|
||||
const corruptObserved: string[] = []
|
||||
|
||||
const readerLoop = (async () => {
|
||||
while (!stop) {
|
||||
try {
|
||||
const buf = await readFile(path.join(finalDir, watchedFile), 'utf8')
|
||||
|
||||
reads++
|
||||
if (buf !== expectedContent) corruptObserved.push(buf.slice(0, 50))
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ENOENT') enoentObserved++
|
||||
else throw err
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
const runChild = (staging: string) => {
|
||||
return new Promise<{ code: number, stderr: string }>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['-r', TS_REGISTER, WORKER_PATH, staging, finalDir], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
|
||||
child.stderr.on('data', (chunk) => stderr += chunk.toString('utf8'))
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code) => resolve({ code: code ?? -1, stderr }))
|
||||
})
|
||||
}
|
||||
|
||||
const [childA, childB] = await Promise.all([runChild(stagingA), runChild(stagingB)])
|
||||
|
||||
stop = true
|
||||
await readerLoop
|
||||
|
||||
if (childA.code !== 0) throw new Error(`child A exited with ${childA.code}: ${childA.stderr}`)
|
||||
|
||||
if (childB.code !== 0) throw new Error(`child B exited with ${childB.code}: ${childB.stderr}`)
|
||||
|
||||
expect(enoentObserved, 'reader must never see ENOENT for an already-published file').to.equal(0)
|
||||
expect(corruptObserved, 'reader must always read complete bytes').to.deep.equal([])
|
||||
expect(reads, 'reader loop should run at least once').to.be.greaterThan(0)
|
||||
|
||||
// After both publishers exit, finalDir must contain every file from the bundle.
|
||||
for (const rel of Object.keys(FIXTURE_FILES)) {
|
||||
const dst = path.join(finalDir, rel)
|
||||
|
||||
expect(await pathExists(dst), `${rel} must exist in finalDir`).to.equal(true)
|
||||
const actual = await readFile(dst, 'utf8')
|
||||
|
||||
expect(actual, `${rel} content must match expected`).to.equal(FIXTURE_FILES[rel])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { proxyquire, sinon } from '../../../spec_helper'
|
||||
import { mkdtemp, remove } from 'fs-extra'
|
||||
import { Readable } from 'stream'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { BundleError } from '../../../../lib/cloud/bundles/bundle_error'
|
||||
import { SystemError } from '../../../../lib/cloud/network/system_error'
|
||||
import { HttpError } from '../../../../lib/cloud/network/http_error'
|
||||
|
||||
const proxyquireWithFastDelay = (fetchStub: sinon.SinonStub) => {
|
||||
// Collapse the retry delay so the budget burns in milliseconds.
|
||||
const { asyncRetry } = require('../../../../lib/util/async_retry')
|
||||
|
||||
return proxyquire('../lib/cloud/bundles/stream_download_verify_extract', {
|
||||
'cross-fetch': fetchStub,
|
||||
'../../util/async_retry': {
|
||||
asyncRetry,
|
||||
linearDelay: () => () => 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const callIt = async (fn: any, kind: 'cy-prompt' | 'studio', staging: string) => {
|
||||
let caught: any
|
||||
|
||||
try {
|
||||
await fn({
|
||||
url: `https://cdn.cypress.io/${kind}/abc123.tar`,
|
||||
staging,
|
||||
kind,
|
||||
})
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
|
||||
return caught
|
||||
}
|
||||
|
||||
const collectErrors = (caught: any): Error[] => caught?.errors ?? [caught]
|
||||
|
||||
describe('streamDownloadVerifyExtract', () => {
|
||||
let tmp: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(os.tmpdir(), 'cy-stream-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await remove(tmp).catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
describe('error tagging + retry', () => {
|
||||
it('wraps fetch timeout as BundleError(stage=network, cause: SystemError ETIMEDOUT) and burns full retry budget', async () => {
|
||||
const abortError = Object.assign(new Error('The user aborted a request.'), { name: 'AbortError' })
|
||||
const fetchStub = sinon.stub().rejects(abortError)
|
||||
|
||||
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
|
||||
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
|
||||
|
||||
// Full retry budget consumed via cause-based shouldRetry
|
||||
expect(fetchStub.callCount).to.equal(3)
|
||||
|
||||
const errs = collectErrors(caught)
|
||||
|
||||
expect(errs.length).to.equal(3)
|
||||
for (const e of errs) {
|
||||
expect(BundleError.isBundleError(e)).to.equal(true)
|
||||
expect((e as BundleError).stage).to.equal('network')
|
||||
expect((e as BundleError).kind).to.equal('cy-prompt')
|
||||
|
||||
const cause = (e as Error & { cause?: unknown }).cause
|
||||
|
||||
expect(SystemError.isSystemError(cause as any), `${e?.message} cause should be SystemError`).to.equal(true)
|
||||
expect((cause as SystemError).code).to.equal('ETIMEDOUT')
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps a non-retryable HTTP 404 as BundleError(stage=network, cause: HttpError) and does NOT retry', async () => {
|
||||
const response = {
|
||||
ok: false,
|
||||
url: 'https://cdn.cypress.io/studio/abc123.tar',
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'not found',
|
||||
}
|
||||
const fetchStub = sinon.stub().resolves(response)
|
||||
|
||||
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
|
||||
const caught = await callIt(streamDownloadVerifyExtract, 'studio', path.join(tmp, 'staging'))
|
||||
|
||||
// 4xx is not retryable per isRetryableError, so only one attempt
|
||||
expect(fetchStub.callCount).to.equal(1)
|
||||
|
||||
expect(BundleError.isBundleError(caught)).to.equal(true)
|
||||
expect((caught as BundleError).stage).to.equal('network')
|
||||
expect((caught as BundleError).kind).to.equal('studio')
|
||||
|
||||
const cause = (caught as Error & { cause?: unknown }).cause
|
||||
|
||||
expect(HttpError.isHttpError(cause as any)).to.equal(true)
|
||||
expect((cause as HttpError).status).to.equal(404)
|
||||
})
|
||||
|
||||
it('retries on HTTP 500 (idempotent GET) and burns full retry budget', async () => {
|
||||
const response = {
|
||||
ok: false,
|
||||
url: 'https://cdn.cypress.io/cy-prompt/abc123.tar',
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => 'boom',
|
||||
}
|
||||
const fetchStub = sinon.stub().resolves(response)
|
||||
|
||||
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
|
||||
|
||||
await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
|
||||
|
||||
expect(fetchStub.callCount).to.equal(3)
|
||||
})
|
||||
|
||||
it('wraps a retryable HTTP 503 as BundleError(stage=network, cause: HttpError) and burns full retry budget', async () => {
|
||||
const response = {
|
||||
ok: false,
|
||||
url: 'https://cdn.cypress.io/cy-prompt/abc123.tar',
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
text: async () => 'busy',
|
||||
}
|
||||
const fetchStub = sinon.stub().resolves(response)
|
||||
|
||||
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
|
||||
|
||||
await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
|
||||
|
||||
expect(fetchStub.callCount).to.equal(3)
|
||||
})
|
||||
|
||||
it('wraps a filesystem-class syscall (ENOSPC) from the pipeline as BundleError(stage=extract) and does NOT retry', async () => {
|
||||
const enospc = Object.assign(new Error('no space left on device'), { code: 'ENOSPC', errno: -28 })
|
||||
|
||||
const makeBody = () => new Readable({
|
||||
read () {
|
||||
this.destroy(enospc)
|
||||
},
|
||||
})
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (h: string) => {
|
||||
if (h === 'x-cypress-signature') return 'sig'
|
||||
|
||||
if (h === 'x-cypress-manifest-signature') return 'manifest-sig'
|
||||
|
||||
return null
|
||||
},
|
||||
},
|
||||
body: makeBody(),
|
||||
}
|
||||
|
||||
const fetchStub = sinon.stub().callsFake(async () => ({ ...response, body: makeBody() }))
|
||||
|
||||
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
|
||||
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
|
||||
|
||||
// Filesystem syscall is non-transient — must not retry
|
||||
expect(fetchStub.callCount).to.equal(1)
|
||||
|
||||
expect(BundleError.isBundleError(caught)).to.equal(true)
|
||||
expect((caught as BundleError).stage).to.equal('extract')
|
||||
expect((caught as BundleError).kind).to.equal('cy-prompt')
|
||||
|
||||
// Cause is the raw error, NOT a SystemError (which would flag retryable)
|
||||
const cause = (caught as Error & { cause?: unknown }).cause
|
||||
|
||||
expect(SystemError.isSystemError(cause as any)).to.equal(false)
|
||||
expect((cause as any).code).to.equal('ENOSPC')
|
||||
})
|
||||
|
||||
it('still treats network-class syscalls (ECONNRESET) mid-pipeline as stage=network and retries', async () => {
|
||||
const econnreset = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET', errno: -54 })
|
||||
|
||||
const makeBody = () => new Readable({
|
||||
read () {
|
||||
this.destroy(econnreset)
|
||||
},
|
||||
})
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (h: string) => {
|
||||
if (h === 'x-cypress-signature') return 'sig'
|
||||
|
||||
if (h === 'x-cypress-manifest-signature') return 'manifest-sig'
|
||||
|
||||
return null
|
||||
},
|
||||
},
|
||||
body: makeBody(),
|
||||
}
|
||||
|
||||
const fetchStub = sinon.stub().callsFake(async () => ({ ...response, body: makeBody() }))
|
||||
|
||||
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
|
||||
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
|
||||
|
||||
// Network-class syscall → retryable → full retry budget
|
||||
expect(fetchStub.callCount).to.equal(3)
|
||||
|
||||
const errs = collectErrors(caught)
|
||||
|
||||
for (const e of errs) {
|
||||
expect(BundleError.isBundleError(e)).to.equal(true)
|
||||
expect((e as BundleError).stage).to.equal('network')
|
||||
|
||||
const cause = (e as Error & { cause?: unknown }).cause
|
||||
|
||||
expect(SystemError.isSystemError(cause as any)).to.equal(true)
|
||||
expect((cause as SystemError).code).to.equal('ECONNRESET')
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps a non-syscall pipeline error as BundleError(stage=extract, cause preserved) and does NOT retry', async () => {
|
||||
// Body that yields bytes which tar.Parse({ strict: true }) will reject.
|
||||
const makeBody = () => Readable.from([Buffer.from('this is not a tar archive at all')])
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (h: string) => {
|
||||
if (h === 'x-cypress-signature') return 'sig'
|
||||
|
||||
if (h === 'x-cypress-manifest-signature') return 'manifest-sig'
|
||||
|
||||
return null
|
||||
},
|
||||
},
|
||||
body: makeBody(),
|
||||
}
|
||||
|
||||
const fetchStub = sinon.stub().callsFake(async () => {
|
||||
// fresh body per attempt in case asyncRetry retries
|
||||
return { ...response, body: makeBody() }
|
||||
})
|
||||
|
||||
const { streamDownloadVerifyExtract } = proxyquireWithFastDelay(fetchStub)
|
||||
const caught = await callIt(streamDownloadVerifyExtract, 'cy-prompt', path.join(tmp, 'staging'))
|
||||
|
||||
// Tar parse error is not retryable (no errno/code, not Http/SystemError)
|
||||
expect(fetchStub.callCount).to.equal(1)
|
||||
|
||||
expect(BundleError.isBundleError(caught)).to.equal(true)
|
||||
expect((caught as BundleError).stage).to.equal('extract')
|
||||
expect((caught as BundleError).kind).to.equal('cy-prompt')
|
||||
|
||||
// The original (tar) error is preserved as cause
|
||||
const cause = (caught as Error & { cause?: unknown }).cause
|
||||
|
||||
expect(cause).to.be.instanceOf(Error)
|
||||
expect(SystemError.isSystemError(cause as any)).to.equal(false)
|
||||
expect(HttpError.isHttpError(cause as any)).to.equal(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import '../../../spec_helper'
|
||||
import { ensureDir, mkdtemp, pathExists, remove, utimes, writeFile } from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { sweepOrphanStaging } from '../../../../lib/cloud/bundles/sweep_orphan_staging'
|
||||
|
||||
const ageOf = (ms: number) => (Date.now() - ms) / 1000
|
||||
|
||||
describe('sweepOrphanStaging', () => {
|
||||
let baseDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
baseDir = await mkdtemp(path.join(os.tmpdir(), 'cy-sweep-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await remove(baseDir).catch(() => { /* ignore */ })
|
||||
})
|
||||
|
||||
it('removes staging dirs older than the threshold', async () => {
|
||||
const stale = path.join(baseDir, '.staging-stale')
|
||||
|
||||
await ensureDir(stale)
|
||||
await writeFile(path.join(stale, 'a'), 'x')
|
||||
|
||||
const oldSeconds = ageOf(2 * 60 * 60 * 1000) // 2h ago
|
||||
|
||||
await utimes(stale, oldSeconds, oldSeconds)
|
||||
|
||||
const removed = await sweepOrphanStaging(baseDir, 60 * 60 * 1000)
|
||||
|
||||
expect(removed).to.equal(1)
|
||||
expect(await pathExists(stale)).to.equal(false)
|
||||
})
|
||||
|
||||
it('leaves staging dirs younger than the threshold alone', async () => {
|
||||
const fresh = path.join(baseDir, '.staging-fresh')
|
||||
|
||||
await ensureDir(fresh)
|
||||
await writeFile(path.join(fresh, 'a'), 'x')
|
||||
|
||||
const removed = await sweepOrphanStaging(baseDir, 60 * 60 * 1000)
|
||||
|
||||
expect(removed).to.equal(0)
|
||||
expect(await pathExists(fresh)).to.equal(true)
|
||||
})
|
||||
|
||||
it('ignores non-staging entries even when old', async () => {
|
||||
const final = path.join(baseDir, 'somehash')
|
||||
|
||||
await ensureDir(final)
|
||||
const oldSeconds = ageOf(2 * 60 * 60 * 1000)
|
||||
|
||||
await utimes(final, oldSeconds, oldSeconds)
|
||||
|
||||
const removed = await sweepOrphanStaging(baseDir, 60 * 60 * 1000)
|
||||
|
||||
expect(removed).to.equal(0)
|
||||
expect(await pathExists(final)).to.equal(true)
|
||||
})
|
||||
|
||||
it('returns 0 and swallows errors when baseDir does not exist', async () => {
|
||||
const removed = await sweepOrphanStaging(path.join(baseDir, 'does-not-exist'), 60 * 60 * 1000)
|
||||
|
||||
expect(removed).to.equal(0)
|
||||
})
|
||||
})
|
||||
@@ -138,13 +138,12 @@ describe('CyPromptLifecycleManager', () => {
|
||||
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
|
||||
}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
|
||||
await cyPromptReadyPromise
|
||||
|
||||
expect(mockCtx.update).to.be.calledOnce
|
||||
expect(ensureCyPromptBundleStub).to.be.calledWith({
|
||||
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
|
||||
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
|
||||
projectId: 'test-project-id',
|
||||
})
|
||||
@@ -208,13 +207,12 @@ describe('CyPromptLifecycleManager', () => {
|
||||
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
|
||||
}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
|
||||
await cyPromptReadyPromise
|
||||
|
||||
expect(mockCtx.update).to.be.calledOnce
|
||||
expect(ensureCyPromptBundleStub).to.be.calledWith({
|
||||
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
|
||||
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
|
||||
projectId: 'test-project-id',
|
||||
})
|
||||
@@ -267,13 +265,12 @@ describe('CyPromptLifecycleManager', () => {
|
||||
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
|
||||
}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
|
||||
await cyPromptReadyPromise
|
||||
|
||||
expect(mockCtx.update).to.be.calledOnce
|
||||
expect(ensureCyPromptBundleStub).to.be.calledWith({
|
||||
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
|
||||
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
|
||||
projectId: 'test-project-id',
|
||||
})
|
||||
@@ -328,7 +325,7 @@ describe('CyPromptLifecycleManager', () => {
|
||||
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
|
||||
}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
|
||||
await cyPromptReadyPromise
|
||||
|
||||
@@ -358,7 +355,7 @@ describe('CyPromptLifecycleManager', () => {
|
||||
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
|
||||
}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
|
||||
const cyPromptManager1 = await cyPromptReadyPromise1
|
||||
|
||||
@@ -381,7 +378,6 @@ describe('CyPromptLifecycleManager', () => {
|
||||
|
||||
expect(ensureCyPromptBundleStub).to.be.calledOnce
|
||||
expect(ensureCyPromptBundleStub).to.be.calledWith({
|
||||
cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'),
|
||||
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
|
||||
projectId: 'test-project-id',
|
||||
})
|
||||
@@ -516,7 +512,7 @@ describe('CyPromptLifecycleManager', () => {
|
||||
|
||||
const mockManifest = {}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
|
||||
cyPromptLifecycleManager.initializeCyPromptManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
@@ -562,7 +558,7 @@ describe('CyPromptLifecycleManager', () => {
|
||||
'server/index.js': 'a1',
|
||||
}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
|
||||
cyPromptLifecycleManager.initializeCyPromptManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
@@ -722,7 +718,7 @@ describe('CyPromptLifecycleManager', () => {
|
||||
'server/index.js': 'c3c4ab913ca059819549f105e756a4c4471df19abef884ce85eafc7b7970e7b4',
|
||||
}
|
||||
|
||||
ensureCyPromptBundleStub.resolves(mockManifest)
|
||||
ensureCyPromptBundleStub.resolves({ manifest: mockManifest, cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc') })
|
||||
})
|
||||
|
||||
it('registers a listener that will be called when cy-prompt is ready', () => {
|
||||
|
||||
@@ -1,104 +1,64 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { proxyquire, sinon } from '../../../spec_helper'
|
||||
|
||||
describe('ensureCyPromptBundle', () => {
|
||||
let ensureCyPromptBundle: typeof import('../../../../lib/cloud/cy-prompt/ensure_cy_prompt_bundle').ensureCyPromptBundle
|
||||
let tmpdir: string = '/tmp'
|
||||
let rmStub: sinon.SinonStub = sinon.stub()
|
||||
let ensureStub: sinon.SinonStub = sinon.stub()
|
||||
let extractStub: sinon.SinonStub = sinon.stub()
|
||||
let getCyPromptBundleStub: sinon.SinonStub = sinon.stub()
|
||||
let readFileStub: sinon.SinonStub = sinon.stub()
|
||||
let verifySignatureStub: sinon.SinonStub = sinon.stub()
|
||||
let pathExistsStub: sinon.SinonStub = sinon.stub()
|
||||
const mockRandom: number = 0.123
|
||||
const mockRandomString: string = mockRandom.toString(36).substring(2, 15)
|
||||
const mockResponseSignature = '159'
|
||||
const mockManifest = {
|
||||
'server/index.js': 'abcdefg',
|
||||
}
|
||||
let ensureSignedBundleStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
rmStub = sinon.stub()
|
||||
ensureStub = sinon.stub()
|
||||
extractStub = sinon.stub()
|
||||
getCyPromptBundleStub = sinon.stub()
|
||||
readFileStub = sinon.stub()
|
||||
verifySignatureStub = sinon.stub()
|
||||
pathExistsStub = sinon.stub()
|
||||
sinon.stub(Math, 'random').returns(mockRandom)
|
||||
ensureSignedBundleStub = sinon.stub()
|
||||
|
||||
ensureCyPromptBundle = (proxyquire('../lib/cloud/cy-prompt/ensure_cy_prompt_bundle', {
|
||||
os: {
|
||||
tmpdir: () => tmpdir,
|
||||
platform: () => 'linux',
|
||||
},
|
||||
'fs-extra': {
|
||||
remove: rmStub.resolves(),
|
||||
ensureDir: ensureStub.resolves(),
|
||||
readFile: readFileStub.resolves(JSON.stringify(mockManifest)),
|
||||
pathExists: pathExistsStub.resolves(true),
|
||||
},
|
||||
'../api/cy-prompt/get_cy_prompt_bundle': {
|
||||
getCyPromptBundle: getCyPromptBundleStub.resolves(mockResponseSignature),
|
||||
},
|
||||
'../encryption': {
|
||||
verifySignature: verifySignatureStub.resolves(true),
|
||||
},
|
||||
'../extract_atomic': {
|
||||
extractAtomic: extractStub.resolves(),
|
||||
'../bundles/ensure_signed_bundle': {
|
||||
ensureSignedBundle: ensureSignedBundleStub,
|
||||
},
|
||||
})).ensureCyPromptBundle
|
||||
})
|
||||
|
||||
it('should ensure the cy prompt bundle', async () => {
|
||||
const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123')
|
||||
const bundlePath = path.join(cyPromptPath, 'bundle.tar')
|
||||
it('delegates to ensureSignedBundle with kind=cy-prompt and unwraps the bundleDir', async () => {
|
||||
const mockManifest = { 'server/index.js': 'abc123' }
|
||||
const mockBundleDir = '/cache/bundles/cy-prompt/abc'
|
||||
|
||||
const manifest = await ensureCyPromptBundle({
|
||||
cyPromptPath,
|
||||
cyPromptUrl: 'https://cypress.io/cy-prompt',
|
||||
projectId: '123',
|
||||
ensureSignedBundleStub.resolves({
|
||||
manifest: mockManifest,
|
||||
bundleDir: mockBundleDir,
|
||||
})
|
||||
|
||||
expect(ensureStub).to.be.calledWith(cyPromptPath)
|
||||
expect(readFileStub).to.be.calledWith(path.join(cyPromptPath, 'manifest.json'), 'utf8')
|
||||
expect(getCyPromptBundleStub).to.be.calledWith({
|
||||
cyPromptUrl: 'https://cypress.io/cy-prompt',
|
||||
projectId: '123',
|
||||
bundlePath: `${bundlePath}-${mockRandomString}`,
|
||||
const result = await ensureCyPromptBundle({
|
||||
cyPromptUrl: 'https://cdn.cypress.io/cy-prompt/abc.tar',
|
||||
projectId: 'proj-1',
|
||||
})
|
||||
|
||||
expect(extractStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`, cyPromptPath)
|
||||
expect(rmStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`)
|
||||
expect(ensureSignedBundleStub).to.be.calledOnce
|
||||
expect(ensureSignedBundleStub).to.be.calledWith({
|
||||
url: 'https://cdn.cypress.io/cy-prompt/abc.tar',
|
||||
projectId: 'proj-1',
|
||||
kind: 'cy-prompt',
|
||||
})
|
||||
|
||||
expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature)
|
||||
|
||||
expect(manifest).to.deep.eq(mockManifest)
|
||||
expect(result).to.deep.equal({
|
||||
manifest: mockManifest,
|
||||
cyPromptPath: mockBundleDir,
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the cy prompt bundle signature is invalid', async () => {
|
||||
verifySignatureStub.resolves(false)
|
||||
it('forwards an undefined projectId without injecting one', async () => {
|
||||
ensureSignedBundleStub.resolves({ manifest: {}, bundleDir: '/cache/bundles/cy-prompt/x' })
|
||||
|
||||
const ensureCyPromptBundlePromise = ensureCyPromptBundle({
|
||||
cyPromptPath: '/tmp/cypress/cy-prompt/123',
|
||||
cyPromptUrl: 'https://cypress.io/cy-prompt',
|
||||
projectId: '123',
|
||||
await ensureCyPromptBundle({ cyPromptUrl: 'https://cdn.cypress.io/cy-prompt/x.tar' })
|
||||
|
||||
expect(ensureSignedBundleStub).to.be.calledWith({
|
||||
url: 'https://cdn.cypress.io/cy-prompt/x.tar',
|
||||
projectId: undefined,
|
||||
kind: 'cy-prompt',
|
||||
})
|
||||
|
||||
await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to verify cy-prompt signature')
|
||||
})
|
||||
|
||||
it('should throw an error if the cy prompt bundle manifest is not found', async () => {
|
||||
pathExistsStub.resolves(false)
|
||||
it('propagates errors from ensureSignedBundle', async () => {
|
||||
const err = new Error('boom')
|
||||
|
||||
const ensureCyPromptBundlePromise = ensureCyPromptBundle({
|
||||
cyPromptPath: '/tmp/cypress/cy-prompt/123',
|
||||
cyPromptUrl: 'https://cypress.io/cy-prompt',
|
||||
projectId: '123',
|
||||
})
|
||||
ensureSignedBundleStub.rejects(err)
|
||||
|
||||
await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to find cy-prompt manifest')
|
||||
await expect(ensureCyPromptBundle({ cyPromptUrl: 'https://cdn.cypress.io/cy-prompt/abc.tar' }))
|
||||
.to.be.rejectedWith(err)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,6 @@ const jose = require('jose')
|
||||
const crypto = require('crypto')
|
||||
const encryption = require('../../../lib/cloud/encryption')
|
||||
const { expect } = require('chai')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const TEST_BODY = {
|
||||
test: 'string',
|
||||
@@ -105,40 +103,4 @@ describe('encryption', () => {
|
||||
|
||||
expect(roundtripResponse).to.eql(LARGE_RESPONSE)
|
||||
})
|
||||
|
||||
describe('verifySignatureFromFile', () => {
|
||||
it('verifies a valid signature from a file', async () => {
|
||||
const filePath = path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'encryption', 'index.js')
|
||||
const fixtureContents = await fs.promises.readFile(filePath)
|
||||
const sign = crypto.createSign('sha256', {
|
||||
defaultEncoding: 'base64',
|
||||
}).update(fixtureContents).end()
|
||||
const signature = sign.sign(privateKey, 'base64')
|
||||
|
||||
expect(
|
||||
await encryption.verifySignatureFromFile(
|
||||
filePath,
|
||||
signature,
|
||||
publicKey,
|
||||
),
|
||||
).to.eql(true)
|
||||
})
|
||||
|
||||
it('does not verify an invalid signature from a file', async () => {
|
||||
const filePath = path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'encryption', 'index.js')
|
||||
const fixtureContents = await fs.promises.readFile(filePath)
|
||||
const sign = crypto.createSign('sha256', {
|
||||
defaultEncoding: 'base64',
|
||||
}).update(fixtureContents).end()
|
||||
const signature = sign.sign(privateKey, 'base64')
|
||||
|
||||
expect(
|
||||
await encryption.verifySignatureFromFile(
|
||||
filePath,
|
||||
`a ${signature}`,
|
||||
publicKey,
|
||||
),
|
||||
).to.eql(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,578 +1,87 @@
|
||||
import path from 'path'
|
||||
import { proxyquire, sinon } from '../../spec_helper'
|
||||
import { EventEmitter } from 'events'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
describe('extractAtomic', () => {
|
||||
let extractAtomic: typeof import('../../../lib/cloud/extract_atomic').extractAtomic
|
||||
let createReadStreamStub: sinon.SinonStub
|
||||
let ParseStub: sinon.SinonStub
|
||||
let ensureDirStub: sinon.SinonStub
|
||||
let writeFileAtomicStub: sinon.SinonStub
|
||||
let mockStream: Readable
|
||||
let mockParser: EventEmitter & { pipe: sinon.SinonStub }
|
||||
describe('renameAtomicWithRetry', () => {
|
||||
let renameAtomicWithRetry: typeof import('../../../lib/cloud/extract_atomic').renameAtomicWithRetry
|
||||
let renameStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
createReadStreamStub = sinon.stub()
|
||||
ParseStub = sinon.stub()
|
||||
ensureDirStub = sinon.stub().resolves()
|
||||
writeFileAtomicStub = sinon.stub().resolves()
|
||||
renameStub = sinon.stub()
|
||||
|
||||
// Create a mock parser first (needed for stream pipe)
|
||||
mockParser = Object.assign(new EventEmitter(), {
|
||||
pipe: sinon.stub().returns(mockParser),
|
||||
})
|
||||
|
||||
// Create a mock stream
|
||||
mockStream = Object.assign(new Readable({
|
||||
read () {
|
||||
// Empty implementation
|
||||
},
|
||||
}), {
|
||||
pipe: sinon.stub().returns(mockParser),
|
||||
}) as Readable & { pipe: sinon.SinonStub }
|
||||
|
||||
ParseStub.returns(mockParser)
|
||||
createReadStreamStub.returns(mockStream)
|
||||
|
||||
extractAtomic = (proxyquire('../lib/cloud/extract_atomic', {
|
||||
fs: {
|
||||
createReadStream: createReadStreamStub,
|
||||
},
|
||||
tar: {
|
||||
Parse: ParseStub,
|
||||
},
|
||||
renameAtomicWithRetry = (proxyquire('../lib/cloud/extract_atomic', {
|
||||
'fs-extra': {
|
||||
ensureDir: ensureDirStub,
|
||||
ensureDir: sinon.stub().resolves(),
|
||||
rename: renameStub,
|
||||
},
|
||||
'write-file-atomic': writeFileAtomicStub,
|
||||
})).extractAtomic
|
||||
})).renameAtomicWithRetry
|
||||
})
|
||||
|
||||
it('should extract a single file from tar archive', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const fileContent = Buffer.from('file content')
|
||||
const fileMode = 0o755
|
||||
it('should rename once when the operation succeeds', async () => {
|
||||
renameStub.resolves()
|
||||
|
||||
// Create a mock entry
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null) // End stream
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: fileMode,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
await renameAtomicWithRetry('/src/file', '/dst/file')
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
// Simulate entry event
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
})
|
||||
|
||||
// Simulate parser end
|
||||
setImmediate(() => {
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(createReadStreamStub).to.be.calledWith(archivePath)
|
||||
expect(ParseStub).to.be.calledOnce
|
||||
expect((mockStream as any).pipe).to.be.calledWith(mockParser)
|
||||
expect(ensureDirStub).to.be.calledWith(destinationPath)
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'file.txt'),
|
||||
fileContent,
|
||||
{ mode: fileMode },
|
||||
)
|
||||
expect(renameStub).to.be.calledOnce
|
||||
expect(renameStub).to.be.calledWith('/src/file', '/dst/file')
|
||||
})
|
||||
|
||||
it('should extract multiple files from tar archive', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const file1Content = Buffer.from('file 1 content')
|
||||
const file2Content = Buffer.from('file 2 content')
|
||||
it('should retry on EPERM and succeed on a subsequent attempt', async () => {
|
||||
const epermError = Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' })
|
||||
|
||||
const mockEntry1 = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(file1Content)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file1.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
renameStub.onFirstCall().rejects(epermError)
|
||||
renameStub.onSecondCall().resolves()
|
||||
|
||||
const mockEntry2 = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(file2Content)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file2.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
await renameAtomicWithRetry('/src/file', '/dst/file')
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry1)
|
||||
mockParser.emit('entry', mockEntry2)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(writeFileAtomicStub).to.be.calledTwice
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'file1.txt'),
|
||||
file1Content,
|
||||
{ mode: 0o644 },
|
||||
)
|
||||
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'file2.txt'),
|
||||
file2Content,
|
||||
{ mode: 0o644 },
|
||||
)
|
||||
expect(renameStub).to.be.calledTwice
|
||||
})
|
||||
|
||||
it('should skip non-file entries', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
|
||||
const mockDirectoryEntry = {
|
||||
type: 'Directory',
|
||||
path: 'directory',
|
||||
resume: sinon.stub(),
|
||||
}
|
||||
|
||||
const mockSymlinkEntry = {
|
||||
type: 'SymbolicLink',
|
||||
path: 'symlink',
|
||||
resume: sinon.stub(),
|
||||
}
|
||||
|
||||
const mockFileEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(Buffer.from('content'))
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockDirectoryEntry)
|
||||
mockParser.emit('entry', mockSymlinkEntry)
|
||||
mockParser.emit('entry', mockFileEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(mockDirectoryEntry.resume).to.be.called
|
||||
expect(mockSymlinkEntry.resume).to.be.called
|
||||
expect(mockFileEntry.resume).not.to.be.called
|
||||
expect(writeFileAtomicStub).to.be.calledOnce
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'file.txt'),
|
||||
Buffer.from('content'),
|
||||
{ mode: 0o644 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should create nested directories for files in subdirectories', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const fileContent = Buffer.from('content')
|
||||
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'nested/path/to/file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(ensureDirStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'nested/path/to'),
|
||||
)
|
||||
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'nested/path/to/file.txt'),
|
||||
fileContent,
|
||||
{ mode: 0o644 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should use default mode 0o644 when entry mode is not provided', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const fileContent = Buffer.from('content')
|
||||
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: undefined,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'file.txt'),
|
||||
fileContent,
|
||||
{ mode: 0o644 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle files with multiple chunks', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const chunk1 = Buffer.from('chunk 1')
|
||||
const chunk2 = Buffer.from('chunk 2')
|
||||
const chunk3 = Buffer.from('chunk 3')
|
||||
const expectedContent = Buffer.concat([chunk1, chunk2, chunk3])
|
||||
|
||||
let chunkIndex = 0
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
if (chunkIndex === 0) {
|
||||
this.push(chunk1)
|
||||
chunkIndex++
|
||||
} else if (chunkIndex === 1) {
|
||||
this.push(chunk2)
|
||||
chunkIndex++
|
||||
} else if (chunkIndex === 2) {
|
||||
this.push(chunk3)
|
||||
chunkIndex++
|
||||
} else {
|
||||
this.push(null)
|
||||
}
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'file.txt'),
|
||||
expectedContent,
|
||||
{ mode: 0o644 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle stream errors', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const streamError = new Error('Stream error')
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockStream.emit('error', streamError)
|
||||
})
|
||||
|
||||
await expect(extractPromise).to.be.rejectedWith('Stream error')
|
||||
})
|
||||
|
||||
it('should handle parser errors', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const parserError = new Error('Parser error')
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('error', parserError)
|
||||
})
|
||||
|
||||
await expect(extractPromise).to.be.rejectedWith('Parser error')
|
||||
})
|
||||
|
||||
it('should handle write errors', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const writeError = new Error('Write error')
|
||||
const fileContent = Buffer.from('content')
|
||||
|
||||
writeFileAtomicStub.rejects(writeError)
|
||||
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await expect(extractPromise).to.be.rejectedWith('Write error')
|
||||
})
|
||||
|
||||
it('should retry on EPERM and succeed when write succeeds on retry', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const fileContent = Buffer.from('content')
|
||||
const epermError = Object.assign(new Error('EPERM: operation not permitted, rename \'file.txt.123\' -> \'file.txt\''), { code: 'EPERM' })
|
||||
|
||||
writeFileAtomicStub.onFirstCall().rejects(epermError)
|
||||
writeFileAtomicStub.onSecondCall().resolves()
|
||||
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(writeFileAtomicStub).to.be.calledTwice
|
||||
expect(writeFileAtomicStub).to.be.calledWith(
|
||||
path.join(destinationPath, 'file.txt'),
|
||||
fileContent,
|
||||
{ mode: 0o644 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should retry on EACCES and succeed when write succeeds on retry', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const fileContent = Buffer.from('content')
|
||||
it('should retry on EACCES and succeed on a subsequent attempt', async () => {
|
||||
const eaccesError = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
|
||||
|
||||
writeFileAtomicStub.onFirstCall().rejects(eaccesError)
|
||||
writeFileAtomicStub.onSecondCall().resolves()
|
||||
renameStub.onFirstCall().rejects(eaccesError)
|
||||
renameStub.onSecondCall().resolves()
|
||||
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
await renameAtomicWithRetry('/src/file', '/dst/file')
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(writeFileAtomicStub).to.be.calledTwice
|
||||
expect(renameStub).to.be.calledTwice
|
||||
})
|
||||
|
||||
it('should throw when EPERM persists after all retries', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const fileContent = Buffer.from('content')
|
||||
const epermError = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
|
||||
it('should retry on EBUSY (Windows: file in use by AV / another process) and succeed on a subsequent attempt', async () => {
|
||||
const ebusyError = Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' })
|
||||
|
||||
writeFileAtomicStub.rejects(epermError)
|
||||
renameStub.onFirstCall().rejects(ebusyError)
|
||||
renameStub.onSecondCall().resolves()
|
||||
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
await renameAtomicWithRetry('/src/file', '/dst/file')
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await expect(extractPromise).to.be.rejectedWith(epermError)
|
||||
expect(writeFileAtomicStub.callCount).to.equal(4) // initial + 3 retries
|
||||
expect(renameStub).to.be.calledTwice
|
||||
})
|
||||
|
||||
it('should not retry on non-retryable errors', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const fileContent = Buffer.from('content')
|
||||
const enospcError = Object.assign(new Error('ENOSPC: no space left'), { code: 'ENOSPC' })
|
||||
it('should throw the last error when EPERM persists past the retry budget', async () => {
|
||||
const epermError = Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' })
|
||||
|
||||
writeFileAtomicStub.rejects(enospcError)
|
||||
renameStub.rejects(epermError)
|
||||
|
||||
const mockEntry = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(fileContent)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
await expect(extractPromise).to.be.rejectedWith(enospcError)
|
||||
expect(writeFileAtomicStub).to.be.calledOnce
|
||||
await expect(renameAtomicWithRetry('/src/file', '/dst/file')).to.be.rejectedWith(epermError)
|
||||
// MAX_RETRIES = 3, so 4 total attempts (initial + 3 retries)
|
||||
expect(renameStub.callCount).to.equal(4)
|
||||
})
|
||||
|
||||
it('should wait for all file writes to complete before resolving', async () => {
|
||||
const archivePath = '/path/to/archive.tar'
|
||||
const destinationPath = '/path/to/destination'
|
||||
const file1Content = Buffer.from('file 1')
|
||||
const file2Content = Buffer.from('file 2')
|
||||
it('should not retry on non-retryable errors such as ENOENT', async () => {
|
||||
const enoentError = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
|
||||
|
||||
let resolveWrite1: () => void
|
||||
let resolveWrite2: () => void
|
||||
const write1Promise = new Promise<void>((resolve) => {
|
||||
resolveWrite1 = resolve
|
||||
})
|
||||
const write2Promise = new Promise<void>((resolve) => {
|
||||
resolveWrite2 = resolve
|
||||
})
|
||||
renameStub.rejects(enoentError)
|
||||
|
||||
writeFileAtomicStub.onFirstCall().returns(write1Promise)
|
||||
writeFileAtomicStub.onSecondCall().returns(write2Promise)
|
||||
await expect(renameAtomicWithRetry('/src/file', '/dst/file')).to.be.rejectedWith(enoentError)
|
||||
expect(renameStub).to.be.calledOnce
|
||||
})
|
||||
|
||||
const mockEntry1 = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(file1Content)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file1.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
it('should not retry on errors without an EPERM/EACCES code', async () => {
|
||||
const opaqueError = new Error('something else went wrong')
|
||||
|
||||
const mockEntry2 = Object.assign(new Readable({
|
||||
read () {
|
||||
this.push(file2Content)
|
||||
this.push(null)
|
||||
},
|
||||
}), {
|
||||
type: 'File',
|
||||
path: 'file2.txt',
|
||||
mode: 0o644,
|
||||
resume: sinon.stub(),
|
||||
})
|
||||
renameStub.rejects(opaqueError)
|
||||
|
||||
const extractPromise = extractAtomic(archivePath, destinationPath)
|
||||
|
||||
setImmediate(() => {
|
||||
mockParser.emit('entry', mockEntry1)
|
||||
mockParser.emit('entry', mockEntry2)
|
||||
mockParser.emit('end')
|
||||
})
|
||||
|
||||
// Wait a bit to ensure parser has finished but writes haven't
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// Extract should not have resolved yet
|
||||
let resolved = false
|
||||
|
||||
extractPromise.then(() => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
expect(resolved).to.be.false
|
||||
|
||||
// Resolve writes
|
||||
resolveWrite1!()
|
||||
resolveWrite2!()
|
||||
|
||||
await extractPromise
|
||||
|
||||
expect(resolved).to.be.true
|
||||
await expect(renameAtomicWithRetry('/src/file', '/dst/file')).to.be.rejectedWith(opaqueError)
|
||||
expect(renameStub).to.be.calledOnce
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,13 +189,6 @@ describe('StudioLifecycleManager', () => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
debugData,
|
||||
})
|
||||
|
||||
const studioReadyPromise = new Promise((resolve) => {
|
||||
studioLifecycleManager?.registerStudioReadyListener((studioManager) => {
|
||||
resolve(studioManager)
|
||||
@@ -206,13 +199,19 @@ describe('StudioLifecycleManager', () => {
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
debugData,
|
||||
})
|
||||
|
||||
await studioReadyPromise
|
||||
|
||||
expect(mockCtx.update).to.be.calledOnce
|
||||
expect(ensureStudioBundleStub).to.be.calledWith({
|
||||
studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'),
|
||||
studioUrl: 'https://cloud.cypress.io/studio/bundle/abc.tgz',
|
||||
projectId: 'abc123',
|
||||
})
|
||||
@@ -292,13 +291,6 @@ describe('StudioLifecycleManager', () => {
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
debugData: {},
|
||||
})
|
||||
|
||||
const studioReadyPromise = new Promise((resolve) => {
|
||||
studioLifecycleManager?.registerStudioReadyListener((studioManager) => {
|
||||
resolve(studioManager)
|
||||
@@ -309,7 +301,14 @@ describe('StudioLifecycleManager', () => {
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
debugData: {},
|
||||
})
|
||||
|
||||
await studioReadyPromise
|
||||
|
||||
@@ -407,9 +406,9 @@ describe('StudioLifecycleManager', () => {
|
||||
|
||||
const mockManifest = {}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
@@ -453,9 +452,9 @@ describe('StudioLifecycleManager', () => {
|
||||
'server/index.js': 'a1',
|
||||
}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
@@ -606,7 +605,7 @@ describe('StudioLifecycleManager', () => {
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
})
|
||||
|
||||
it('registers a listener that will be called when studio is ready', () => {
|
||||
@@ -697,7 +696,7 @@ describe('StudioLifecycleManager', () => {
|
||||
}),
|
||||
])
|
||||
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
@@ -721,7 +720,7 @@ describe('StudioLifecycleManager', () => {
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
})
|
||||
|
||||
it('updates status and emits events when status changes', async () => {
|
||||
@@ -768,7 +767,7 @@ describe('StudioLifecycleManager', () => {
|
||||
it('handles status updates properly during initialization', async () => {
|
||||
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
|
||||
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
cfg: mockCfg,
|
||||
debugData: {},
|
||||
@@ -794,7 +793,7 @@ describe('StudioLifecycleManager', () => {
|
||||
|
||||
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
|
||||
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
cfg: mockCfg,
|
||||
debugData: {},
|
||||
@@ -952,10 +951,10 @@ describe('StudioLifecycleManager', () => {
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
|
||||
// First initialize with some state
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
@@ -975,7 +974,7 @@ describe('StudioLifecycleManager', () => {
|
||||
|
||||
const initialCallCount = postStudioSessionStub.callCount
|
||||
|
||||
studioLifecycleManager.retry()
|
||||
await studioLifecycleManager.retry()
|
||||
|
||||
// Verify state was cleared
|
||||
expect(studioLifecycleManager.getCurrentStatus()).to.equal('INITIALIZING')
|
||||
@@ -997,22 +996,22 @@ describe('StudioLifecycleManager', () => {
|
||||
expect(ensureStudioBundleStub.callCount).to.equal(initialCallCount + 1)
|
||||
})
|
||||
|
||||
it('sets status to IN_ERROR when no initialization parameters are available', () => {
|
||||
it('sets status to IN_ERROR when no initialization parameters are available', async () => {
|
||||
// Set up ctx so retry doesn't return early
|
||||
// @ts-expect-error - accessing private property
|
||||
studioLifecycleManager.ctx = mockCtx
|
||||
|
||||
// Don't initialize first, so no params are stored
|
||||
studioLifecycleManager.retry()
|
||||
await studioLifecycleManager.retry()
|
||||
|
||||
expect(studioLifecycleManager.getCurrentStatus()).to.equal('IN_ERROR')
|
||||
})
|
||||
|
||||
it('does nothing when no ctx is available', () => {
|
||||
it('does nothing when no ctx is available', async () => {
|
||||
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
|
||||
|
||||
// Call retry without ctx
|
||||
studioLifecycleManager.retry()
|
||||
await studioLifecycleManager.retry()
|
||||
|
||||
// Should not have updated status
|
||||
expect(statusChangesSpy).not.to.be.called
|
||||
@@ -1023,11 +1022,14 @@ describe('StudioLifecycleManager', () => {
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
}
|
||||
|
||||
ensureStudioBundleStub.resolves(mockManifest)
|
||||
ensureStudioBundleStub.resolves({ manifest: mockManifest, studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc') })
|
||||
|
||||
// Add some cached promises to the static map
|
||||
const dummyPromise = Promise.resolve({
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
manifest: {
|
||||
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
|
||||
},
|
||||
studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'),
|
||||
})
|
||||
|
||||
// @ts-expect-error - accessing private static property
|
||||
@@ -1036,7 +1038,7 @@ describe('StudioLifecycleManager', () => {
|
||||
StudioLifecycleManager.hashLoadingMap.set('abc', dummyPromise) // This should be the current hash (from studioUrl)
|
||||
|
||||
// Initialize with ctx so retry will work
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
@@ -1053,7 +1055,7 @@ describe('StudioLifecycleManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
studioLifecycleManager.retry()
|
||||
await studioLifecycleManager.retry()
|
||||
|
||||
// Verify only the current studio hash was cleared (abc from the studioUrl)
|
||||
// @ts-expect-error - accessing private static property
|
||||
@@ -1086,7 +1088,7 @@ describe('StudioLifecycleManager', () => {
|
||||
expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(2)
|
||||
|
||||
// Initialize with ctx so retry will work
|
||||
studioLifecycleManager.initializeStudioManager({
|
||||
await studioLifecycleManager.initializeStudioManager({
|
||||
cloudDataSource: mockCloudDataSource,
|
||||
ctx: mockCtx,
|
||||
cfg: mockCfg,
|
||||
@@ -1100,7 +1102,7 @@ describe('StudioLifecycleManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
studioLifecycleManager.retry()
|
||||
await studioLifecycleManager.retry()
|
||||
|
||||
// Wait for retry to complete
|
||||
await new Promise((resolve) => {
|
||||
|
||||
@@ -1,106 +1,64 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { proxyquire, sinon } from '../../../spec_helper'
|
||||
|
||||
describe('ensureStudioBundle', () => {
|
||||
let ensureStudioBundle: typeof import('../../../../lib/cloud/studio/ensure_studio_bundle').ensureStudioBundle
|
||||
let tmpdir: string = '/tmp'
|
||||
let rmStub: sinon.SinonStub = sinon.stub()
|
||||
let ensureStub: sinon.SinonStub = sinon.stub()
|
||||
let copyStub: sinon.SinonStub = sinon.stub()
|
||||
let extractStub: sinon.SinonStub = sinon.stub()
|
||||
let getStudioBundleStub: sinon.SinonStub = sinon.stub()
|
||||
let readFileStub: sinon.SinonStub = sinon.stub()
|
||||
let verifySignatureStub: sinon.SinonStub = sinon.stub()
|
||||
let pathExistsStub: sinon.SinonStub = sinon.stub()
|
||||
const mockRandom: number = 0.123
|
||||
const mockRandomString: string = mockRandom.toString(36).substring(2, 15)
|
||||
const mockResponseSignature = '159'
|
||||
const mockManifest = {
|
||||
'server/index.js': 'abcdefg',
|
||||
}
|
||||
let ensureSignedBundleStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
rmStub = sinon.stub()
|
||||
ensureStub = sinon.stub()
|
||||
copyStub = sinon.stub()
|
||||
readFileStub = sinon.stub()
|
||||
extractStub = sinon.stub()
|
||||
getStudioBundleStub = sinon.stub()
|
||||
verifySignatureStub = sinon.stub()
|
||||
pathExistsStub = sinon.stub()
|
||||
sinon.stub(Math, 'random').returns(mockRandom)
|
||||
ensureSignedBundleStub = sinon.stub()
|
||||
|
||||
ensureStudioBundle = (proxyquire('../lib/cloud/studio/ensure_studio_bundle', {
|
||||
os: {
|
||||
tmpdir: () => tmpdir,
|
||||
platform: () => 'linux',
|
||||
},
|
||||
'fs-extra': {
|
||||
remove: rmStub.resolves(),
|
||||
ensureDir: ensureStub.resolves(),
|
||||
copy: copyStub.resolves(),
|
||||
readFile: readFileStub.resolves(JSON.stringify(mockManifest)),
|
||||
pathExists: pathExistsStub.resolves(true),
|
||||
},
|
||||
'../api/studio/get_studio_bundle': {
|
||||
getStudioBundle: getStudioBundleStub.resolves(mockResponseSignature),
|
||||
},
|
||||
'../encryption': {
|
||||
verifySignature: verifySignatureStub.resolves(true),
|
||||
},
|
||||
'../extract_atomic': {
|
||||
extractAtomic: extractStub.resolves(),
|
||||
'../bundles/ensure_signed_bundle': {
|
||||
ensureSignedBundle: ensureSignedBundleStub,
|
||||
},
|
||||
})).ensureStudioBundle
|
||||
})
|
||||
|
||||
it('should ensure the studio bundle', async () => {
|
||||
const studioPath = path.join(os.tmpdir(), 'cypress', 'studio', '123')
|
||||
const bundlePath = path.join(studioPath, 'bundle.tar')
|
||||
it('delegates to ensureSignedBundle with kind=studio and unwraps the bundleDir', async () => {
|
||||
const mockManifest = { 'server/index.js': 'abc123' }
|
||||
const mockBundleDir = '/cache/bundles/studio/abc'
|
||||
|
||||
const manifest = await ensureStudioBundle({
|
||||
studioPath,
|
||||
studioUrl: 'https://cypress.io/studio',
|
||||
projectId: '123',
|
||||
ensureSignedBundleStub.resolves({
|
||||
manifest: mockManifest,
|
||||
bundleDir: mockBundleDir,
|
||||
})
|
||||
|
||||
expect(ensureStub).to.be.calledWith(studioPath)
|
||||
expect(readFileStub).to.be.calledWith(path.join(studioPath, 'manifest.json'), 'utf8')
|
||||
expect(getStudioBundleStub).to.be.calledWith({
|
||||
studioUrl: 'https://cypress.io/studio',
|
||||
bundlePath: `${bundlePath}-${mockRandomString}`,
|
||||
const result = await ensureStudioBundle({
|
||||
studioUrl: 'https://cdn.cypress.io/studio/abc.tar',
|
||||
projectId: 'proj-1',
|
||||
})
|
||||
|
||||
expect(extractStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`, studioPath)
|
||||
expect(rmStub).to.be.calledWith(`${bundlePath}-${mockRandomString}`)
|
||||
expect(ensureSignedBundleStub).to.be.calledOnce
|
||||
expect(ensureSignedBundleStub).to.be.calledWith({
|
||||
url: 'https://cdn.cypress.io/studio/abc.tar',
|
||||
projectId: 'proj-1',
|
||||
kind: 'studio',
|
||||
})
|
||||
|
||||
expect(verifySignatureStub).to.be.calledWith(JSON.stringify(mockManifest), mockResponseSignature)
|
||||
|
||||
expect(manifest).to.deep.eq(mockManifest)
|
||||
expect(result).to.deep.equal({
|
||||
manifest: mockManifest,
|
||||
studioPath: mockBundleDir,
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the studio bundle signature is invalid', async () => {
|
||||
verifySignatureStub.resolves(false)
|
||||
it('forwards an undefined projectId without injecting one', async () => {
|
||||
ensureSignedBundleStub.resolves({ manifest: {}, bundleDir: '/cache/bundles/studio/x' })
|
||||
|
||||
const ensureStudioBundlePromise = ensureStudioBundle({
|
||||
studioPath: '/tmp/cypress/studio/123',
|
||||
studioUrl: 'https://cypress.io/studio',
|
||||
projectId: '123',
|
||||
await ensureStudioBundle({ studioUrl: 'https://cdn.cypress.io/studio/x.tar' })
|
||||
|
||||
expect(ensureSignedBundleStub).to.be.calledWith({
|
||||
url: 'https://cdn.cypress.io/studio/x.tar',
|
||||
projectId: undefined,
|
||||
kind: 'studio',
|
||||
})
|
||||
|
||||
await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to verify studio signature')
|
||||
})
|
||||
|
||||
it('should throw an error if the studio bundle manifest is not found', async () => {
|
||||
pathExistsStub.resolves(false)
|
||||
it('propagates errors from ensureSignedBundle', async () => {
|
||||
const err = new Error('boom')
|
||||
|
||||
const ensureStudioBundlePromise = ensureStudioBundle({
|
||||
studioPath: '/tmp/cypress/studio/123',
|
||||
studioUrl: 'https://cypress.io/studio',
|
||||
projectId: '123',
|
||||
})
|
||||
ensureSignedBundleStub.rejects(err)
|
||||
|
||||
await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to find studio manifest')
|
||||
await expect(ensureStudioBundle({ studioUrl: 'https://cdn.cypress.io/studio/abc.tar' }))
|
||||
.to.be.rejectedWith(err)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
import { expect } from 'chai'
|
||||
import { proxyquire, sinon } from '../../../../spec_helper'
|
||||
|
||||
proxyquire.noPreserveCache()
|
||||
|
||||
describe('TelemetryReporter', () => {
|
||||
// noPreserveCache() mutates the global proxyquire instance; scope it so it
|
||||
// doesn't leak into specs loaded after this one.
|
||||
before(() => proxyquire.noPreserveCache())
|
||||
after(() => proxyquire.preserveCache())
|
||||
|
||||
let TelemetryReporter: typeof import('../../../../../lib/cloud/studio/telemetry/TelemetryReporter').TelemetryReporter
|
||||
let initializeTelemetryReporter: typeof import('../../../../../lib/cloud/studio/telemetry/TelemetryReporter').initializeTelemetryReporter
|
||||
let reportTelemetry: typeof import('../../../../../lib/cloud/studio/telemetry/TelemetryReporter').reportTelemetry
|
||||
|
||||
@@ -3,11 +3,11 @@ require('../../spec_helper')
|
||||
const _ = require('lodash')
|
||||
const os = require('os')
|
||||
const electron = require('electron')
|
||||
const DataContext = require('@packages/data-context')
|
||||
const savedState = require(`../../../lib/saved_state`)
|
||||
const menu = require(`../../../lib/gui/menu`)
|
||||
const Windows = require(`../../../lib/gui/windows`)
|
||||
const interactiveMode = require(`../../../lib/modes/interactive`)
|
||||
const { GracefulExit } = require(`../../../lib/util/graceful-exit`)
|
||||
|
||||
describe('gui/interactive', () => {
|
||||
context('.isMac', () => {
|
||||
@@ -186,8 +186,8 @@ describe('gui/interactive', () => {
|
||||
})
|
||||
|
||||
describe('data context management', () => {
|
||||
let willQuitHandler
|
||||
let clearCtxImmediateCallback
|
||||
let beforeQuitHandler
|
||||
let quitTeardownImmediateCallback
|
||||
|
||||
let mockEvent = {
|
||||
preventDefault: sinon.stub(),
|
||||
@@ -199,49 +199,59 @@ describe('gui/interactive', () => {
|
||||
return interactiveMode.run(opts).then(() => {
|
||||
expect(interactiveMode.ready).to.be.calledWith(opts)
|
||||
}).then(async () => {
|
||||
expect(willQuitHandler).to.be.defined
|
||||
expect(beforeQuitHandler).to.be.defined
|
||||
|
||||
willQuitHandler(mockEvent)
|
||||
beforeQuitHandler(mockEvent)
|
||||
expect(mockEvent.preventDefault).to.have.been.called
|
||||
expect(clearCtxImmediateCallback).to.be.defined
|
||||
expect(quitTeardownImmediateCallback).to.be.defined
|
||||
|
||||
await clearCtxImmediateCallback()
|
||||
await quitTeardownImmediateCallback()
|
||||
|
||||
expect(DataContext.clearCtx).to.have.been.called
|
||||
expect(electron.app.quit).to.have.been.called
|
||||
expect(GracefulExit.exitGracefully).to.have.been.calledWith(0)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
willQuitHandler = undefined
|
||||
clearCtxImmediateCallback = undefined
|
||||
beforeQuitHandler = undefined
|
||||
quitTeardownImmediateCallback = undefined
|
||||
|
||||
sinon.stub(interactiveMode, 'ready')
|
||||
sinon.stub(electron.app, 'once').callsFake((eventName, handler) => {
|
||||
if (eventName === 'will-quit') {
|
||||
willQuitHandler = handler
|
||||
sinon.stub(electron.app, 'on').callsFake((eventName, handler) => {
|
||||
if (eventName === 'before-quit') {
|
||||
beforeQuitHandler = handler
|
||||
}
|
||||
})
|
||||
|
||||
sinon.stub(GracefulExit, 'exitGracefully').resolves()
|
||||
|
||||
sinon.stub(global, 'setImmediate').callsFake((callback) => {
|
||||
// we intercept the setImmediate call so we can synchronously
|
||||
// execute the callback in the test and await its result
|
||||
clearCtxImmediateCallback = callback
|
||||
quitTeardownImmediateCallback = callback
|
||||
})
|
||||
|
||||
electron.app.quit = sinon.stub()
|
||||
})
|
||||
|
||||
it('uses will-quit listener to destroy DataContext before exiting', () => {
|
||||
sinon.stub(DataContext, 'clearCtx').resolves()
|
||||
|
||||
it('uses before-quit listener and invokes graceful exit', () => {
|
||||
return performAssertions()
|
||||
})
|
||||
|
||||
it('still quits if destroying DataContext throws error', () => {
|
||||
sinon.stub(DataContext, 'clearCtx').rejects()
|
||||
it('exits with code 1 when graceful exit fails during quit teardown', () => {
|
||||
GracefulExit.exitGracefully.restore()
|
||||
sinon.stub(GracefulExit, 'exitGracefully').rejects(new Error('teardown failed'))
|
||||
sinon.stub(process, 'exit')
|
||||
|
||||
return performAssertions()
|
||||
const opts = {}
|
||||
|
||||
return interactiveMode.run(opts).then(() => {
|
||||
expect(interactiveMode.ready).to.be.calledWith(opts)
|
||||
}).then(async () => {
|
||||
beforeQuitHandler(mockEvent)
|
||||
await quitTeardownImmediateCallback()
|
||||
|
||||
expect(process.exit).to.have.been.calledWith(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,64 @@ const childProcess = require('child_process')
|
||||
const path = require('path')
|
||||
|
||||
const PROJECT_ROOT = path.join(path.dirname(require.resolve('@tooling/system-tests/package.json')), 'projects/kill-child-process')
|
||||
const REQUIRE_ASYNC_CHILD_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child')
|
||||
const CONFIG_FILE = path.join(PROJECT_ROOT, 'cypress.config.js')
|
||||
|
||||
describe('require_async_child', () => {
|
||||
it('exits with code 0 when the parent closes the IPC channel (disconnect handler)', function (done) {
|
||||
this.timeout(15_000)
|
||||
|
||||
const child = childProcess.fork(REQUIRE_ASYNC_CHILD_PATH, ['--projectRoot', PROJECT_ROOT, '--file', CONFIG_FILE], {
|
||||
env: {
|
||||
...process.env,
|
||||
// Match real config-child loading (see run_child_fixture / ProjectConfigIpc)
|
||||
NODE_OPTIONS: '--import tsx',
|
||||
},
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finish = (err) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
clearTimeout(watchdog)
|
||||
done(err)
|
||||
}
|
||||
|
||||
const watchdog = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
finish(new Error('timed out waiting for require_async_child to exit after IPC disconnect'))
|
||||
}, 12_000)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
return finish(new Error(`Expected exit without signal after graceful IPC disconnect, got signal ${signal}`))
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
return finish(new Error(`Expected exit code 0 after disconnect teardown (process.exit()), got ${code}`))
|
||||
}
|
||||
|
||||
finish()
|
||||
})
|
||||
|
||||
child.on('error', finish)
|
||||
|
||||
child.on('message', (msg) => {
|
||||
if (msg?.event === 'ready') {
|
||||
// Closing the IPC channel triggers `process.on('disconnect')` in require_async_child,
|
||||
// which must call process.exit() so the child cannot run orphaned.
|
||||
child.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('disconnects if the parent ipc is closed', (done) => {
|
||||
const child = childProcess.fork(path.join(__dirname, 'run_child_fixture'))
|
||||
|
||||
@@ -20,7 +76,7 @@ describe('require_async_child', () => {
|
||||
event: 'setupTestingType',
|
||||
args: ['e2e', {
|
||||
...JSON.parse(msg.childMessage.args[0].initialConfig),
|
||||
configFile: path.join(PROJECT_ROOT, 'cypress.config.js'),
|
||||
configFile: CONFIG_FILE,
|
||||
projectRoot: PROJECT_ROOT,
|
||||
testingType: 'e2e',
|
||||
env: {},
|
||||
|
||||
@@ -5,6 +5,23 @@ const REQUIRE_ASYNC_CHILD_PATH = require.resolve('@packages/server/lib/plugins/c
|
||||
|
||||
let proc
|
||||
|
||||
function killGrandchild () {
|
||||
if (proc && !proc.killed) {
|
||||
proc.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
|
||||
;['SIGINT', 'SIGTERM'].forEach((signal) => {
|
||||
process.on(signal, () => {
|
||||
killGrandchild()
|
||||
process.exit(signal === 'SIGINT' ? 130 : 143)
|
||||
})
|
||||
})
|
||||
|
||||
process.on('exit', () => {
|
||||
killGrandchild()
|
||||
})
|
||||
|
||||
process.on('message', (msg) => {
|
||||
if (msg.msg === 'spawn') {
|
||||
proc = childProcess.fork(REQUIRE_ASYNC_CHILD_PATH, ['--projectRoot', msg.data.projectRoot, '--file', path.join(msg.data.projectRoot, 'cypress.config.js')], {
|
||||
@@ -26,7 +43,8 @@ process.on('message', (msg) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Just incase the test exits
|
||||
// If the parent test process goes away, tear down the grandchild so it cannot keep running (e.g. Ping... loop).
|
||||
process.on('disconnect', () => {
|
||||
killGrandchild()
|
||||
process.exit()
|
||||
})
|
||||
|
||||
@@ -61,11 +61,11 @@ describe('lib/project-base', () => {
|
||||
this.project._cfg = this.config
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(async function () {
|
||||
Fixtures.remove()
|
||||
|
||||
if (this.project) {
|
||||
this.project.close()
|
||||
await this.project.close()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
require('../spec_helper')
|
||||
|
||||
const mockery = require('mockery')
|
||||
const { enable: enableMockery, mockElectron } = require('../mockery_helper')
|
||||
|
||||
const morganFn = function () {}
|
||||
|
||||
mockery.registerMock('morgan', () => {
|
||||
// Set by the morgan mock when `useMorgan` runs.
|
||||
let lastMorganFactoryArgs
|
||||
|
||||
function morganMockFactory (format, options) {
|
||||
lastMorganFactoryArgs = { format, options }
|
||||
|
||||
return morganFn
|
||||
})
|
||||
}
|
||||
|
||||
const _ = require('lodash')
|
||||
const os = require('os')
|
||||
@@ -17,6 +25,7 @@ const { SocketE2E } = require(`../../lib/socket-e2e`)
|
||||
const fileServer = require(`../../lib/file_server`)
|
||||
const ensureUrl = require(`../../lib/util/ensure-url`)
|
||||
const { getCtx } = require('@packages/data-context')
|
||||
const { GracefulExit } = require('../../lib/util/graceful-exit')
|
||||
|
||||
function getOpenOptions (overrides = {}) {
|
||||
return {
|
||||
@@ -33,6 +42,12 @@ function getOpenOptions (overrides = {}) {
|
||||
|
||||
describe('lib/server-base', () => {
|
||||
beforeEach(function () {
|
||||
// put_protocol_artifact_spec and others call mockery.deregisterAll(); re-enable and
|
||||
// re-register per test so require('morgan') is always our mock.
|
||||
enableMockery(mockery)
|
||||
mockElectron(mockery)
|
||||
mockery.registerMock('morgan', morganMockFactory)
|
||||
|
||||
this.fileServer = {
|
||||
close () {},
|
||||
port () {
|
||||
@@ -78,6 +93,48 @@ describe('lib/server-base', () => {
|
||||
})
|
||||
})
|
||||
|
||||
context('#useMorgan', () => {
|
||||
beforeEach(function () {
|
||||
GracefulExit.resetForTesting()
|
||||
sinon.stub(process, 'exit')
|
||||
lastMorganFactoryArgs = undefined
|
||||
// CI or other specs may set a low timeout; if the race timer wins before
|
||||
// flushAndExit clears processTeardown, skip() still mirrors isShuttingDown
|
||||
// and the post-await assertion flakes (see graceful_exit_spec teardown test).
|
||||
delete process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
GracefulExit.resetForTesting()
|
||||
delete process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT
|
||||
process.exit.restore()
|
||||
})
|
||||
|
||||
it('passes dev format and skip that mirrors GracefulExit.isShuttingDown', async function () {
|
||||
this.server.useMorgan()
|
||||
|
||||
expect(lastMorganFactoryArgs.format).to.eq('dev')
|
||||
expect(lastMorganFactoryArgs.options.skip()).to.be.false
|
||||
|
||||
let resolveStep
|
||||
const stepPromise = new Promise((resolve) => {
|
||||
resolveStep = resolve
|
||||
})
|
||||
|
||||
GracefulExit.addStep(() => stepPromise, 'slow-step')
|
||||
|
||||
const exitPromise = GracefulExit.exitGracefully(0)
|
||||
|
||||
expect(lastMorganFactoryArgs.options.skip()).to.be.true
|
||||
|
||||
resolveStep()
|
||||
|
||||
await exitPromise
|
||||
|
||||
expect(lastMorganFactoryArgs.options.skip()).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
context('#open', () => {
|
||||
beforeEach(function () {
|
||||
sinon.stub(this.server, 'createServer').resolves()
|
||||
|
||||
@@ -2,7 +2,9 @@ require('../../spec_helper')
|
||||
const os = require('os')
|
||||
const osPath = require('ospath')
|
||||
const path = require('path')
|
||||
const Promise = require('bluebird')
|
||||
|
||||
const { fs } = require('../../../lib/util/fs')
|
||||
const AppData = require(`../../../lib/util/app_data`)
|
||||
|
||||
describe('lib/util/app_data', () => {
|
||||
@@ -31,6 +33,30 @@ describe('lib/util/app_data', () => {
|
||||
})
|
||||
})
|
||||
|
||||
context('#ensure', () => {
|
||||
it('does not create the symlink until the appData directory exists', async () => {
|
||||
let ensureDirCompleted = false
|
||||
let ensureDirCompletedAtSymlinkCall = null
|
||||
|
||||
sinon.stub(fs, 'removeAsync').resolves()
|
||||
sinon.stub(fs, 'ensureDirAsync').callsFake(() => {
|
||||
return Promise.delay(50).then(() => {
|
||||
ensureDirCompleted = true
|
||||
})
|
||||
})
|
||||
|
||||
sinon.stub(fs, 'ensureSymlinkAsync').callsFake(() => {
|
||||
ensureDirCompletedAtSymlinkCall = ensureDirCompleted
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
await AppData.ensure()
|
||||
|
||||
expect(ensureDirCompletedAtSymlinkCall, 'ensureSymlinkAsync was called before the appData directory existed').to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
context('#findCommonAncestor', () => {
|
||||
it('posix', () => {
|
||||
expect(AppData.findCommonAncestor('/a/b/c/d', '/a/b/c/d/')).to.equal('/a/b/c/d')
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import '../../spec_helper'
|
||||
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import Promise from 'bluebird'
|
||||
import lockFileModule from 'lockfile'
|
||||
import { fs } from '../../../lib/util/fs'
|
||||
import * as env from '../../../lib/util/env'
|
||||
import exit from '../../../lib/util/exit'
|
||||
import { GracefulExit } from '../../../lib/util/graceful-exit'
|
||||
import { File as FileUtil } from '../../../lib/util/file'
|
||||
|
||||
const lockFile = Promise.promisifyAll(lockFileModule)
|
||||
|
||||
/** Introspect GracefulExit for regressions on File teardown registration (not public API). */
|
||||
function countUnlockLockfileSteps (): number {
|
||||
const singleton = (GracefulExit as unknown as { singleton: { steps: Map<string, { name: string }> } }).singleton
|
||||
|
||||
return [...singleton.steps.values()].filter((s) => s.name === 'unlock lockfile').length
|
||||
}
|
||||
|
||||
describe('lib/util/file', () => {
|
||||
beforeEach(function () {
|
||||
this.dir = path.join(os.tmpdir(), 'cypress', 'file_spec')
|
||||
@@ -25,12 +34,37 @@ describe('lib/util/file', () => {
|
||||
})
|
||||
|
||||
it('unlocks file on exit', function () {
|
||||
sinon.spy(lockFile, 'unlockSync')
|
||||
sinon.stub(exit, 'ensure')
|
||||
new FileUtil({ path: this.path })
|
||||
exit.ensure.yield()
|
||||
const unlockSpy = sinon.spy(lockFile, 'unlockSync')
|
||||
let teardownStep: () => Promise<void>
|
||||
const addStepStub = sinon.stub(GracefulExit, 'addStep').callsFake((fn: () => Promise<void> | void) => {
|
||||
teardownStep = fn as () => Promise<void>
|
||||
|
||||
expect(lockFile.unlockSync).to.be.called
|
||||
return 'test-step'
|
||||
})
|
||||
|
||||
new FileUtil({ path: this.path })
|
||||
|
||||
return teardownStep!().then(() => {
|
||||
expect(lockFile.unlockSync).to.be.called
|
||||
}).finally(() => {
|
||||
addStepStub.restore()
|
||||
unlockSpy.restore()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not leave orphaned GracefulExit unlock steps when ephemeral File instances are discarded', function () {
|
||||
const before = countUnlockLockfileSteps()
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
new FileUtil({ path: path.join(this.dir, `ephemeral-${i}.json`) })
|
||||
}
|
||||
|
||||
// Each File should remove its GracefulExit step when the instance is no longer needed, so
|
||||
// unreferenced instances must not accumulate unlock handlers (see lib/util/file.ts).
|
||||
expect(
|
||||
countUnlockLockfileSteps(),
|
||||
'ephemeral File instances must not leave GracefulExit unlock steps registered after they are discarded',
|
||||
).to.equal(before)
|
||||
})
|
||||
|
||||
context('#transaction', () => {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import '../../spec_helper'
|
||||
|
||||
import { GracefulExit } from '../../../lib/util/graceful-exit'
|
||||
|
||||
/**
|
||||
* Other packages (e.g. firefox-profile) register SIGINT handlers that call
|
||||
* process.exit(130). process.emit('SIGINT') invokes every listener, so a stub
|
||||
* on process.exit counts unrelated exits and flakes in CI when many listeners
|
||||
* are present. Snapshot listeners, clear them, run the callback, then restore.
|
||||
*/
|
||||
function withoutForeignSigHandlers<T> (fn: () => Promise<T>): Promise<T> {
|
||||
const sigintListeners = process.listeners('SIGINT').slice()
|
||||
const sigtermListeners = process.listeners('SIGTERM').slice()
|
||||
|
||||
process.removeAllListeners('SIGINT')
|
||||
process.removeAllListeners('SIGTERM')
|
||||
|
||||
return Promise.resolve()
|
||||
.then(fn)
|
||||
.finally(() => {
|
||||
GracefulExit.resetForTesting()
|
||||
process.removeAllListeners('SIGINT')
|
||||
process.removeAllListeners('SIGTERM')
|
||||
sigintListeners.forEach((listener) => process.on('SIGINT', listener))
|
||||
sigtermListeners.forEach((listener) => process.on('SIGTERM', listener))
|
||||
})
|
||||
}
|
||||
|
||||
describe('lib/util/graceful-exit', () => {
|
||||
beforeEach(() => {
|
||||
GracefulExit.resetForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
GracefulExit.resetForTesting()
|
||||
delete process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT
|
||||
})
|
||||
|
||||
it('isShuttingDown is false when idle', () => {
|
||||
expect(GracefulExit.isShuttingDown).to.be.false
|
||||
})
|
||||
|
||||
it('isShuttingDown is true while exitGracefully is in progress and false after teardown completes', async () => {
|
||||
const exitStub = sinon.stub(process, 'exit')
|
||||
|
||||
expect(GracefulExit.isShuttingDown).to.be.false
|
||||
|
||||
let resolveStep: () => void
|
||||
const stepPromise = new Promise<void>((resolve) => {
|
||||
resolveStep = resolve
|
||||
})
|
||||
|
||||
GracefulExit.addStep(async () => {
|
||||
await stepPromise
|
||||
}, 'slow-step')
|
||||
|
||||
const exitPromise = GracefulExit.exitGracefully(0)
|
||||
|
||||
expect(GracefulExit.isShuttingDown).to.be.true
|
||||
|
||||
resolveStep!()
|
||||
|
||||
await exitPromise
|
||||
|
||||
expect(GracefulExit.isShuttingDown).to.be.false
|
||||
expect(exitStub).to.have.been.calledOnce
|
||||
|
||||
exitStub.restore()
|
||||
})
|
||||
|
||||
it('runs registered teardown steps then exits with the requested code', async () => {
|
||||
const exitStub = sinon.stub(process, 'exit')
|
||||
const step = sinon.stub().resolves()
|
||||
|
||||
GracefulExit.addStep(step as any, 'test-step')
|
||||
await GracefulExit.exitGracefully(0)
|
||||
|
||||
expect(step).to.have.been.calledOnce
|
||||
expect(exitStub).to.have.been.calledWith(0)
|
||||
})
|
||||
|
||||
it('exits with code 1 when a step throws', async () => {
|
||||
const exitStub = sinon.stub(process, 'exit')
|
||||
|
||||
GracefulExit.addStep(async () => {
|
||||
throw new Error('step failed')
|
||||
}, 'failing-step')
|
||||
|
||||
await GracefulExit.exitGracefully(0)
|
||||
|
||||
expect(exitStub).to.have.been.calledWith(1)
|
||||
})
|
||||
|
||||
it('returns the same in-flight promise when exitGracefully is called twice', async () => {
|
||||
const exitStub = sinon.stub(process, 'exit')
|
||||
let resolveStep: () => void
|
||||
const stepPromise = new Promise<void>((resolve) => {
|
||||
resolveStep = resolve
|
||||
})
|
||||
|
||||
GracefulExit.addStep(async () => {
|
||||
await stepPromise
|
||||
}, 'slow-step')
|
||||
|
||||
const p1 = GracefulExit.exitGracefully(3)
|
||||
const p2 = GracefulExit.exitGracefully(7)
|
||||
|
||||
resolveStep!()
|
||||
|
||||
await Promise.all([p1, p2])
|
||||
|
||||
expect(exitStub).to.have.been.calledOnce
|
||||
expect(exitStub).to.have.been.calledWith(3)
|
||||
})
|
||||
|
||||
it('debounces duplicate SIGINT soon after teardown starts (single graceful exit)', async () => {
|
||||
const exitStub = sinon.stub(process, 'exit')
|
||||
|
||||
await withoutForeignSigHandlers(async () => {
|
||||
GracefulExit.resetForTesting()
|
||||
|
||||
let resolveStep: () => void
|
||||
const stepPromise = new Promise<void>((resolve) => {
|
||||
resolveStep = resolve
|
||||
})
|
||||
|
||||
GracefulExit.addStep(async () => {
|
||||
await stepPromise
|
||||
}, 'slow-step')
|
||||
|
||||
process.emit('SIGINT' as NodeJS.Signals)
|
||||
process.emit('SIGINT' as NodeJS.Signals)
|
||||
|
||||
resolveStep!()
|
||||
|
||||
await new Promise((r) => setImmediate(r))
|
||||
|
||||
expect(exitStub).to.have.been.calledOnce
|
||||
expect(exitStub).to.have.been.calledWith(130)
|
||||
})
|
||||
|
||||
exitStub.restore()
|
||||
})
|
||||
|
||||
it('SIGINT after dedup window during hung teardown forces exit 1', async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
const exitStub = sinon.stub(process, 'exit')
|
||||
|
||||
await withoutForeignSigHandlers(async () => {
|
||||
GracefulExit.resetForTesting()
|
||||
|
||||
GracefulExit.addStep(() => new Promise(() => {}), 'hang')
|
||||
|
||||
process.emit('SIGINT' as NodeJS.Signals)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
|
||||
process.emit('SIGINT' as NodeJS.Signals)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(exitStub).to.have.been.calledWith(1)
|
||||
})
|
||||
|
||||
exitStub.restore()
|
||||
})
|
||||
|
||||
it('force exits after teardown timeout when a step never completes', async function () {
|
||||
this.timeout(5000)
|
||||
|
||||
process.env.CYPRESS_INTERNAL_TEARDOWN_TIMEOUT = '50'
|
||||
|
||||
const exitStub = sinon.stub(process, 'exit')
|
||||
|
||||
GracefulExit.addStep(() => new Promise(() => {}), 'hang')
|
||||
|
||||
void GracefulExit.exitGracefully(0)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
|
||||
expect(exitStub).to.have.been.calledWith(1)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import pDefer from 'p-defer'
|
||||
import treeKill from 'tree-kill'
|
||||
import gulp from 'gulp'
|
||||
import os from 'os'
|
||||
import { psTreeSync } from '../utils/psTreeSync'
|
||||
|
||||
const childProcesses = new Set<ChildProcess>()
|
||||
const exitedPids = new Set<number>()
|
||||
@@ -90,23 +89,20 @@ async function exitHandler (exitCode: number) {
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
function signalHandler (signal: NodeJS.Signals, code: number) {
|
||||
hasExited = true
|
||||
try {
|
||||
// Windows doesn't automatically send signals to the whole process tree
|
||||
if (os.platform().startsWith('win')) {
|
||||
// signal handlers must be synchronous, so we can't use tree-kill
|
||||
const tree = psTreeSync(process.pid).filter((pid) => pid !== process.pid)
|
||||
|
||||
for (const pid of tree) {
|
||||
process.kill(pid, signal)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`An error occurred while handling signal ${signal}: ${error}`)
|
||||
async function signalHandler (signal: NodeJS.Signals) {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false)
|
||||
}
|
||||
|
||||
process.exit(128 + code)
|
||||
hasExited = true
|
||||
try {
|
||||
await exitAllProcesses()
|
||||
} catch (error) {
|
||||
console.error(`An error occurred while handling signal ${signal}: ${error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(128 + os.constants.signals[signal])
|
||||
}
|
||||
|
||||
async function uncaughtExceptionHandler (error: Error) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
function linuxOutput (pid: number): string {
|
||||
try {
|
||||
return execSync(`pgrep -P ${pid}`).toString().trim()
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function windowsOutput (pid: number): string {
|
||||
try {
|
||||
return execSync(`wmic process where (ParentProcessId=${pid}) get ProcessId`, { stdio: 'ignore' })
|
||||
.toString()
|
||||
.replace('ProcessId', '') // Remove the header row
|
||||
.trim()
|
||||
} catch (error) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function isValidPid (pid: unknown): pid is number {
|
||||
return !Number.isNaN(pid)
|
||||
}
|
||||
|
||||
// Treated as unknown to force validation before passing to the platform
|
||||
export function psTreeSync (pid: unknown): number[] {
|
||||
const root = pid ? Number(pid) : process.pid
|
||||
|
||||
if (!pid) {
|
||||
// check if pgrep is installed on linux/osx, and wmic is installed on windows
|
||||
if (process.platform.startsWith('win')) {
|
||||
try {
|
||||
execSync('which wmic')
|
||||
} catch (error) {
|
||||
console.warn('wmic is not available, unable to determine process tree')
|
||||
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
execSync('which pgrep')
|
||||
} catch (error) {
|
||||
console.warn('pgrep is not available, unable to determine process tree')
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidPid(root)) {
|
||||
throw new TypeError('pid must be a number')
|
||||
}
|
||||
|
||||
const output = process.platform.startsWith('win') ? windowsOutput(root) : linuxOutput(root)
|
||||
const childPids = output.split('\n').filter(Boolean).map(Number)
|
||||
|
||||
if (root === process.pid) {
|
||||
return childPids.flatMap(psTreeSync)
|
||||
}
|
||||
|
||||
return [root, ...childPids.flatMap(psTreeSync)]
|
||||
}
|
||||
+13
-2
@@ -4,13 +4,24 @@
|
||||
// arm.medium (2 CPU) runs 4 simultaneous tsc startups and intermittently
|
||||
// segfaults inside V8's bytecode-cache deserializer (#33730).
|
||||
const { spawn } = require('child_process')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const minimist = require('minimist')
|
||||
|
||||
const lerna = path.resolve(__dirname, '..', 'node_modules', '.bin', 'lerna')
|
||||
|
||||
const argv = minimist(process.argv.slice(2), { string: ['scope'] })
|
||||
const concurrency = Math.min(4, os.availableParallelism())
|
||||
|
||||
const args = ['run', 'build', '--stream', `--concurrency=${concurrency}`]
|
||||
|
||||
if (argv.scope) {
|
||||
args.push('--scope', argv.scope)
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
'lerna',
|
||||
['run', 'build', '--stream', `--concurrency=${concurrency}`],
|
||||
lerna,
|
||||
args,
|
||||
{ stdio: 'inherit', shell: true },
|
||||
)
|
||||
|
||||
|
||||
@@ -189,59 +189,3 @@ exports['e2e forms / submissions with jquery XHR POST / failing'] = `
|
||||
✖ 1 of 1 failed (100%) XX:XX 1
|
||||
|
||||
`
|
||||
|
||||
exports['e2e forms / submissions with jquery XHR POST / passing'] = `
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Starting)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Cypress: 1.2.3 │
|
||||
│ Browser: FooBrowser 88 │
|
||||
│ Specs: 1 found (form_submission_passing.cy.js) │
|
||||
│ Searched: cypress/e2e/form_submission_passing.cy.js │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Running: form_submission_passing.cy.js (1 of 1)
|
||||
|
||||
|
||||
form submissions
|
||||
✓ will find 'form success' message by default (after retrying)
|
||||
✓ needs an explicit should when an element is immediately found
|
||||
|
||||
|
||||
2 passing
|
||||
|
||||
|
||||
(Results)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Tests: 2 │
|
||||
│ Passing: 2 │
|
||||
│ Failing: 0 │
|
||||
│ Pending: 0 │
|
||||
│ Skipped: 0 │
|
||||
│ Screenshots: 0 │
|
||||
│ Video: false │
|
||||
│ Duration: X seconds │
|
||||
│ Spec Ran: form_submission_passing.cy.js │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Finished)
|
||||
|
||||
|
||||
Spec Tests Passing Failing Pending Skipped
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ✔ form_submission_passing.cy.js XX:XX 2 2 - - - │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
✔ All specs passed! XX:XX 2 2 - - -
|
||||
|
||||
|
||||
`
|
||||
|
||||
@@ -1,59 +1,3 @@
|
||||
exports['e2e headless / tests in headless mode pass'] = `
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Starting)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Cypress: 1.2.3 │
|
||||
│ Browser: FooBrowser 88 │
|
||||
│ Specs: 1 found (headless.cy.js) │
|
||||
│ Searched: cypress/e2e/headless.cy.js │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Running: headless.cy.js (1 of 1)
|
||||
|
||||
|
||||
e2e headless spec
|
||||
✓ has the expected values for Cypress.browser
|
||||
✓ has expected launch args
|
||||
|
||||
|
||||
2 passing
|
||||
|
||||
|
||||
(Results)
|
||||
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Tests: 2 │
|
||||
│ Passing: 2 │
|
||||
│ Failing: 0 │
|
||||
│ Pending: 0 │
|
||||
│ Skipped: 0 │
|
||||
│ Screenshots: 0 │
|
||||
│ Video: false │
|
||||
│ Duration: X seconds │
|
||||
│ Spec Ran: headless.cy.js │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
====================================================================================================
|
||||
|
||||
(Run Finished)
|
||||
|
||||
|
||||
Spec Tests Passing Failing Pending Skipped
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ✔ headless.cy.js XX:XX 2 2 - - - │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
✔ All specs passed! XX:XX 2 2 - - -
|
||||
|
||||
|
||||
`
|
||||
|
||||
exports['e2e headless / tests in headed mode pass in !electron'] = `
|
||||
|
||||
====================================================================================================
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ const human = require('human-interval')
|
||||
const morgan = require('morgan')
|
||||
const Bluebird = require('bluebird')
|
||||
const debug = require('debug')('cypress:system-tests')
|
||||
const treeKill = require('tree-kill')
|
||||
const { once } = require('events')
|
||||
const os = require('os')
|
||||
const { create: createHttpsServer } = require('@packages/https-proxy/test/helpers/https_server')
|
||||
|
||||
const { allowDestroy } = require(`@packages/server/lib/util/server_destroy`)
|
||||
@@ -43,8 +46,9 @@ type CypressConfig = { [key: string]: any }
|
||||
export type BrowserName = 'electron' | 'firefox' | 'chrome' | 'chrome-for-testing' | 'webkit'
|
||||
| '!electron' | '!chrome' | '!chrome-for-testing' | '!firefox' | '!webkit'
|
||||
|
||||
type ExecResult = {
|
||||
code: number
|
||||
export type ExecResult = {
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
@@ -250,6 +254,11 @@ type ExecOptions = {
|
||||
* Run Cypress with POSIX exit codes.
|
||||
*/
|
||||
posixExitCodes?: boolean
|
||||
/**
|
||||
* If true, skip asserting the Cypress child exited without a termination signal (e.g. SIGABRT).
|
||||
* @default false
|
||||
*/
|
||||
skipExitSignalAssertion?: boolean
|
||||
}
|
||||
|
||||
type Server = {
|
||||
@@ -289,11 +298,75 @@ export type SpawnerResult = {
|
||||
stdout: stream.Readable
|
||||
stderr: stream.Readable
|
||||
on(event: 'error', cb: (err: Error) => void): void
|
||||
on(event: 'exit', cb: (exitCode: number) => void): void
|
||||
on(event: 'exit', cb: (exitCode: number | null, signal?: NodeJS.Signals | null) => void): void
|
||||
kill: ChildProcess['kill']
|
||||
pid: number
|
||||
}
|
||||
|
||||
/** Active Cypress child from {@link systemTests.exec} (tree-kill; Docker has no host pid). */
|
||||
let activeCypressSpawn: SpawnerResult | null = null
|
||||
let activeSpawnIsDocker = false
|
||||
let interruptRequested = false
|
||||
let interruptInProgress = false
|
||||
|
||||
const INTERRUPT_CHILD_WAIT_MS = 30000
|
||||
|
||||
async function handleHarnessInterrupt (signal: NodeJS.Signals) {
|
||||
if (interruptInProgress) {
|
||||
process.exit(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
interruptInProgress = true
|
||||
interruptRequested = true
|
||||
|
||||
// So Mocha (or others) cannot exit this process before we tear down Cypress.
|
||||
process.removeAllListeners(signal)
|
||||
|
||||
try {
|
||||
if (activeCypressSpawn && !activeSpawnIsDocker) {
|
||||
await new Promise<void>((resolve) => {
|
||||
treeKill(activeCypressSpawn!.pid, signal, (err?: Error) => {
|
||||
if (err) {
|
||||
debug('tree-kill error: %o', err)
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
once(activeCypressSpawn, 'exit'),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('timeout waiting for Cypress exit')), INTERRUPT_CHILD_WAIT_MS)
|
||||
}),
|
||||
])
|
||||
} catch (e) {
|
||||
debug('waiting for Cypress exit after interrupt: %o', e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeCypressSpawn = null
|
||||
activeSpawnIsDocker = false
|
||||
|
||||
const code = 128 + os.constants.signals[signal]
|
||||
|
||||
process.exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
function installHarnessInterruptHandlers () {
|
||||
for (const sig of (['SIGINT', 'SIGTERM'] as const)) {
|
||||
process.prependListener(sig, () => {
|
||||
void handleHarnessInterrupt(sig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
installHarnessInterruptHandlers()
|
||||
|
||||
const cpSpawner: Spawner = (cmd, args, env, options) => {
|
||||
if (options.withBinary) {
|
||||
throw new Error('withBinary is not supported without the use of dockerImage')
|
||||
@@ -812,6 +885,10 @@ const systemTests = {
|
||||
debug('systemTests.exec options %o', options)
|
||||
options = this.options(ctx, options)
|
||||
|
||||
if (interruptRequested) {
|
||||
ctx.skip()
|
||||
}
|
||||
|
||||
debug('processed options %o', options)
|
||||
const args = options.args || this.args(options)
|
||||
|
||||
@@ -848,10 +925,23 @@ const systemTests = {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
const exit = function (code) {
|
||||
const { expectedExitCode } = options
|
||||
const exit = function (code: number | null, signal: NodeJS.Signals | null | undefined) {
|
||||
if (interruptRequested) {
|
||||
return {
|
||||
code,
|
||||
signal: signal == null ? null : signal,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
|
||||
const { expectedExitCode, skipExitSignalAssertion } = options
|
||||
|
||||
maybeVerifyExitCode(expectedExitCode, () => {
|
||||
if (!skipExitSignalAssertion) {
|
||||
expect(signal == null, `Cypress process exited by signal: ${signal} (exit code ${code})`).to.be.true
|
||||
}
|
||||
|
||||
if (expectedExitCode === 0) {
|
||||
expect(code).to.eq(expectedExitCode, `Process errored: Exit code ${code}`)
|
||||
} else {
|
||||
@@ -934,6 +1024,7 @@ const systemTests = {
|
||||
|
||||
return {
|
||||
code,
|
||||
signal: signal == null ? null : signal,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
@@ -982,41 +1073,66 @@ const systemTests = {
|
||||
.extend(options.processEnv)
|
||||
.value()
|
||||
|
||||
if (interruptRequested) {
|
||||
ctx.skip()
|
||||
}
|
||||
|
||||
const spawnerFn: Spawner = options.dockerImage ? dockerSpawner : cpSpawner
|
||||
const sp: SpawnerResult = await spawnerFn(cmd, args, env, options)
|
||||
|
||||
options.onSpawn && options.onSpawn(sp)
|
||||
activeSpawnIsDocker = !!options.dockerImage
|
||||
activeCypressSpawn = sp
|
||||
|
||||
const ColorOutput = function () {
|
||||
const colorOutput = new stream.Transform()
|
||||
try {
|
||||
options.onSpawn && options.onSpawn(sp)
|
||||
|
||||
colorOutput._transform = (chunk, encoding, cb) => cb(null, chalk.magenta(chunk.toString()))
|
||||
const ColorOutput = function () {
|
||||
const colorOutput = new stream.Transform()
|
||||
|
||||
return colorOutput
|
||||
colorOutput._transform = (chunk, encoding, cb) => cb(null, chalk.magenta(chunk.toString()))
|
||||
|
||||
return colorOutput
|
||||
}
|
||||
|
||||
// pipe these to our current process
|
||||
// so we can see them in the terminal
|
||||
// color it so we can tell which is test output
|
||||
sp.stdout
|
||||
.pipe(ColorOutput())
|
||||
.pipe(process.stdout)
|
||||
|
||||
sp.stderr
|
||||
.pipe(ColorOutput())
|
||||
.pipe(process.stderr)
|
||||
|
||||
sp.stdout.on('data', (buf) => stdout += buf.toString())
|
||||
sp.stderr.on('data', (buf) => stderr += buf.toString())
|
||||
|
||||
const [exitCode, exitSignal] = await new Promise<[number | null, NodeJS.Signals | null | undefined]>((resolve, reject) => {
|
||||
sp.on('error', reject)
|
||||
sp.on('exit', (code, sig) => {
|
||||
resolve([code, sig])
|
||||
})
|
||||
})
|
||||
|
||||
await copy(projectPath)
|
||||
|
||||
if (interruptRequested) {
|
||||
return {
|
||||
code: exitCode,
|
||||
signal: exitSignal == null ? null : exitSignal,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
|
||||
return exit(exitCode, exitSignal)
|
||||
} finally {
|
||||
if (activeCypressSpawn === sp) {
|
||||
activeCypressSpawn = null
|
||||
activeSpawnIsDocker = false
|
||||
}
|
||||
}
|
||||
|
||||
// pipe these to our current process
|
||||
// so we can see them in the terminal
|
||||
// color it so we can tell which is test output
|
||||
sp.stdout
|
||||
.pipe(ColorOutput())
|
||||
.pipe(process.stdout)
|
||||
|
||||
sp.stderr
|
||||
.pipe(ColorOutput())
|
||||
.pipe(process.stderr)
|
||||
|
||||
sp.stdout.on('data', (buf) => stdout += buf.toString())
|
||||
sp.stderr.on('data', (buf) => stderr += buf.toString())
|
||||
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
sp.on('error', reject)
|
||||
sp.on('exit', resolve)
|
||||
})
|
||||
|
||||
await copy(projectPath)
|
||||
|
||||
return exit(exitCode)
|
||||
},
|
||||
|
||||
sendHtml (contents) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user