Merge pull request #26494 from cypress-io/ryanm/chore/merge-develop

This commit is contained in:
Ryan Manuel
2023-04-13 18:01:51 +00:00
committed by GitHub
140 changed files with 22021 additions and 528 deletions

View File

@@ -30,7 +30,7 @@ mainBuildFilters: &mainBuildFilters
- /^release\/\d+\.\d+\.\d+$/
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- 'update-v8-snapshot-cache-on-develop'
- 'emily/before-spec-promise'
- 'matth/misc/telemetry'
# usually we don't build Mac app - it takes a long time
# but sometimes we want to really confirm we are doing the right thing
@@ -41,7 +41,6 @@ macWorkflowFilters: &darwin-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'emily/before-spec-promise', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -52,7 +51,6 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'fix/preflight', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -73,7 +71,6 @@ windowsWorkflowFilters: &windows-workflow-filters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'lmiller/fixing-vite-windows', << pipeline.git.branch >> ]
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'fix/preflight', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -139,7 +136,7 @@ commands:
- run:
name: Check current branch to persist artifacts
command: |
if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "emily/before-spec-promise" && "$CIRCLE_BRANCH" != "update-v8-snapshot-cache-on-develop" ]]; then
if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "update-v8-snapshot-cache-on-develop" ]]; then
echo "Not uploading artifacts or posting install comment for this branch."
circleci-agent step halt
fi
@@ -495,6 +492,8 @@ commands:
if [[ -v MAIN_RECORD_KEY ]]; then
# internal PR
CYPRESS_RECORD_KEY=$MAIN_RECORD_KEY \
CYPRESS_INTERNAL_ENABLE_TELEMETRY="true" \
OTEL_RESOURCE_ATTRIBUTES="ci.branch=$CIRCLE_BRANCH,ci.job=$CIRCLE_JOB,ci.node-index=$CIRCLE_NODE_INDEX,ci.circle=$CIRCLECI,ci.build-url=$CIRCLE_BUILD_URL,ci.build-number=$CIRCLE_BUILD_NUM" \
yarn cypress:run --record --parallel --group 5x-driver-<<parameters.browser>> --browser <<parameters.browser>>
else
# external PR
@@ -567,6 +566,8 @@ commands:
PERCY_PARALLEL_NONCE=$CIRCLE_WORKFLOW_WORKSPACE_ID \
PERCY_ENABLE=${PERCY_TOKEN:-0} \
PERCY_PARALLEL_TOTAL=-1 \
CYPRESS_INTERNAL_ENABLE_TELEMETRY="true" \
OTEL_RESOURCE_ATTRIBUTES="ci.branch=$CIRCLE_BRANCH,ci.job=$CIRCLE_JOB,ci.node-index=$CIRCLE_NODE_INDEX,ci.circle=$CIRCLECI,ci.build-url=$CIRCLE_BUILD_URL,ci.build-number=$CIRCLE_BUILD_NUM" \
$cmd yarn workspace @packages/<<parameters.package>> cypress:run:<<parameters.type>> --browser <<parameters.browser>> --record --parallel --group <<parameters.package>>-<<parameters.type>>
else
# external PR
@@ -1307,7 +1308,7 @@ jobs:
<<: *defaultsParameters
steps:
- restore_cached_workspace
- run:
- run:
name: 'Determine if Release Workflow should be triggered'
command: |
if [[ "$CIRCLE_BRANCH" != "develop" ]]; then
@@ -1455,7 +1456,7 @@ jobs:
# run type checking for each individual package
- run: yarn lerna run types
- verify-mocha-results:
expectedResultCount: 18
expectedResultCount: 19
- store_test_results:
path: /tmp/cypress
# CLI tests generate HTML files with sample CLI command output

View File

@@ -5,7 +5,7 @@ on:
debug-only:
description: 'debug-only'
required: false
default: true
default: false
days-before-stale:
description: 'days-before-stale'
required: false
@@ -50,5 +50,5 @@ jobs:
exempt-issue-labels: ${{ github.event.inputs.exempt-issue-labels || env.DEFAULT_EXEMPT_ISSUE_LABELS }}
exempt-pr-labels: ${{ github.event.inputs.exempt-pr-labels || env.DEFAULT_EXEMPT_PR_LABELS }}
exempt-all-milestones: true
operations-per-run: 1000 #using during debug mode to capture all the tickets impacted
operations-per-run: 200 #keeping this a bit higher because it processes newest tickets to oldest
debug-only: ${{ github.event.inputs.debug-only || env.DEFAULT_DEBUG_ONLY }}

View File

@@ -37,7 +37,7 @@ jobs:
echo 'IS_COLLABORATOR='$(jq -r '.data.repository.collaborators.totalCount' collaborators.json) >> $GITHUB_ENV
- uses: actions/add-to-project@v0.4.1
# only add issues/prs from outside contributors to the project
if: ${{ env.IS_COLLABORATOR == 0 }}
if: ${{ env.IS_COLLABORATOR == 0 || github.event.repository.name == 'cypress-support-internal' || github.event.pull_request.user.login == 'github-actions[bot]' || github.event.issue.user.login == 'github-actions[bot]' }}
with:
project-url: https://github.com/orgs/${{github.repository_owner}}/projects/${{env.PROJECT_NUMBER}}
github-token: ${{ secrets.ADD_TO_TRIAGE_BOARD_TOKEN }}

View File

@@ -30,7 +30,7 @@ jobs:
strategy:
max-parallel: 1
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
platform: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
env:
CYPRESS_BOT_APP_ID: ${{ secrets.RAM_APP }}

View File

@@ -42,7 +42,7 @@ Thanks for taking the time to contribute! :smile:
## Code of Conduct
All contributors are expecting to abide by our [Code of Conduct](./CODE_OF_CONDUCT.md).
All contributors are expected to abide by our [Code of Conduct](./CODE_OF_CONDUCT.md).
## Opening Issues
@@ -112,7 +112,7 @@ video | Problems with video recordings | [open](https://github.com/cypress-io/cy
## Writing Documentation
Cypress documentation lives in a separate repository with its own dependencies and build tools.
See [Documentation Contributing Guidelines](https://github.com/cypress-io/cypress-documentation/blob/master/CONTRIBUTING.md).
@@ -155,7 +155,7 @@ Here is a list of the core packages in this repository with a short description,
| [proxy](./packages/proxy) | `@packages/proxy` | Code for Cypress' network proxy layer. |
| [reporter](./packages/reporter) | `@packages/reporter` | The reporter shows the running results of the tests (The Command Log UI). |
| [resolve-dist](./packages/resolve-dist) | `@packages/resolve-dist` | Centralizes the resolution of paths to compiled/static assets from server-side code.. |
| [rewriter](./packages/rewriter) | `@packages/rewriter` | The logic to rewrite JS and HTML that flows through the Cypress proxy.
| [rewriter](./packages/rewriter) | `@packages/rewriter` | The logic to rewrite JS and HTML that flows through the Cypress proxy.
| [root](./packages/root) | `@packages/root` | Dummy package pointing at the root of the repository. |
| [runner](./packages/runner) | `@packages/runner` | (deprecated) The runner is the minimal "chrome" around the user's application under test. |
| [scaffold-config](./packages/scaffold-config) | `@packages/scaffold-config` | The logic related to scaffolding new projects using launchpad. |
@@ -318,13 +318,13 @@ Each package is responsible for building itself and testing itself and can do so
When executing top or package level scripts, [Vite](https://vitejs.dev/) may be used to build/host parts of the application. This section is to serve as a general reference for these environment variables that may be leverage throughout the repository.
###### `CYPRESS_INTERNAL_VITE_DEV`
Set to `1` if wanting to leverage [vite's](https://vitejs.dev/guide/#command-line-interface) `vite dev` over `vite build` to avoid a full [production build](https://vitejs.dev/guide/build.html).
###### `CYPRESS_INTERNAL_VITE_INSPECT`
###### `CYPRESS_INTERNAL_VITE_INSPECT`
Used internally to leverage [vite-plugin-inspect](https://github.com/antfu/vite-plugin-inspect) to view intermediary vite plugin state. The `CYPRESS_INTERNAL_VITE_DEV` is required for this to be applied correctly. Set to `1` to enable.
###### `CYPRESS_INTERNAL_VITE_OPEN_MODE_TESTING`
###### `CYPRESS_INTERNAL_VITE_OPEN_MODE_TESTING`
Leveraged only for internal cy-in-cy type tests to access the Cypress instance from the parent frame. Please see the [E2E Open Mode Testing](./guides/e2e-open-testing.md) Guide. Set to `true` when doing
###### `CYPRESS_INTERNAL_VITE_APP_PORT`
###### `CYPRESS_INTERNAL_VITE_APP_PORT`
Leveraged only when `CYPRESS_INTERNAL_VITE_DEV` is set to spawn the vite dev server for the app on the specified port. The default port is `3333`.
###### `CYPRESS_INTERNAL_VITE_LAUNCHPAD_PORT`
###### `CYPRESS_INTERNAL_VITE_LAUNCHPAD_PORT`
Leveraged only when `CYPRESS_INTERNAL_VITE_DEV` is set to spawn the vite dev server for the launchpad on the specified port. The default port is `3001`.
#### Debug Logs
@@ -443,7 +443,7 @@ We do not continuously deploy the Cypress binary, so `develop` contains all of t
- After the PR is approved, the original contributor can merge the PR (if the original contributor has access).
- When you merge a PR into `develop`, select [**Squash and merge**](https://docs.github.com/en/github/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-pull-request-commits). This will squash all commits into a single commit.
*The only exceptions to squashing are:*
*The only exceptions to squashing are:*
1. When converting files to another language and there is a clear commit history needed to maintain from the file conversion.
2. When merging a `release/*` branch to `develop`. Individual PRs were already squashed when they were merged to the release branch, and we want that history intact on develop.

View File

@@ -1,5 +1,5 @@
{
"chrome:beta": "112.0.5615.49",
"chrome:stable": "111.0.5563.146",
"chrome:beta": "113.0.5672.24",
"chrome:stable": "112.0.5615.49",
"chrome:minimum": "64.0.3282.0"
}

View File

@@ -3,14 +3,29 @@
_Released 04/11/2023 (PENDING)_
**Features:**
- The Component Testing setup wizard will now show a warning message if an issue is encountered with an installed [third party framework definition](https://on.cypress.io/component-integrations). Addresses [#25838](https://github.com/cypress-io/cypress/issues/25838).
**Bugfixes:**
- Capture the [Azure](https://azure.microsoft.com/) CI provider's environment variable [`SYSTEM_PULLREQUEST_PULLREQUESTNUMBER`](https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#system-variables-devops-services) to display the linked PR number in the Cloud. Addressed in [#26215](https://github.com/cypress-io/cypress/pull/26215).
- Fixed an issue in the onboarding wizard where project framework & bundler would not be auto-detected when opening directly into component testing mode using the `--component` CLI flag. Fixes [#22777](https://github.com/cypress-io/cypress/issues/22777).
- Fixed an issue in the onboarding wizard where project framework & bundler would not be auto-detected when opening directly into component testing mode using the `--component` CLI flag. Fixes [#22777](https://github.com/cypress-io/cypress/issues/22777) and [#26388](https://github.com/cypress-io/cypress/issues/26388).
- Updated to use the `SEMAPHORE_GIT_WORKING_BRANCH` [Semphore](https://docs.semaphoreci.com) CI environment variable to correctly associate a Cloud run to the current branch. Previously this was incorrectly associating a run to the target branch. Fixes [#26309](https://github.com/cypress-io/cypress/issues/26309).
- Fix an edge case in Component Testing where a custom `baseUrl` in `tsconfig.json` for Next.js 13.2.0+ is not respected. This was partially fixed in [#26005](https://github.com/cypress-io/cypress/pull/26005), but an edge case was missed. Fixes [#25951](https://github.com/cypress-io/cypress/issues/25951).
- Correctly detect and resolve dependencies when configuring Component Testing in projects using Yarn's [Plug'n'Play feature](https://yarnpkg.com/features/pnp). Fixes [#25960](https://github.com/cypress-io/cypress/issues/25960).
- Fixed an issue where `click` events fired on `.type('{enter}')` did not propagate through shadow roots. Fixes [#26392](https://github.com/cypress-io/cypress/issues/26392).
**Misc:**
- Removed unintentional debug logs. Addressed in [#26411](https://github.com/cypress-io/cypress/pull/26411).
- Improved styling on the [Runs Page](https://docs.cypress.io/guides/core-concepts/cypress-app#Runs). Addresses [#26180](https://github.com/cypress-io/cypress/issues/26180).
**Dependency Updates:**
- Upgraded [`commander`](https://www.npmjs.com/package/commander) from `^5.1.0` to `^6.2.1`. Addressed in [#26226](https://github.com/cypress-io/cypress/pull/26226).
- Upgraded [`minimist`](https://www.npmjs.com/package/minimist) from `1.2.6` to `1.2.8` to address this [CVE-2021-44906](https://github.com/advisories/GHSA-xvch-5gv4-984h) NVD security vulnerability. Addressed in [#26254](https://github.com/cypress-io/cypress/pull/26254).
- Added [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3) to support storing test run information which will be sent when recording to the Cloud
**Misc:**
- Removed unintentional debug logs. Address in [#26411](https://github.com/cypress-io/cypress/pull/26411)
@@ -35,10 +50,6 @@ _Released 03/28/2023_
**Misc:**
- Made some minor styling updates to the Debug page. Addresses [#26041](https://github.com/cypress-io/cypress/issues/26041).
**Dependency Updates:**
- Added [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3) to support storing test run information which will be sent when recording to the Cloud
## 12.8.1

View File

@@ -218,7 +218,7 @@ exports['cli help command shows help 1'] = `
Commands:
help Shows CLI help and exits
version prints Cypress version
version [options] prints Cypress version
open [options] Opens Cypress in the interactive GUI.
run [options] Runs Cypress tests from the CLI without the GUI
open-ct [options] Opens Cypress component testing interactive mode.
@@ -258,7 +258,7 @@ exports['cli help command shows help for -h 1'] = `
Commands:
help Shows CLI help and exits
version prints Cypress version
version [options] prints Cypress version
open [options] Opens Cypress in the interactive GUI.
run [options] Runs Cypress tests from the CLI without the GUI
open-ct [options] Opens Cypress component testing interactive mode.
@@ -298,7 +298,7 @@ exports['cli help command shows help for --help 1'] = `
Commands:
help Shows CLI help and exits
version prints Cypress version
version [options] prints Cypress version
open [options] Opens Cypress in the interactive GUI.
run [options] Runs Cypress tests from the CLI without the GUI
open-ct [options] Opens Cypress component testing interactive mode.
@@ -339,7 +339,7 @@ exports['cli unknown command shows usage and exits 1'] = `
Commands:
help Shows CLI help and exits
version prints Cypress version
version [options] prints Cypress version
open [options] Opens Cypress in the interactive GUI.
run [options] Runs Cypress tests from the CLI without the GUI
open-ct [options] Opens Cypress component testing interactive mode.
@@ -408,20 +408,6 @@ Electron version: not found
Bundled Node version: not found
`
exports['cli --version no binary version 1'] = `
Cypress package version: 1.2.3
Cypress binary version: not installed
Electron version: not found
Bundled Node version: not found
`
exports['cli -v no binary version 1'] = `
Cypress package version: 1.2.3
Cypress binary version: not installed
Electron version: not found
Bundled Node version: not found
`
exports['cli cypress run warns with space-separated --spec 1'] = `
⚠ Warning: It looks like you're passing --spec a space-separated list of arguments:
@@ -466,7 +452,7 @@ exports['cli CYPRESS_INTERNAL_ENV allows and warns when staging environment 1']
Commands:
help Shows CLI help and exits
version prints Cypress version
version [options] prints Cypress version
open [options] Opens Cypress in the interactive GUI.
run [options] Runs Cypress tests from the CLI without the GUI
open-ct [options] Opens Cypress component testing interactive mode.

View File

@@ -154,26 +154,16 @@ const text = (description) => {
function includesVersion (args) {
return (
_.includes(args, 'version') ||
_.includes(args, '--version') ||
_.includes(args, '-v')
)
}
function showVersions (args) {
function showVersions (opts) {
debug('printing Cypress version')
debug('additional arguments %o', args)
debug('additional arguments %o', opts)
const versionParser = commander.option(
'--component <package|binary|electron|node>', 'component to report version for',
)
.allowUnknownOption(true)
const parsed = versionParser.parse(args)
const parsedOptions = {
component: parsed.component,
}
debug('parsed version arguments %o', parsedOptions)
debug('parsed version arguments %o', opts)
const reportAllVersions = (versions) => {
logger.always('Cypress package version:', versions.package)
@@ -215,8 +205,8 @@ function showVersions (args) {
return require('./exec/versions')
.getVersions()
.then((versions = defaultVersions) => {
if (parsedOptions.component) {
reportComponentVersion(parsedOptions.component, versions)
if (opts?.component) {
reportComponentVersion(opts.component, versions)
} else {
reportAllVersions(versions)
}
@@ -456,13 +446,19 @@ module.exports = {
program.help()
})
program
const handleVersion = (cmd) => {
return cmd
.option('--component <package|binary|electron|node>', 'component to report version for')
.action((opts, ...other) => {
showVersions(util.parseOpts(opts))
})
}
handleVersion(program
.storeOptionsAsProperties()
.option('-v, --version', text('version'))
.command('version')
.description(text('version'))
.action(() => {
showVersions(args)
})
.description(text('version')))
maybeAddInspectFlags(addCypressOpenCommand(program))
.action((opts) => {
@@ -665,7 +661,7 @@ module.exports = {
// and now does not understand top level options
// .option('-v, --version').command('version')
// so we have to manually catch '-v, --version'
return showVersions(args)
handleVersion(program)
}
debug('program parsing arguments')

View File

@@ -34,7 +34,7 @@
"check-more-types": "^2.24.0",
"cli-cursor": "^3.1.0",
"cli-table3": "~0.6.1",
"commander": "^5.1.0",
"commander": "^6.2.1",
"common-tags": "^1.8.0",
"dayjs": "^1.10.4",
"debug": "^4.3.4",
@@ -89,7 +89,6 @@
"mock-fs": "5.1.1",
"mocked-env": "1.3.2",
"nock": "13.2.9",
"postinstall-postinstall": "2.1.0",
"proxyquire": "2.1.3",
"resolve-pkg": "2.0.0",
"shelljs": "0.8.5",

View File

@@ -23,11 +23,11 @@ describe('cli', () => {
beforeEach(() => {
logger.reset()
sinon.stub(process, 'exit')
sinon.stub(process, 'exit').returns(null)
os.platform.returns('darwin')
// sinon.stub(util, 'exit')
sinon.stub(util, 'logErrorExit1')
sinon.stub(util, 'logErrorExit1').returns(null)
sinon.stub(util, 'pkgBuildInfo').returns({ stable: true })
this.exec = (args) => {
const cliArgs = `node test ${args}`.split(' ')
@@ -136,189 +136,169 @@ describe('cli', () => {
})
})
context('cypress version', () => {
let restoreEnv
;['--version', '-v', 'version'].forEach((versionCommand) => {
context(`cypress ${versionCommand}`, () => {
let restoreEnv
afterEach(() => {
if (restoreEnv) {
restoreEnv()
restoreEnv = null
}
})
afterEach(() => {
if (restoreEnv) {
restoreEnv()
restoreEnv = null
}
})
const binaryDir = '/binary/dir'
const binaryDir = '/binary/dir'
beforeEach(() => {
sinon.stub(state, 'getBinaryDir').returns(binaryDir)
})
describe('individual package versions', () => {
beforeEach(() => {
sinon.stub(state, 'getBinaryDir').returns(binaryDir)
})
describe('individual package versions', () => {
beforeEach(() => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon
.stub(state, 'getBinaryPkgAsync')
.withArgs(binaryDir)
.resolves({
version: 'X.Y.Z',
electronVersion: '10.9.8',
electronNodeVersion: '7.7.7',
})
})
it('reports just the package version', (done) => {
this.exec(`${versionCommand} --component package`)
process.exit.callsFake((exitCode) => {
expect(logger.print()).to.equal('1.2.3')
done()
})
})
it('reports just the binary version', (done) => {
this.exec(`${versionCommand} --component binary`)
process.exit.callsFake(() => {
expect(logger.print()).to.equal('X.Y.Z')
done()
})
})
it('reports just the electron version', (done) => {
this.exec(`${versionCommand} --component electron`)
process.exit.callsFake(() => {
expect(logger.print()).to.equal('10.9.8')
done()
})
})
it('reports just the bundled Node version', (done) => {
this.exec(`${versionCommand} --component node`)
process.exit.callsFake(() => {
expect(logger.print()).to.equal('7.7.7')
done()
})
})
it('handles not found bundled Node version', (done) => {
state.getBinaryPkgAsync
.withArgs(binaryDir)
.resolves({
version: 'X.Y.Z',
electronVersion: '10.9.8',
})
this.exec(`${versionCommand} --component node`)
process.exit.callsFake(() => {
expect(logger.print()).to.equal('not found')
done()
})
})
})
it('reports package version', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon
.stub(state, 'getBinaryPkgAsync')
.withArgs(binaryDir)
.resolves({
version: 'X.Y.Z',
electronVersion: '10.9.8',
electronNodeVersion: '7.7.7',
})
})
it('reports just the package version', (done) => {
this.exec('version --component package')
this.exec(versionCommand)
process.exit.callsFake(() => {
expect(logger.print()).to.equal('1.2.3')
snapshot('cli version and binary version 1', logger.print(), { allowSharedSnapshot: true })
done()
})
})
it('reports just the binary version', (done) => {
this.exec('version --component binary')
it('reports package and binary message', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({ version: 'X.Y.Z' })
this.exec(versionCommand)
process.exit.callsFake(() => {
expect(logger.print()).to.equal('X.Y.Z')
snapshot('cli version and binary version 2', logger.print(), { allowSharedSnapshot: true })
done()
})
})
it('reports just the electron version', (done) => {
this.exec('version --component electron')
process.exit.callsFake(() => {
expect(logger.print()).to.equal('10.9.8')
done()
})
})
it('reports just the bundled Node version', (done) => {
this.exec('version --component node')
process.exit.callsFake(() => {
expect(logger.print()).to.equal('7.7.7')
done()
})
})
it('handles not found bundled Node version', (done) => {
state.getBinaryPkgAsync
.withArgs(binaryDir)
.resolves({
it('reports electron and node message', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({
version: 'X.Y.Z',
electronVersion: '10.9.8',
electronVersion: '10.10.88',
electronNodeVersion: '11.10.3',
})
this.exec('version --component node')
this.exec(versionCommand)
process.exit.callsFake(() => {
expect(logger.print()).to.equal('not found')
snapshot('cli version with electron and node 1', logger.print(), { allowSharedSnapshot: true })
done()
})
})
})
it('reports package version', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon
.stub(state, 'getBinaryPkgAsync')
.withArgs(binaryDir)
.resolves({
version: 'X.Y.Z',
it('reports package and binary message with npm log silent', (done) => {
restoreEnv = mockedEnv({
npm_config_loglevel: 'silent',
})
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({ version: 'X.Y.Z' })
this.exec(versionCommand)
process.exit.callsFake(() => {
// should not be empty!
snapshot('cli version and binary version with npm log silent', logger.print(), { allowSharedSnapshot: true })
done()
})
})
this.exec('version')
process.exit.callsFake(() => {
snapshot('cli version and binary version 1', logger.print())
done()
})
})
it('reports package and binary message with npm log warn', (done) => {
restoreEnv = mockedEnv({
npm_config_loglevel: 'warn',
})
it('reports package and binary message', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({ version: 'X.Y.Z' })
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({
version: 'X.Y.Z',
})
this.exec('version')
process.exit.callsFake(() => {
snapshot('cli version and binary version 2', logger.print())
done()
})
})
it('reports electron and node message', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({
version: 'X.Y.Z',
electronVersion: '10.10.88',
electronNodeVersion: '11.10.3',
})
this.exec('version')
process.exit.callsFake(() => {
snapshot('cli version with electron and node 1', logger.print())
done()
})
})
it('reports package and binary message with npm log silent', (done) => {
restoreEnv = mockedEnv({
npm_config_loglevel: 'silent',
})
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({ version: 'X.Y.Z' })
this.exec('version')
process.exit.callsFake(() => {
this.exec(versionCommand)
process.exit.callsFake(() => {
// should not be empty!
snapshot('cli version and binary version with npm log silent', logger.print())
done()
})
})
it('reports package and binary message with npm log warn', (done) => {
restoreEnv = mockedEnv({
npm_config_loglevel: 'warn',
snapshot('cli version and binary version with npm log warn', logger.print(), { allowSharedSnapshot: true })
done()
})
})
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves({
version: 'X.Y.Z',
})
it('handles non-existent binary', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves(null)
this.exec('version')
process.exit.callsFake(() => {
// should not be empty!
snapshot('cli version and binary version with npm log warn', logger.print())
done()
})
})
it('handles non-existent binary version', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves(null)
this.exec('version')
process.exit.callsFake(() => {
snapshot('cli version no binary version 1', logger.print())
done()
})
})
it('handles non-existent binary --version', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves(null)
this.exec('--version')
process.exit.callsFake(() => {
snapshot('cli --version no binary version 1', logger.print())
done()
})
})
it('handles non-existent binary -v', (done) => {
sinon.stub(util, 'pkgVersion').returns('1.2.3')
sinon.stub(state, 'getBinaryPkgAsync').resolves(null)
this.exec('-v')
process.exit.callsFake(() => {
snapshot('cli -v no binary version 1', logger.print())
done()
this.exec(versionCommand)
process.exit.callsFake(() => {
snapshot('cli version no binary version 1', logger.print(), { allowSharedSnapshot: true })
done()
})
})
})
})

View File

@@ -1,3 +1,5 @@
# [create-cypress-tests-v2.0.2](https://github.com/cypress-io/cypress/compare/create-cypress-tests-v2.0.1...create-cypress-tests-v2.0.2) (2023-04-07)
# [create-cypress-tests-v2.0.1](https://github.com/cypress-io/cypress/compare/create-cypress-tests-v2.0.0...create-cypress-tests-v2.0.1) (2023-01-03)

View File

@@ -20,7 +20,7 @@
"bluebird": "3.7.2",
"chalk": "4.1.0",
"cli-highlight": "2.1.10",
"commander": "6.1.0",
"commander": "6.2.1",
"fast-glob": "3.2.7",
"find-up": "5.0.0",
"fs-extra": "^9.1.0",

View File

@@ -19,8 +19,6 @@ All other tests will be marked pending, see why in the [Cypress test statuses](h
If you have multiple spec files, all specs will be loaded, and every test will be filtered the same way, since the grep is run-time operation and cannot eliminate the spec files without loading them. If you want to run only specific tests, use the built-in [--spec](https://on.cypress.io/command-line#cypress-run-spec-lt-spec-gt) CLI argument.
Watch the video [intro to @cypress/grep plugin](https://www.youtube.com/watch?v=HS-Px-Sghd8)
Table of Contents
<!-- MarkdownTOC autolink="true" -->
@@ -602,4 +600,4 @@ Version >= 3 of @cypress/grep _only_ supports Cypress >= 10.
License: MIT - do anything with the code, but don't blame me if it does not work.
Support: if you find any problems with this module, email / tweet /
[open issue](https://github.com/cypress-io/cypress/issues) on Github.
[open issue](https://github.com/cypress-io/cypress/issues) on Github.

View File

@@ -1,3 +1,10 @@
# [@cypress/webpack-dev-server-v3.4.1](https://github.com/cypress-io/cypress/compare/@cypress/webpack-dev-server-v3.4.0...@cypress/webpack-dev-server-v3.4.1) (2023-04-07)
### Bug Fixes
* correctly pass resolvedBaseUrl for Next.js 13.2.0+ ([#26399](https://github.com/cypress-io/cypress/issues/26399)) ([e8390f4](https://github.com/cypress-io/cypress/commit/e8390f46cd852417f2c0b07a9c73eeaf7437e823))
# [@cypress/webpack-dev-server-v3.4.0](https://github.com/cypress-io/cypress/compare/@cypress/webpack-dev-server-v3.3.1...@cypress/webpack-dev-server-v3.4.0) (2023-03-20)

View File

@@ -29,7 +29,12 @@ export async function nextHandler (devServerConfig: WebpackDevServerConfig): Pro
*/
function getNextJsPackages (devServerConfig: WebpackDevServerConfig) {
const resolvePaths = { paths: [devServerConfig.cypressConfig.projectRoot] }
const packages = {} as { loadConfig: Function, getNextJsBaseWebpackConfig: Function, nextLoadJsConfig: Function }
const packages = {} as {
loadConfig: (phase: 'development', dir: string) => Promise<any>
getNextJsBaseWebpackConfig: Function
nextLoadJsConfig: Function
getSupportedBrowsers: (dir: string, isDevelopment: boolean, nextJsConfig: any) => Promise<string[] | undefined>
}
try {
const loadConfigPath = require.resolve('next/dist/server/config', resolvePaths)
@@ -55,6 +60,15 @@ function getNextJsPackages (devServerConfig: WebpackDevServerConfig) {
throw new Error(`Failed to load "next/dist/build/load-jsconfig" with error: ${ e.message ?? e}`)
}
// Does not exist prior to Next 13.
try {
const getUtilsPath = require.resolve('next/dist/build/utils', resolvePaths)
packages.getSupportedBrowsers = require(getUtilsPath).getSupportedBrowsers ?? (() => Promise.resolve([]))
} catch (e: any) {
throw new Error(`Failed to load "next/dist/build/utils" with error: ${ e.message ?? e}`)
}
return packages
}
@@ -173,12 +187,13 @@ function getNextJsPackages (devServerConfig: WebpackDevServerConfig) {
]
*/
async function loadWebpackConfig (devServerConfig: WebpackDevServerConfig): Promise<Configuration> {
const { loadConfig, getNextJsBaseWebpackConfig, nextLoadJsConfig } = getNextJsPackages(devServerConfig)
const { loadConfig, getNextJsBaseWebpackConfig, nextLoadJsConfig, getSupportedBrowsers } = getNextJsPackages(devServerConfig)
const nextConfig = await loadConfig('development', devServerConfig.cypressConfig.projectRoot)
const runWebpackSpan = getRunWebpackSpan(devServerConfig)
const reactVersion = getReactVersion(devServerConfig.cypressConfig.projectRoot)
const jsConfigResult = await nextLoadJsConfig?.(devServerConfig.cypressConfig.projectRoot, nextConfig)
const supportedBrowsers = await getSupportedBrowsers(devServerConfig.cypressConfig.projectRoot, true, nextConfig)
const webpackConfig = await getNextJsBaseWebpackConfig(
devServerConfig.cypressConfig.projectRoot,
@@ -196,8 +211,12 @@ async function loadWebpackConfig (devServerConfig: WebpackDevServerConfig): Prom
compilerType: 'client',
// Required for Next.js > 13
hasReactRoot: reactVersion === 18,
// Required for Next.js > 13.2.1 to respect TS/JS config
// Required for Next.js > 13.2.0 to respect TS/JS config
jsConfig: jsConfigResult.jsConfig,
// Required for Next.js > 13.2.0 to respect tsconfig.compilerOptions.baseUrl
resolvedBaseUrl: jsConfigResult.resolvedBaseUrl,
// Added in Next.js 13, passed via `...info`: https://github.com/vercel/next.js/pull/45637/files
supportedBrowsers,
},
)

View File

@@ -49,7 +49,7 @@
"stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,src,__snapshots__ --exclude cypress-tests.ts,*only.cy.js",
"stop-only-all": "yarn stop-only --folder packages",
"pretest": "yarn ensure-deps",
"test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{config,data-context,electron,errors,extension,https-proxy,launcher,net-stubbing,network,packherd-require,proxy,rewriter,scaffold-config,socket,v8-snapshot-require}'\" --scope \"'@tooling/{electron-mksnapshot,v8-snapshot}'\"",
"test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{config,data-context,electron,errors,extension,https-proxy,launcher,net-stubbing,network,packherd-require,proxy,rewriter,scaffold-config,socket,v8-snapshot-require,telemetry}'\" --scope \"'@tooling/{electron-mksnapshot,v8-snapshot}'\"",
"test-debug": "lerna exec yarn test-debug --ignore \"'@packages/{driver,root,static,web-config}'\"",
"pretest-e2e": "yarn ensure-deps",
"test-integration": "lerna exec yarn test-integration --ignore \"'@packages/{driver,root,static,web-config}'\"",
@@ -189,7 +189,6 @@
"patch-package": "6.4.7",
"playwright-webkit": "1.24.2",
"pluralize": "8.0.0",
"postinstall-postinstall": "2.0.0",
"print-arch": "1.0.0",
"proxyquire": "2.1.3",
"rimraf": "3.0.2",

View File

@@ -100,7 +100,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.contains('a', 'OVERLIMIT').click()
cy.withCtx((ctx) => {
expect((ctx.actions.electron.openExternal as SinonStub).lastCall.lastArg).to.eq('http://dummy.cypress.io/runs/4')
expect((ctx.actions.electron.openExternal as SinonStub).lastCall.lastArg).to.contain('http://dummy.cypress.io/runs/4')
})
})
})
@@ -660,22 +660,22 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.visitApp()
moveToRunsPage()
cy.get('[href="http://dummy.cypress.io/runs/0"]').first().within(() => {
cy.get('[href^="http://dummy.cypress.io/runs/0"]').first().within(() => {
cy.findByText('fix: make gql work CANCELLED')
cy.get('[data-cy="run-card-icon-CANCELLED"]')
})
cy.get('[href="http://dummy.cypress.io/runs/1"]').first().within(() => {
cy.get('[href^="http://dummy.cypress.io/runs/1"]').first().within(() => {
cy.findByText('fix: make gql work ERRORED')
cy.get('[data-cy="run-card-icon-ERRORED"]')
})
cy.get('[href="http://dummy.cypress.io/runs/2"]').first().within(() => {
cy.get('[href^="http://dummy.cypress.io/runs/2"]').first().within(() => {
cy.findByText('fix: make gql work FAILED')
cy.get('[data-cy="run-card-icon-FAILED"]')
})
cy.get('[href="http://dummy.cypress.io/runs/0"]').first().as('firstRun')
cy.get('[href^="http://dummy.cypress.io/runs/0"]').first().as('firstRun')
cy.get('@firstRun').within(() => {
cy.get('[data-cy="run-card-author"]').contains('John Appleseed')
@@ -699,7 +699,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.get('[data-cy^="runCard-"]').first().click()
cy.withCtx((ctx) => {
expect((ctx.actions.electron.openExternal as SinonStub).lastCall.lastArg).to.eq('http://dummy.cypress.io/runs/0')
expect((ctx.actions.electron.openExternal as SinonStub).lastCall.lastArg).to.contain('http://dummy.cypress.io/runs/0')
})
})

View File

@@ -13,9 +13,9 @@ export {}
* To work around this, we build the driver, eventManager
* and some other dependencies using webpack, and consumed the dist'd
* source code.
*
*
* This is attached to `window` under the `UnifiedRunner` namespace.
*
*
* For now, just declare the types that we need to give us type safety where possible.
* Eventually, we should decouple the event manager and import it directly.
*/
@@ -37,7 +37,7 @@ declare global {
* We get a reference to the copy of React (and React DOM)
* that is used in the Reporter and Driver, which are bundled with
* webpack.
*
*
* Unfortunately, attempting to have React in a project
* using Vue causes mad conflicts because React'S JSX type
* is ambient, so we cannot actually type it.
@@ -54,7 +54,7 @@ declare global {
* Any React components or general code needed from
* runner, reporter or driver are also bundled with
* webpack and made available via the window.UnifedRunner namespace.
*
*
* We cannot import the correct types, because this causes the linter and type
* checker to run on runner and reporter, and it blows up.
*/
@@ -64,4 +64,4 @@ declare global {
}
}
}
}
}

View File

@@ -29,6 +29,7 @@
"@iconify/vue": "3.0.0-beta.1",
"@intlify/vite-plugin-vue-i18n": "2.4.0",
"@packages/frontend-shared": "0.0.0-development",
"@packages/telemetry": "0.0.0-development",
"@percy/cypress": "^3.1.0",
"@popperjs/core": "2.11.6",
"@testing-library/cypress": "9.0.0",

View File

@@ -1,6 +1,21 @@
<template>
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4Z" fill="white"/>
<path d="M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4M10 4C10 2.34315 8.65685 1 7 1C5.34315 1 4 2.34315 4 4M10 4H13M4 4H1" stroke="#747994" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
width="14"
height="8"
viewBox="0 0 14 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4Z"
fill="white"
/>
<path
d="M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4M10 4C10 2.34315 8.65685 1 7 1C5.34315 1 4 2.34315 4 4M10 4H13M4 4H1"
stroke="#747994"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</template>

View File

@@ -1,5 +1,17 @@
<template>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4.5L3.5 7L7 1" stroke="#4956E3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<svg
width="8"
height="8"
viewBox="0 0 8 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 4.5L3.5 7L7 1"
stroke="#4956E3"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</template>

View File

@@ -163,7 +163,10 @@ describe('<DebugRunNavigation />', () => {
})
it('renders correctly in several sizes', () => {
//pausing time to prevent Percy flake
cy.clock(new Date())
cy.get('[data-cy="debug-toggle"]').click()
cy.tick(2 * 1000) //allow toggle to animate
cy.viewport(616, 850) //currently the narrowest the parent component will go
cy.percySnapshot('narrowest')

View File

@@ -11,15 +11,23 @@ import { createPinia } from './store'
import Toast, { POSITION } from 'vue-toastification'
import 'vue-toastification/dist/index.css'
import { createWebsocket, getRunnerConfigFromWindow } from './runner'
import { telemetry } from '@packages/telemetry/src/browser'
// Grab the time just before loading config to include that in the cypress:app span
const now = performance.now()
const config = getRunnerConfigFromWindow()
telemetry.init({ namespace: 'cypress:app', config })
telemetry.startSpan({ name: 'cypress:app', attachType: 'root', opts: { startTime: now } })
const app = createApp(App)
const config = getRunnerConfigFromWindow()
const ws = createWebsocket(config)
window.ws = ws
telemetry.attachWebSocket(ws)
// This injects the Cypress driver and Reporter, which are bundled with Webpack.
// No need to wait for it to finish - it's resolved async with a deferred promise,
// So it'll be ready when we need to run a spec. If not, we will wait for it.

View File

@@ -13,6 +13,8 @@ import { useScreenshotStore } from '../store/screenshot-store'
import { useStudioStore } from '../store/studio-store'
import { getAutIframeModel } from '.'
import { handlePausing } from './events/pausing'
import { addTelemetryListeners } from './events/telemetry'
import { telemetry } from '@packages/telemetry/src/browser'
export type CypressInCypressMochaEvent = Array<Array<string | Record<string, any>>>
@@ -129,6 +131,12 @@ export class EventManager {
window.location.href = url
})
this.ws.on('update:telemetry:context', (contextString) => {
const context = JSON.parse(contextString)
telemetry.setRootContext(context)
})
this.ws.on('automation:push:message', (msg, data = {}) => {
if (!Cypress) return
@@ -346,6 +354,7 @@ export class EventManager {
// that Cypress knows not to set any more
// cookies
$window.on('beforeunload', () => {
telemetry.getSpan('cypress:app')?.end()
this.reporterBus.emit('reporter:restart:test:run')
this._clearAllCookies()
@@ -452,6 +461,8 @@ export class EventManager {
}
_addListeners () {
addTelemetryListeners(Cypress)
Cypress.on('message', (msg, data, cb) => {
this.ws.emit('client:request', msg, data, cb)
})

View File

@@ -0,0 +1,39 @@
import { telemetry } from '@packages/telemetry/src/browser'
export const addTelemetryListeners = (Cypress) => {
Cypress.on('test:before:run', (attributes, test) => {
// we emit the 'test:before:run' events within various driver tests
try {
// If a span for a previous test hasn't been ended, end it before starting the new test span
const previousTestSpan = telemetry.findActiveSpan((span) => {
return span.name.startsWith('test:')
})
if (previousTestSpan) {
telemetry.endActiveSpanAndChildren(previousTestSpan)
}
const span = telemetry.startSpan({ name: `test:${test.fullTitle()}`, active: true })
span?.setAttributes({
currentRetry: attributes.currentRetry,
})
} catch (error) {
// TODO: log error when client side debug logging is available
}
})
Cypress.on('test:after:run', (attributes, test) => {
try {
const span = telemetry.getSpan(`test:${test.fullTitle()}`)
span?.setAttributes({
timings: JSON.stringify(attributes.timings),
})
span?.end()
} catch (error) {
// TODO: log error when client side debug logging is available
}
})
}

View File

@@ -1,15 +1,19 @@
<template>
<ExternalLink
:data-cy="`runCard-${run.id}`"
class="border rounded bg-light-50 border-gray-100 w-full
block overflow-hidden hocus-default"
:href="run.url || '#'"
class="border rounded bg-light-50 border-gray-100 w-full block overflow-hidden hocus-default"
:href="runUrl"
:use-default-hocus="false"
>
<ListRowHeader
:icon="icon"
:data-cy="`run-card-icon-${run.status}`"
>
<template #icon>
<SolidStatusIcon
size="24"
:status="STATUS_MAP[run.status!]"
/>
</template>
<template #header>
<span class="font-semibold mr-8px whitespace-pre-wrap">{{ run.commitInfo?.summary }}</span>
<span
@@ -55,10 +59,8 @@
</li>
</ul>
</template>
<template #right>
<RunResults
:gql="props.gql"
/>
<template #middle>
<RunResults :gql="props.gql" />
</template>
</ListRowHeader>
</ExternalLink>
@@ -70,14 +72,11 @@ import ListRowHeader from '@cy/components/ListRowHeader.vue'
import ExternalLink from '@cy/gql-components/ExternalLink.vue'
import { gql } from '@urql/core'
import RunResults from './RunResults.vue'
import type { RunCardFragment } from '../generated/graphql'
import PassedIcon from '~icons/cy/status-passed-solid_x24.svg'
import FailedIcon from '~icons/cy/status-failed-solid_x24.svg'
import ErroredIcon from '~icons/cy/status-errored-solid_x24.svg'
import SkippedIcon from '~icons/cy/status-skipped_x24.svg'
import PendingIcon from '~icons/cy/status-pending_x24.svg'
import type { CloudRunStatus, RunCardFragment } from '../generated/graphql'
import { dayjs } from './utils/day.js'
import { useDurationFormat } from '../composables/useDurationFormat'
import { SolidStatusIcon, StatusType } from '@cypress-design/vue-statusicon'
import { getUrlWithParams } from '@packages/frontend-shared/src/utils/getUrlWithParams'
gql`
fragment RunCard on CloudRun {
@@ -100,25 +99,33 @@ fragment RunCard on CloudRun {
}
`
const STATUS_MAP: Record<CloudRunStatus, StatusType> = {
PASSED: 'passed',
FAILED: 'failed',
CANCELLED: 'cancelled',
ERRORED: 'errored',
RUNNING: 'running',
NOTESTS: 'noTests',
OVERLIMIT: 'overLimit',
TIMEDOUT: 'timedOut',
} as const
const props = defineProps<{
gql: RunCardFragment
}>()
const ICON_MAP = {
PASSED: PassedIcon,
FAILED: FailedIcon,
TIMEDOUT: ErroredIcon,
ERRORED: ErroredIcon,
OVERLIMIT: ErroredIcon,
CANCELLED: SkippedIcon,
NOTESTS: SkippedIcon,
RUNNING: PendingIcon,
} as const
const icon = computed(() => ICON_MAP[props.gql.status!])
const run = computed(() => props.gql)
const runUrl = computed(() => {
return getUrlWithParams({
url: run.value.url || '#',
params: {
utm_medium: 'Runs Tab',
utm_campaign: 'Cloud Run',
},
})
})
const relativeCreatedAt = computed(() => dayjs(new Date(run.value.createdAt!)).fromNow())
const totalDuration = useDurationFormat(run.value.totalDuration ?? 0)

View File

@@ -19,6 +19,7 @@
"@babel/generator": "7.17.9",
"@babel/parser": "7.13.0",
"@graphql-tools/batch-execute": "^8.4.6",
"@packages/telemetry": "0.0.0-development",
"@urql/core": "2.4.4",
"@urql/exchange-execute": "1.1.0",
"@urql/exchange-graphcache": "4.3.6",

View File

@@ -105,6 +105,14 @@ abstract class DataEmitterEvents {
this._emit('relevantRunSpecChange', run)
}
/**
* Emitted when there is a change to the auto-detected framework/bundler
* for a CT project
*/
frameworkDetectionChange () {
this._emit('frameworkDetectionChange')
}
/**
* When we want to update the cache with known values from the server, without
* triggering a full refresh, we can send down a specific fragment / data to update

View File

@@ -116,6 +116,8 @@ export class WizardActions {
coreData.wizard.detectedBundler = this.getNullableBundler(detected.bundler || detected.framework.supportedBundlers[0])
coreData.wizard.chosenBundler = this.getNullableBundler(detected.bundler || detected.framework.supportedBundlers[0])
})
this.ctx.emitter.frameworkDetectionChange()
}
}
@@ -167,11 +169,14 @@ export class WizardActions {
}
const officialFrameworks = CT_FRAMEWORKS.map((framework) => resolveComponentFrameworkDefinition(framework))
const thirdParty = await detectThirdPartyCTFrameworks(this.ctx.currentProject)
const { frameworks: thirdParty, erroredFrameworks } = await detectThirdPartyCTFrameworks(this.ctx.currentProject)
const resolvedThirdPartyFrameworks = thirdParty.map(resolveComponentFrameworkDefinition)
debug('errored third party frameworks %o', erroredFrameworks)
this.ctx.update((d) => {
d.wizard.frameworks = officialFrameworks.concat(resolvedThirdPartyFrameworks)
d.wizard.erroredFrameworks = erroredFrameworks
})
}

View File

@@ -11,6 +11,8 @@ import { autoBindDebug, hasTypeScriptInstalled, toPosix } from '../util'
import _ from 'lodash'
import { pathToFileURL } from 'url'
import os from 'os'
import type { OTLPTraceExporterCloud } from '@packages/telemetry'
import { telemetry, encodeTelemetryContext } from '@packages/telemetry'
const pkg = require('@packages/root')
const debug = debugLib(`cypress:lifecycle:ProjectConfigIpc`)
@@ -76,6 +78,14 @@ export class ProjectConfigIpc extends EventEmitter {
this.emit('disconnect')
})
// This forwards telemetry requests from the child process to the server
this.on('export:telemetry', (data) => {
// Not too worried about tracking successes
(telemetry.exporter() as OTLPTraceExporterCloud)?.send(data, () => {}, (err) => {
debug('error exporting telemetry data from child process %s', err)
})
})
return autoBindDebug(this)
}
@@ -87,6 +97,7 @@ export class ProjectConfigIpc extends EventEmitter {
send(event: 'execute:plugins', evt: string, ids: {eventId: string, invocationId: string}, args: any[]): boolean
send(event: 'setupTestingType', testingType: TestingType, options: Cypress.PluginConfigOptions): boolean
send(event: 'loadConfig'): boolean
send(event: 'main:process:will:disconnect'): void
send (event: string, ...args: any[]) {
if (this._childProcess.killed || !this._childProcess.connected) {
return false
@@ -96,7 +107,8 @@ export class ProjectConfigIpc extends EventEmitter {
}
on(evt: 'childProcess:unhandledError', listener: (err: CypressError) => void): this
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: string, listener: (...args: any[]) => void) {
return super.on(evt, listener)
@@ -319,6 +331,11 @@ export class ProjectConfigIpc extends EventEmitter {
debug(`no typescript found, just use regular Node.js`)
}
const telemetryCtx = encodeTelemetryContext({ context: telemetry.getActiveContextObject(), version: pkg.version })
// Pass the active context from the main process to the child process as the --telemetryCtx flag.
configProcessArgs.push('--telemetryCtx', telemetryCtx)
if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF_PARENT_PROJECT) {
if (isSandboxNeeded()) {
configProcessArgs.push('--no-sandbox')

View File

@@ -19,6 +19,8 @@ import { autoBindDebug } from '../util/autoBindDebug'
import type { EventRegistrar } from './EventRegistrar'
import type { DataContext } from '../DataContext'
import { isDependencyInstalled, WIZARD_BUNDLERS } from '@packages/scaffold-config'
import type { OTLPTraceExporterCloud } from '@packages/telemetry'
import { telemetry } from '@packages/telemetry'
const debug = debugLib(`cypress:lifecycle:ProjectConfigManager`)
@@ -248,13 +250,17 @@ export class ProjectConfigManager {
}
private async setupNodeEvents (loadConfigReply: LoadConfigReply): Promise<void> {
const nodeEventsSpan = telemetry.startSpan({ name: 'dataContext:setupNodeEvents' })
assert(this._eventsIpc, 'Expected _eventsIpc to be defined at this point')
this._state = 'loadingNodeEvents'
try {
assert(this._testingType, 'Cannot setup node events without a testing type')
this._registeredEventsTarget = this._testingType
const config = await this.getFullInitialConfig()
const config = await this.getFullInitialConfig();
(telemetry.exporter() as OTLPTraceExporterCloud)?.attachProjectId(config.projectId)
const setupNodeEventsReply = await this._eventsIpc.callSetupNodeEventsWithConfig(this._testingType, config, this.options.handlers)
@@ -271,6 +277,7 @@ export class ProjectConfigManager {
throw error
} finally {
nodeEventsSpan?.end()
this.options.ctx.emitter.toLaunchpad()
this.options.ctx.emitter.toApp()
}
@@ -606,6 +613,34 @@ export class ProjectConfigManager {
return true
}
/**
* Informs the child process if the main process will soon disconnect.
* @returns promise
*/
mainProcessWillDisconnect (): Promise<void> {
return new Promise((resolve, reject) => {
if (!this._eventsIpc) {
debug(`mainProcessWillDisconnect message not set, no IPC available`)
reject()
return
}
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()
}, 5000)
this._eventsIpc.on('main:process:will:disconnect:ack', () => {
clearTimeout(timeoutId)
resolve()
})
})
}
private async closeWatchers () {
await Promise.all(Array.from(this._watchers).map((watcher) => {
return watcher.close().catch((error) => {

View File

@@ -24,6 +24,7 @@ import { getServerPluginHandlers, resetPluginHandlers } from '../util/pluginHand
import { detectLanguage } from '@packages/scaffold-config'
import { validateNeedToRestartOnChange } from '@packages/config'
import { MAJOR_VERSION_FOR_CONTENT } from '@packages/types'
import { telemetry } from '@packages/telemetry'
export interface SetupFullConfigOptions {
projectName: string
@@ -235,8 +236,12 @@ export class ProjectLifecycleManager {
}
if (this._currentTestingType === 'component') {
const span = telemetry.startSpan({ name: 'dataContext:ct:startDevServer' })
const devServerOptions = await this.ctx._apis.projectApi.getDevServer().start({ specs: this.ctx.project.specs, config: finalConfig })
span?.end()
if (!devServerOptions?.port) {
throw getError('CONFIG_FILE_DEV_SERVER_INVALID_RETURN', devServerOptions)
}
@@ -373,12 +378,18 @@ export class ProjectLifecycleManager {
}
if (this._configManager?.isLoadingConfigFile) {
const span = telemetry.startSpan({ name: `dataContext:loadConfig` })
try {
await this.initializeConfig()
return true
} catch (error) {
this.ctx.debug('error thrown by initializeConfig', error)
return false
} finally {
span?.end()
}
}
@@ -784,6 +795,10 @@ export class ProjectLifecycleManager {
return this.ctx.onError(getError('NO_DEFAULT_CONFIG_FILE_FOUND', this.projectRoot))
}
const span = telemetry.startSpan({ name: 'dataContext:setAndLoadCurrentTestingType' })
span?.setAttributes({ testingType: testingType ? testingType : 'undefined' })
if (testingType) {
this.setAndLoadCurrentTestingType(testingType)
} else {
@@ -792,6 +807,7 @@ export class ProjectLifecycleManager {
}
return this._pendingInitialize.promise.finally(() => {
telemetry.getSpan('dataContext:setAndLoadCurrentTestingType')?.end()
this._pendingInitialize = undefined
})
}
@@ -828,4 +844,12 @@ export class ProjectLifecycleManager {
this.ctx.onError(err, 'Cypress configuration error')
}
}
mainProcessWillDisconnect (): Promise<void> {
if (!this._configManager) {
return Promise.resolve()
}
return this._configManager.mainProcessWillDisconnect()
}
}

View File

@@ -1,5 +1,5 @@
import { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName, MIGRATION_STEPS, MigrationStep, BannerState } from '@packages/types'
import { WizardBundler, CT_FRAMEWORKS, resolveComponentFrameworkDefinition } from '@packages/scaffold-config'
import { WizardBundler, CT_FRAMEWORKS, resolveComponentFrameworkDefinition, ErroredFramework } from '@packages/scaffold-config'
import type { NexusGenObjects } from '@packages/graphql/src/gen/nxs.gen'
import type { App, BrowserWindow } from 'electron'
import type { ChildProcess } from 'child_process'
@@ -71,6 +71,7 @@ export interface WizardDataShape {
detectedBundler: WizardBundler | null
detectedFramework: Cypress.ResolvedComponentFrameworkDefinition | null
frameworks: Cypress.ResolvedComponentFrameworkDefinition[]
erroredFrameworks: ErroredFramework[]
}
export interface MigrationDataShape {
@@ -200,6 +201,7 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
detectedFramework: null,
// TODO: API to add third party frameworks to this list.
frameworks: CT_FRAMEWORKS.map((framework) => resolveComponentFrameworkDefinition(framework)),
erroredFrameworks: [],
},
migration: {
step: 'renameAuto',

View File

@@ -17,14 +17,13 @@ export type {
export * from './util/pluginHandlers'
import { globalPubSub } from './globalPubSub'
export { globalPubSub }
export { globalPubSub } from './globalPubSub'
let ctx: DataContext | null = null
export async function clearCtx () {
if (ctx) {
await ctx.lifecycleManager.mainProcessWillDisconnect()
await ctx.destroy()
ctx = null
}
@@ -43,7 +42,7 @@ export function hasCtx () {
export function getCtx () {
if (!ctx) {
throw new Error(`
Expected DataContext to already have been set via setCtx. If this is a
Expected DataContext to already have been set via setCtx. If this is a
testing context, make sure you are calling "setCtx" in a before hook,
otherwise check the application flow.
`)
@@ -59,8 +58,8 @@ export function getCtx () {
export function setCtx (_ctx: DataContext) {
if (ctx) {
throw new Error(`
The context has already been set. If this is occurring in a testing context,
make sure you are clearing the context. Otherwise
The context has already been set. If this is occurring in a testing context,
make sure you are clearing the context. Otherwise
`)
}

View File

@@ -6,6 +6,7 @@
import type { DataContext } from '../DataContext'
import { getPathToDist, resolveFromPackages } from '@packages/resolve-dist'
import _ from 'lodash'
import { telemetry } from '@packages/telemetry'
const PATH_TO_NON_PROXIED_ERROR = resolveFromPackages('server', 'lib', 'html', 'non_proxied_error.html')
@@ -119,6 +120,7 @@ export class HtmlDataSource {
window.__CYPRESS_CONFIG__ = ${JSON.stringify(serveConfig)};
window.__CYPRESS_TESTING_TYPE__ = '${this.ctx.coreData.currentTestingType}'
window.__CYPRESS_BROWSER__ = ${JSON.stringify(this.ctx.coreData.activeBrowser)}
${telemetry.isEnabled() ? `window.__CYPRESS_TELEMETRY__ = ${JSON.stringify({ context: telemetry.getActiveContextObject(), resources: telemetry.getResources() })}` : ''}
${process.env.CYPRESS_INTERNAL_GQL_NO_SOCKET ? `window.__CYPRESS_GQL_NO_SOCKET__ = 'true';` : ''}
</script>
`)

View File

@@ -93,7 +93,8 @@ describe('src/cy/commands/actions/type - #type events', () => {
cy.get(':text:first').type('a')
})
it('receives textInput event', (done) => {
// TODO fix this test in Webkit https://github.com/cypress-io/cypress/issues/26438
it('receives textInput event', { browser: '!webkit' }, (done) => {
const $txt = cy.$$(':text:first')
$txt[0].addEventListener('textInput', (e) => {
@@ -593,7 +594,8 @@ describe('src/cy/commands/actions/type - #type events', () => {
]
targets.forEach((target) => {
it(target, () => {
// TODO fix this test in Webkit https://github.com/cypress-io/cypress/issues/26438
it(target, { browser: '!webkit' }, () => {
cy.get(`#target-${target}`).focus().type('{enter}')
cy.get('li').should('have.length', 4)
@@ -606,7 +608,8 @@ describe('src/cy/commands/actions/type - #type events', () => {
describe('keydown triggered on another element', () => {
targets.forEach((target) => {
it(target, () => {
// TODO fix this test in Webkit https://github.com/cypress-io/cypress/issues/26438
it(target, { browser: '!webkit' }, () => {
cy.get('#focus-options').select(target)
cy.get('#input-text').focus().type('{enter}')
@@ -649,6 +652,19 @@ describe('src/cy/commands/actions/type - #type events', () => {
})
})
})
describe('shadow dom', () => {
// https://github.com/cypress-io/cypress/issues/26392
it('propagates through shadow roots', () => {
cy.visit('fixtures/shadow-dom-button.html')
cy.get('cy-test-element').invoke('on', 'click', cy.spy().as('clickSpy'))
cy.get('cy-test-element').shadow().find('button').focus().type('{enter}')
cy.get('@clickSpy').should('have.been.called')
})
})
})
describe(`type(' ') fires click event on button-like elements`, () => {

View File

@@ -290,10 +290,6 @@ describe('src/cy/commands/screenshot', () => {
})
it('sets name to undefined when not passed name', function () {
const runnable = cy.state('runnable')
runnable.title = 'foo bar'
Cypress.automation.withArgs('take:screenshot').resolves(this.serverResult)
cy.screenshot().then(() => {
@@ -302,10 +298,6 @@ describe('src/cy/commands/screenshot', () => {
})
it('can pass name', function () {
const runnable = cy.state('runnable')
runnable.title = 'foo bar'
Cypress.automation.withArgs('take:screenshot').resolves(this.serverResult)
cy.screenshot('my/file').then(() => {

View File

@@ -25,6 +25,7 @@
"@packages/runner": "0.0.0-development",
"@packages/server": "0.0.0-development",
"@packages/socket": "0.0.0-development",
"@packages/telemetry": "0.0.0-development",
"@packages/ts": "0.0.0-development",
"@sinonjs/fake-timers": "8.1.0",
"@types/chalk": "^2.2.0",

View File

@@ -294,7 +294,7 @@ export default function (Commands, Cypress, cy, state, config) {
const fireClickEvent = (el) => {
const ctor = $dom.getDocumentFromElement(el).defaultView!.PointerEvent
const event = new ctor('click')
const event = new ctor('click', { composed: true })
el.dispatchEvent(event)
}

View File

@@ -46,6 +46,8 @@ import { setupAutEventHandlers } from './cypress/aut_event_handlers'
import type { CachedTestState } from '@packages/types'
import * as cors from '@packages/network/lib/cors'
import { telemetry } from '@packages/telemetry/src/browser'
const debug = debugFn('cypress:driver:cypress')
declare global {
@@ -410,7 +412,10 @@ class $Cypress {
case 'runner:end':
$sourceMapUtils.destroySourceMapConsumers()
telemetry.getSpan('cypress:app')?.end()
// mocha runner has finished running the tests
// TODO: it would be nice to await this emit before preceding.
this.emit('run:end')
this.maybeEmitCypressInCypress('mocha', 'end', args[0])
@@ -430,7 +435,6 @@ class $Cypress {
}
break
case 'runner:suite:end':
// mocha runner finished processing a suite
this.maybeEmitCypressInCypress('mocha', 'suite end', ...args)
@@ -440,7 +444,6 @@ class $Cypress {
}
break
case 'runner:hook:start':
// mocha runner started processing a hook

View File

@@ -4,5 +4,10 @@ import './config/bluebird'
import './config/jquery'
import './config/lodash'
import $Cypress from './cypress'
import { telemetry } from '@packages/telemetry/src/browser'
// Telemetry has already been initialized in the 'app' package
// but since this is a different package we have to link up the instances.
telemetry.attach()
export default $Cypress

View File

@@ -91,6 +91,7 @@ interface SpecWindow extends Window {
interface CypressRunnable extends Mocha.Runnable {
type: null | 'hook' | 'suite' | 'test'
hookId: any
hookName: string
id: any
err: any
}

View File

@@ -63,4 +63,5 @@ export const stubWizard: MaybeResolver<Wizard> = {
isDetected: false,
}
}),
erroredFrameworks: [],
}

View File

@@ -1,6 +1,7 @@
import ListRowHeader from './ListRowHeader.vue'
import faker from 'faker'
import FileChangesAdded from '~icons/cy/file-changes-added_x24.svg'
import { IconFileChangesAdded, IconActionAdd } from '@cypress-design/vue-icon'
import Button from '@cy/components/Button.vue'
faker.seed(1)
@@ -9,17 +10,21 @@ const header = faker.system.directoryPath()
const iconSelector = '[data-testid=file-added-icon]'
const listRowSelector = '[data-testid=list-row-header]'
const descriptionSelector = '[data-testid=list-row-description]'
const middleSlotSelector = '[data-testid=list-row-middle]'
const rightSlotSelector = '[data-testid=list-row-right]'
describe('<ListRowHeader />', () => {
it('renders the icon slot', () => {
it('renders all supported slots', () => {
cy.mount(() => (
<div class="text-center p-4">
<ListRowHeader
// @ts-ignore - doesn't know about vSlots
vSlots={{
icon: () => <FileChangesAdded data-testid="file-added-icon" />,
icon: () => <IconFileChangesAdded data-testid="file-added-icon" />,
description: () => <p data-testid="list-row-description">{ description }</p>,
header: () => <>{ header }</>,
middle: () => <span data-testid="list-row-middle"><IconActionAdd />Add</span>,
right: () => <Button data-testid="list-row-right">Act</Button>,
}}
/>
</div>
@@ -27,13 +32,17 @@ describe('<ListRowHeader />', () => {
.should('be.visible')
.get(descriptionSelector)
.should('contain.text', description)
.get(middleSlotSelector)
.should('contain.text', 'Add')
.get(rightSlotSelector)
.should('contain.text', 'Act')
})
it('renders a minimal example with an icon and description', () => {
cy.mount(() => (
<div class="text-center p-4" data-testid="list-row-header">
<ListRowHeader
icon={() => <FileChangesAdded data-testid="file-added-icon"/>}
icon={() => <IconFileChangesAdded data-testid="file-added-icon"/>}
description={description}
// @ts-ignore - doesn't know about vSlots
vSlots={{

View File

@@ -10,19 +10,28 @@
/>
</slot>
</div>
<div class="flex-grow h-auto border-gray-100 border-l-1px px-16px">
<h2
class="text-indigo-500 whitespace-nowrap"
:class="{'text-size-18px leading-24px': bigHeader}"
<div class="flex flex-row flex-grow flex-wrap h-auto border-gray-100 border-l-1px px-16px gap-4px justify-between">
<div>
<h2
class="text-indigo-500 whitespace-nowrap"
:class="{'text-size-18px leading-24px': bigHeader}"
>
<slot name="header" />
</h2>
<p class="font-normal text-sm text-gray-700 select-none">
<slot name="description">
<span>{{ description }}</span>
</slot>
</p>
</div>
<div
v-if="slots.middle"
class="flex items-center"
>
<slot name="header" />
</h2>
<p class="font-normal text-sm text-gray-700 select-none">
<slot name="description">
<span>{{ description }}</span>
</slot>
</p>
<slot name="middle" />
</div>
</div>
<div
v-if="slots.right"
class="flex px-16px items-center"

View File

@@ -357,7 +357,11 @@
"languageLabel": "Language",
"configFileLanguageLabel": "Cypress config file",
"detected": "(detected)",
"browseIntegrations": "Browse our list of third-party framework integrations"
"browseIntegrations": "Browse our list of third-party framework integrations",
"communityFrameworkDefinitionProblem": "Community framework definition problem",
"communityDependenciesCouldNotBeParsed": "This project has a community framework definition installed that could not be loaded. It is located at the following path: | This project has some community framework definitions installed that could not be loaded. They are located at the following paths:",
"seeFrameworkDefinitionDocumentation": "See the {0} for more information about creating, installing, and troubleshooting third party definitions.",
"frameworkDefinitionDocumentation": "framework definition documentation"
},
"step": {
"continue": "Continue",

View File

@@ -2251,6 +2251,11 @@ type Subscription {
"""Triggered when the base error or warning state changes"""
errorWarningChange: Query
"""
Triggered when there is a change to the automatically-detected framework/bundler for a CT project
"""
frameworkDetectionChange: Wizard
"""
When the git info has refreshed for some or all of the specs, we fire this event with the specs updated
"""
@@ -2352,6 +2357,11 @@ type Wizard {
"""All of the bundlers to choose from"""
allBundlers: [WizardBundler!]!
bundler: WizardBundler
"""
Framework definitions that had a package.json detected but could not be loaded due to an error
"""
erroredFrameworks: [WizardErroredFramework!]!
framework: WizardFrontendFramework
"""All of the component testing frameworks to choose from"""
@@ -2388,6 +2398,15 @@ enum WizardConfigFileStatusEnum {
valid
}
"""Represents a Framework Definition that failed to load when detected"""
type WizardErroredFramework implements Node {
"""Relay style Node ID field for the WizardErroredFramework field"""
id: ID!
"""The location of the framework's package.json file"""
path: String
}
"""A frontend framework that we can setup within the app"""
type WizardFrontendFramework implements Node {
"""The category (framework, like react-scripts, or library, like react"""

View File

@@ -1,6 +1,6 @@
import type { PushFragmentData } from '@packages/data-context/src/actions'
import { enumType, idArg, list, nonNull, objectType, stringArg, subscriptionType } from 'nexus'
import { CurrentProject, DevState, Query } from '.'
import { CurrentProject, DevState, Query, Wizard } from '.'
import { Spec } from './gql-Spec'
import { RelevantRun } from './gql-RelevantRun'
@@ -156,5 +156,12 @@ export const Subscription = subscriptionType({
return root
},
})
t.field('frameworkDetectionChange', {
type: Wizard,
description: 'Triggered when there is a change to the automatically-detected framework/bundler for a CT project',
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('frameworkDetectionChange', { sendInitial: false }),
resolve: (source, args, ctx) => ctx.wizardData,
})
},
})

View File

@@ -3,6 +3,7 @@ import { WizardFrontendFramework } from './gql-WizardFrontendFramework'
import { WizardNpmPackage } from './gql-WizardNpmPackage'
import { objectType } from 'nexus'
import { WIZARD_BUNDLERS } from '@packages/scaffold-config'
import { WizardErroredFramework } from './gql-WizardErroredFramework'
export const Wizard = objectType({
name: 'Wizard',
@@ -30,6 +31,12 @@ export const Wizard = objectType({
resolve: (source, args, ctx) => Array.from(ctx.coreData.wizard.frameworks),
})
t.nonNull.list.nonNull.field('erroredFrameworks', {
type: WizardErroredFramework,
description: 'Framework definitions that had a package.json detected but could not be loaded due to an error',
resolve: (source, args, ctx) => Array.from(ctx.coreData.wizard.erroredFrameworks),
})
t.nonNull.list.nonNull.field('packagesToInstall', {
type: WizardNpmPackage,
description: 'A list of packages to install, null if we have not chosen both a framework and bundler',

View File

@@ -0,0 +1,12 @@
import { objectType } from 'nexus'
export const WizardErroredFramework = objectType({
name: 'WizardErroredFramework',
description: 'Represents a Framework Definition that failed to load when detected',
node: 'path',
definition (t) {
t.string('path', {
description: `The location of the framework's package.json file`,
})
},
})

View File

@@ -32,5 +32,6 @@ export * from './gql-Version'
export * from './gql-VersionData'
export * from './gql-Wizard'
export * from './gql-WizardBundler'
export * from './gql-WizardErroredFramework'
export * from './gql-WizardFrontendFramework'
export * from './gql-WizardNpmPackage'

View File

@@ -112,24 +112,51 @@ describe('Launchpad: Open Mode', () => {
describe('when launched with --component and not configured', () => {
beforeEach(() => {
cy.scaffoldProject('react-vite-ts-unconfigured')
cy.openProject('react-vite-ts-unconfigured', ['--component'])
cy.visitLaunchpad()
cy.skipWelcome()
})
it('goes to component test onboarding', () => {
cy.openProject('react-vite-ts-unconfigured', ['--component'])
cy.visitLaunchpad()
cy.skipWelcome()
cy.get('[data-cy=header-bar-content]').contains('component testing', { matchCase: false })
// Component testing is not configured for the todo project
cy.get('h1').should('contain', 'Project setup')
})
it('detects CT project framework', () => {
cy.get('[data-testid="select-framework"]').within(() => {
cy.withCtx(async (ctx, o) => {
// Mock wizard initialization taking a long time by replacing
// implementation with no-op and proceeding
o.sinon.stub(ctx.actions.wizard, 'initialize').resolves()
})
cy.openProject('react-vite-ts-unconfigured', ['--component'])
cy.visitLaunchpad()
cy.skipWelcome()
cy.get('[data-testid="select-framework"]').as('framework')
// Validate that UI presents an "empty" state since auto-detection did not fire
cy.get('@framework').within(() => {
cy.contains('Pick a framework', { timeout: 100 }).should('be.visible')
})
cy.withCtx(async (ctx, o) => {
// Trigger actual wizard initialization to occur
(ctx.actions.wizard.initialize as SinonStub).wrappedMethod.apply(ctx.actions.wizard)
})
// Verify that auto-detection has fired via the real initialize call and updated data
// has flowed through to populate UI
cy.get('[data-testid="select-bundler"]').as('bundler')
cy.get('@framework').within(() => {
cy.contains('React.js').should('be.visible')
cy.contains('(detected)').should('be.visible')
})
cy.get('[data-testid="select-bundler"]').within(() => {
cy.get('@bundler').within(() => {
cy.contains('Vite').should('be.visible')
cy.contains('(detected)').should('be.visible')
})

View File

@@ -582,6 +582,18 @@ describe('Launchpad: Setup Project', () => {
cy.findByDisplayValue('pnpm install -D react-scripts react-dom react')
})
it('works with Yarn 3 Plug n Play', () => {
scaffoldAndOpenProject('yarn-v3.1.1-pnp')
cy.visitLaunchpad()
cy.get('[data-cy-testingtype="component"]').click()
cy.get('button').should('be.visible').contains('Vue.js 3(detected)')
cy.get('button').should('be.visible').contains('Vite(detected)')
cy.findByText('Next step').click()
cy.findByTestId('alert').contains(`You've successfully installed all required dependencies.`)
})
it('makes the right command for npm', () => {
scaffoldAndOpenProject('pristine-npm')

View File

@@ -252,5 +252,34 @@ describe('scaffolding component testing', {
cy.get(`[data-testid="select-framework"]`).click()
cy.contains('Qwik').should('be.visible')
})
it('Displays a warning message for dependencies that could not be parsed', () => {
cy.scaffoldProject('qwik-app')
cy.openProject('qwik-app')
cy.withCtx(async (ctx) => {
await ctx.actions.file.removeFileInProject('./node_modules/cypress-ct-bad-missing-value')
await ctx.actions.file.moveFileInProject('./cypress-ct-bad-missing-value', './node_modules/cypress-ct-bad-missing-value')
await ctx.actions.file.removeFileInProject('./node_modules/cypress-ct-bad-syntax')
await ctx.actions.file.moveFileInProject('./cypress-ct-bad-syntax', './node_modules/cypress-ct-bad-syntax')
})
cy.visitLaunchpad()
cy.skipWelcome()
cy.contains('Component Testing').click()
cy.findByTestId('alert-header').should('be.visible').contains('Community framework definition problem')
cy.findByTestId('alert-body').within(() => {
cy.get('li').should('have.length', 2)
cy.contains('cy-projects/qwik-app/node_modules/cypress-ct-bad-missing-value/package.json').should('be.visible')
cy.contains('cy-projects/qwik-app/node_modules/cypress-ct-bad-syntax/package.json').should('be.visible')
})
cy.percySnapshot()
})
})
})

View File

@@ -2,7 +2,7 @@ import { EnvironmentSetupFragmentDoc } from '../generated/graphql-test'
import EnvironmentSetup from './EnvironmentSetup.vue'
describe('<EnvironmentSetup />', { viewportWidth: 800 }, () => {
it('default component', () => {
it('displays framework options and links to community defined frameworks', () => {
cy.mountFragment(EnvironmentSetupFragmentDoc, {
render: (gqlVal) => (
<div class='m-10'>
@@ -84,7 +84,7 @@ describe('<EnvironmentSetup />', { viewportWidth: 800 }, () => {
cy.findByRole('option', { name: 'Create React App (v5) Support is in Alpha (detected)' }).should('be.visible').click()
})
it('shows the description of bundler as Dev Server', () => {
it('shows the description of bundler', () => {
cy.mountFragment(EnvironmentSetupFragmentDoc, {
onResult: (res) => {
res.framework = {
@@ -103,5 +103,84 @@ describe('<EnvironmentSetup />', { viewportWidth: 800 }, () => {
})
cy.findByLabelText('Bundler').should('be.visible')
cy.findByLabelText('Pick a bundler').should('be.visible')
})
it('shows errored community frameworks', () => {
const PATH_1 = '/quite-long/path/to/node_modules/for/definition1/package.json'
const PATH_2 = '/quite-long/path/to/node_modules/for/definition2/package.json'
const PATH_3 = '/quite-long/path/to/node_modules/for/definition3/package.json'
const PLURAL_MESSAGE = 'This project has some community framework definitions installed that could not be loaded. They are located at the following paths:'
const SINGULAR_MESSAGE = 'This project has a community framework definition installed that could not be loaded. It is located at the following path:'
const DOCS_CTA = 'See the framework definition documentation for more information about creating, installing, and troubleshooting third party definitions.'
// we will mount with multiple errored frameworks, and then with a single errored framework
cy.mountFragment(EnvironmentSetupFragmentDoc, {
onResult: (res) => {
res.erroredFrameworks = [
{
id: '1',
path: PATH_1,
__typename: 'WizardErroredFramework',
},
{
id: '2',
path: PATH_2,
__typename: 'WizardErroredFramework',
},
{
id: '3',
path: PATH_3,
__typename: 'WizardErroredFramework',
},
]
},
render: (gqlVal) => (
<div class='m-10'>
<EnvironmentSetup
gql={gqlVal}
nextFn={cy.stub()}
/>
</div>
),
})
cy.contains('h3', 'Community framework definition problem')
cy.contains('p', PLURAL_MESSAGE).should('be.visible')
cy.contains('p', SINGULAR_MESSAGE).should('not.exist')
cy.contains('li', PATH_1).should('be.visible')
cy.contains('li', PATH_2).should('be.visible')
cy.contains('li', PATH_3).should('be.visible')
cy.contains('p', DOCS_CTA).should('be.visible')
cy.contains('a', 'framework definition documentation').should('have.attr', 'href', 'https://on.cypress.io/component-integrations?utm_medium=Framework+Definition+Warning&utm_source=Binary%3A+Launchpad')
cy.mountFragment(EnvironmentSetupFragmentDoc, {
onResult: (res) => {
res.erroredFrameworks = [
{
id: '1',
path: PATH_1,
__typename: 'WizardErroredFramework',
},
]
},
render: (gqlVal) => (
<div class='m-10'>
<EnvironmentSetup
gql={gqlVal}
nextFn={cy.stub()}
/>
</div>
),
})
cy.contains('p', PLURAL_MESSAGE).should('not.exist')
cy.contains('p', SINGULAR_MESSAGE).should('be.visible')
cy.get('li')
.should('have.length', 1)
.contains(PATH_1)
.should('be.visible')
})
})

View File

@@ -1,4 +1,45 @@
<template>
<Alert
v-if="shouldRenderAlert"
v-model="isAlertOpen"
:icon="ErrorOutlineIcon"
class="mx-auto my-24px max-w-640px"
status="warning"
:title="t('setupPage.projectSetup.communityFrameworkDefinitionProblem')"
dismissible
>
<p>
{{ t('setupPage.projectSetup.communityDependenciesCouldNotBeParsed', erroredFrameworks.length) }}
</p>
<ul class="list-disc my-12px ml-36px">
<li
v-for="framework in erroredFrameworks"
:key="framework.path as string"
>
<ExternalLink
data-cy="errored-framework-path"
:href="`file://${framework.path}`"
>
{{ framework.path }}
</ExternalLink>
</li>
</ul>
<i18n-t
tag="p"
keypath="setupPage.projectSetup.seeFrameworkDefinitionDocumentation"
>
<ExternalLink
:href="getUrlWithParams({
url :'https://on.cypress.io/component-integrations',
params: {
utm_medium: 'Framework Definition Warning'
}
})"
>
{{ t('setupPage.projectSetup.frameworkDefinitionDocumentation') }}
</ExternalLink>
</i18n-t>
</Alert>
<WizardLayout
:back-fn="onBack"
:next-fn="props.nextFn"
@@ -6,7 +47,7 @@
class="max-w-640px"
>
<div class="m-24px">
<SelectFwOrBundler
<SelectFrameworkOrBundler
:options="frameworks || []"
:value="props.gql.framework?.type ?? undefined"
:placeholder="t('setupPage.projectSetup.frameworkPlaceholder')"
@@ -15,7 +56,7 @@
data-testid="select-framework"
@select-framework="val => onWizardSetup('framework', val)"
/>
<SelectFwOrBundler
<SelectFrameworkOrBundler
v-if="props.gql.framework?.type && bundlers.length > 1"
class="pt-3px"
:options="bundlers"
@@ -31,19 +72,24 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import WizardLayout from './WizardLayout.vue'
import SelectFwOrBundler from './SelectFwOrBundler.vue'
import SelectFrameworkOrBundler from './SelectFrameworkOrBundler.vue'
import Alert from '@cy/components/Alert.vue'
import { gql } from '@urql/core'
import type { WizardUpdateInput, EnvironmentSetupFragment } from '../generated/graphql'
import {
EnvironmentSetup_ClearTestingTypeDocument,
EnvironmentSetup_WizardUpdateDocument,
EnvironmentSetup_DetectionChangeDocument,
} from '../generated/graphql'
import { useI18n } from '@cy/i18n'
import { useMutation } from '@urql/vue'
import { useMutation, useSubscription } from '@urql/vue'
import type { FrameworkOption } from './types'
import ExternalLink from '@cy/gql-components/ExternalLink.vue'
import { getUrlWithParams } from '@packages/frontend-shared/src/utils/getUrlWithParams'
import ErrorOutlineIcon from '~icons/cy/status-errored-outline_x16.svg'
gql`
fragment EnvironmentSetup on Wizard {
@@ -82,6 +128,18 @@ fragment EnvironmentSetup on Wizard {
type
isDetected
}
erroredFrameworks {
id
path
}
}
`
gql`
subscription EnvironmentSetup_DetectionChange {
frameworkDetectionChange {
...EnvironmentSetup
}
}
`
@@ -115,6 +173,10 @@ const frameworks = computed(() => {
return data
})
const erroredFrameworks = computed(() => {
return props.gql.erroredFrameworks.filter((framework) => framework.path)
})
gql`
mutation EnvironmentSetup_wizardUpdate($input: WizardUpdateInput!) {
wizardUpdate(input: $input) {
@@ -166,4 +228,8 @@ const canNavigateForward = computed(() => {
return bundler !== null && framework !== null
})
useSubscription({ query: EnvironmentSetup_DetectionChangeDocument })
const isAlertOpen = ref(true)
const shouldRenderAlert = computed(() => erroredFrameworks.value.length > 0)
</script>

View File

@@ -1,5 +1,5 @@
import { ref } from 'vue'
import SelectFwOrBundler from './SelectFwOrBundler.vue'
import SelectFrameworkOrBundler from './SelectFrameworkOrBundler.vue'
import type { Option } from './types'
const manyOptions: Readonly<Option[]> = [
@@ -18,30 +18,15 @@ const manyOptions: Readonly<Option[]> = [
},
] as const
describe('<SelectFwOrBundler />', () => {
it('playground', () => {
cy.mount(() => (
<div class="m-10">
<SelectFwOrBundler
selectorType="framework"
label="Front-end Framework"
options={manyOptions}
value="react"
/>
</div>
))
cy.contains('button', 'React.js').click()
})
describe('<SelectFrameworkOrBundler />', () => {
it('renders the name', () => {
cy.mount(() => <SelectFwOrBundler selectorType="framework" label="Front-end Framework" options={[]} />)
cy.mount(() => <SelectFrameworkOrBundler selectorType="framework" label="Front-end Framework" options={[]} />)
cy.contains('Front-end Framework').should('exist')
})
it('shows detected flag', () => {
cy.mount(() => (<SelectFwOrBundler
cy.mount(() => (<SelectFrameworkOrBundler
label="Front-end Framework"
selectorType="framework"
options={manyOptions}
@@ -54,7 +39,7 @@ describe('<SelectFwOrBundler />', () => {
it('shows a placeholder when no value is specified', () => {
cy.mount(() => (
<SelectFwOrBundler
<SelectFrameworkOrBundler
selectorType="framework"
label="Front-end Framework"
placeholder="placeholder"
@@ -74,7 +59,7 @@ describe('<SelectFwOrBundler />', () => {
it('shows a community integration', () => {
cy.mount(() => (
<SelectFwOrBundler
<SelectFrameworkOrBundler
selectorType="framework"
label="Front-end Framework"
placeholder="placeholder"
@@ -86,6 +71,7 @@ describe('<SelectFwOrBundler />', () => {
supportStatus: 'community',
},
]}
value='cypress-ct-solid-js'
/>
))
@@ -94,7 +80,7 @@ describe('<SelectFwOrBundler />', () => {
it('should select the value', () => {
cy.mount(() => (
<SelectFwOrBundler selectorType="framework" label="Front-end Framework" options={manyOptions} value="react" />
<SelectFrameworkOrBundler selectorType="framework" label="Front-end Framework" options={manyOptions} value="react" />
))
cy.contains('button', 'React.js').should('exist')
@@ -104,7 +90,7 @@ describe('<SelectFwOrBundler />', () => {
let val = ref('react')
cy.mount(() => (
<SelectFwOrBundler
<SelectFrameworkOrBundler
label="Front-end Framework"
selectorType="framework"
options={manyOptions}
@@ -125,7 +111,7 @@ describe('<SelectFwOrBundler />', () => {
cy.mount(() => (
<div>
<div>click out</div>
<SelectFwOrBundler selectorType="framework" label="Front-end Framework" options={manyOptions} value="vue2" />
<SelectFrameworkOrBundler selectorType="framework" label="Front-end Framework" options={manyOptions} value="vue2" />
</div>
))

View File

@@ -4,6 +4,7 @@ import { z } from 'zod'
import fs from 'fs-extra'
import Debug from 'debug'
import findUp from 'find-up'
import { isRepositoryRoot } from './searchUtils'
const debug = Debug('cypress:scaffold-config:ct-detect-third-party')
@@ -26,46 +27,6 @@ const thirdPartyDefinitionPrefixes = {
globalPrefix: 'cypress-ct-',
}
const ROOT_PATHS = [
'.git',
// https://pnpm.io/workspaces
'pnpm-workspace.yaml',
// https://rushjs.io/pages/advanced/config_files/
'rush.json',
// https://nx.dev/deprecated/workspace-json#workspace.json
// https://nx.dev/reference/nx-json#nx.json
'workspace.json',
'nx.json',
// https://lerna.js.org/docs/api-reference/configuration
'lerna.json',
]
async function hasWorkspacePackageJson (directory: string) {
try {
const pkg = await fs.readJson(path.join(directory, 'package.json'))
debug('package file for %s: %o', directory, pkg)
return !!pkg.workspaces
} catch (e) {
debug('error reading package.json in %s. this is not the repository root', directory)
return false
}
}
export async function isRepositoryRoot (directory: string) {
if (ROOT_PATHS.some((rootPath) => fs.existsSync(path.join(directory, rootPath)))) {
return true
}
return hasWorkspacePackageJson(directory)
}
export function isThirdPartyDefinition (definition: Cypress.ComponentFrameworkDefinition | Cypress.ThirdPartyComponentFrameworkDefinition): boolean {
return definition.type.startsWith(thirdPartyDefinitionPrefixes.globalPrefix) ||
thirdPartyDefinitionPrefixes.namespacedPrefixRe.test(definition.type)
@@ -83,9 +44,16 @@ const ThirdPartyComponentFrameworkSchema = z.object({
const CT_FRAMEWORK_GLOBAL_GLOB = path.join('node_modules', 'cypress-ct-*', 'package.json')
const CT_FRAMEWORK_NAMESPACED_GLOB = path.join('node_modules', '@*?/cypress-ct-*?', 'package.json')
export interface ErroredFramework {
path: string
reason: string
}
export async function detectThirdPartyCTFrameworks (
projectRoot: string,
): Promise<Cypress.ThirdPartyComponentFrameworkDefinition[]> {
): Promise<{ frameworks: Cypress.ThirdPartyComponentFrameworkDefinition[], erroredFrameworks: ErroredFramework[] }> {
const erroredFrameworks: ErroredFramework[] = []
try {
let fullPathGlobs
let packageJsonPaths: string[] = []
@@ -123,7 +91,7 @@ export async function detectThirdPartyCTFrameworks (
if (packageJsonPaths.length === 0) {
debug('no third-party dependencies detected')
return []
return { frameworks: [], erroredFrameworks }
}
debug('found third-party dependencies %o', packageJsonPaths)
@@ -149,9 +117,11 @@ export async function detectThirdPartyCTFrameworks (
*/
const pkgJson = await fs.readJSON(packageJsonPath)
debug('`name` in package.json', pkgJson.name)
const name = pkgJson.name
debug('Attempting to resolve third party module with require.resolve: %s', pkgJson.name)
debug('`name` in package.json', name)
debug('Attempting to resolve third party module with require.resolve: %s', name)
const modulePath = require.resolve(pkgJson.name, { paths: [projectRoot] })
@@ -167,8 +137,16 @@ export async function detectThirdPartyCTFrameworks (
debug('Import successful: %o', defaultEntry)
return defaultEntry
// adding the path here for use in error messages if needed
const defaultEntryWithPath = Object.assign(defaultEntry, { path: modulePath })
return defaultEntryWithPath
} catch (e) {
erroredFrameworks.push({
path: packageJsonPath,
reason: 'error while resolving',
})
debug('Ignoring %s due to error resolving module', e)
}
}),
@@ -180,17 +158,21 @@ export async function detectThirdPartyCTFrameworks (
return !!validateThirdPartyModule(m)
} catch (e) {
debug('Failed to parse third party module with validation error: %o', e)
erroredFrameworks.push({
path: m.path,
reason: 'error while parsing',
})
return false
}
})
})
return modules
return { frameworks: modules, erroredFrameworks }
} catch (e) {
debug('Error occurred while looking for 3rd party CT plugins: %o', e)
return []
return { frameworks: [], erroredFrameworks }
}
}

View File

@@ -49,7 +49,7 @@ export const WIZARD_DEPENDENCY_TYPESCRIPT = {
package: 'typescript',
installer: 'typescript',
description: 'TypeScript is a language for application-scale JavaScript',
minVersion: '^=3.0.0 || ^=4.0.0',
minVersion: '^=3.4.0 || ^=4.0.0' || '^=5.0.0',
} as const
export const WIZARD_DEPENDENCY_REACT_SCRIPTS = {

View File

@@ -1,10 +1,9 @@
import fs from 'fs-extra'
import * as dependencies from './dependencies'
import componentIndexHtmlGenerator from './component-index-template'
import debugLib from 'debug'
import semver from 'semver'
import { isThirdPartyDefinition } from './ct-detect-third-party'
import resolvePackagePath from 'resolve-package-path'
import { tryToFindPnpFile } from './searchUtils'
const debug = debugLib('cypress:scaffold-config:frameworks')
@@ -14,10 +13,36 @@ export type WizardBundler = typeof dependencies.WIZARD_BUNDLERS[number]
export type CodeGenFramework = Cypress.ResolvedComponentFrameworkDefinition['codeGenFramework']
const yarnPnpRegistrationPath = new Map<string, boolean>()
async function readPackageJson (packageFilePath: string, projectPath: string): Promise<PkgJson> {
return require(require.resolve(packageFilePath))
}
export async function isDependencyInstalled (dependency: Cypress.CypressComponentDependency, projectPath: string): Promise<Cypress.DependencyToInstall> {
try {
debug('detecting %s in %s', dependency.package, projectPath)
// we only need to register this once, when the project check dependencies for the first time.
if (!yarnPnpRegistrationPath.get(projectPath)) {
const pnpFile = await tryToFindPnpFile(projectPath)
if (pnpFile) {
const pnpapi = require(pnpFile)
pnpapi.setup()
yarnPnpRegistrationPath.set(projectPath, true)
} else {
// not using Yarn PnP
yarnPnpRegistrationPath.set(projectPath, false)
}
}
// NOTE: this *must* be required **after** the call to `pnpapi.setup()`
// or the pnpapi module that is added at runtime by Yarn PnP will not be correctly used
// for module resolution.
const resolvePackagePath = require('resolve-package-path')
const packageFilePath = resolvePackagePath(dependency.package, projectPath)
if (!packageFilePath) {
@@ -30,7 +55,7 @@ export async function isDependencyInstalled (dependency: Cypress.CypressComponen
}
}
const pkg = await fs.readJson(packageFilePath) as PkgJson
const pkg = await readPackageJson(packageFilePath, projectPath)
debug('found package.json %o', pkg)

View File

@@ -0,0 +1,71 @@
import findUp from 'find-up'
import path from 'path'
import fs from 'fs-extra'
import Debug from 'debug'
const debug = Debug('cypress:scaffold-config:searchUtils')
const ROOT_PATHS = [
'.git',
// https://pnpm.io/workspaces
'pnpm-workspace.yaml',
// https://rushjs.io/pages/advanced/config_files/
'rush.json',
// https://nx.dev/deprecated/workspace-json#workspace.json
// https://nx.dev/reference/nx-json#nx.json
'workspace.json',
'nx.json',
// https://lerna.js.org/docs/api-reference/configuration
'lerna.json',
]
async function hasWorkspacePackageJson (directory: string) {
try {
const pkg = await fs.readJson(path.join(directory, 'package.json'))
debug('package file for %s: %o', directory, pkg)
return !!pkg.workspaces
} catch (e) {
debug('error reading package.json in %s. this is not the repository root', directory)
return false
}
}
export async function isRepositoryRoot (directory: string) {
if (ROOT_PATHS.some((rootPath) => fs.existsSync(path.join(directory, rootPath)))) {
return true
}
return hasWorkspacePackageJson(directory)
}
/**
* Recursing search upwards from projectPath until the repository root looking for .pnp.cjs.
* If `.pnp.cjs` is found, return it
*/
export async function tryToFindPnpFile (projectPath: string): Promise<string | undefined> {
return findUp(async (directory: string) => {
const isCurrentRepositoryRoot = await isRepositoryRoot(directory)
const file = path.join(directory, '.pnp.cjs')
const hasPnpCjs = await fs.pathExists(file)
if (hasPnpCjs) {
return file
}
if (isCurrentRepositoryRoot) {
debug('stopping search at %s because it is believed to be the repository root', directory)
return findUp.stop
}
// Return undefined to keep searching
return undefined
}, { cwd: projectPath })
}

View File

@@ -1,9 +1,8 @@
import { scaffoldMigrationProject, fakeDepsInNodeModules } from './detect.spec'
import fs from 'fs-extra'
import path from 'path'
import { detectThirdPartyCTFrameworks, validateThirdPartyModule, isThirdPartyDefinition, isRepositoryRoot } from '../../src'
import { detectThirdPartyCTFrameworks, validateThirdPartyModule, isThirdPartyDefinition } from '../../src'
import { expect } from 'chai'
import os from 'os'
import solidJs from './fixtures'
async function copyNodeModule (root, moduleName) {
@@ -54,72 +53,13 @@ describe('isThirdPartyDefinition', () => {
})
})
describe('isRepositoryRoot', () => {
const TEMP_DIR = path.join(os.tmpdir(), 'is-repository-root-tmp')
beforeEach(async () => {
await fs.mkdir(TEMP_DIR)
})
afterEach(async () => {
await fs.rm(TEMP_DIR, { recursive: true })
})
it('returns false if there is nothing in the directory', async () => {
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.false
})
it('returns true if there is a Git directory', async () => {
await fs.mkdir(path.join(TEMP_DIR, '.git'))
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.true
})
it('returns false if there is a package.json without workspaces field', async () => {
await fs.writeFile(path.join(TEMP_DIR, 'package.json'), `{
"name": "@packages/foo",
"private": true,
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}
`)
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.false
})
it('returns true if there is a package.json with workspaces field', async () => {
await fs.writeFile(path.join(TEMP_DIR, 'package.json'), `{
"name": "monorepo-repo",
"private": true,
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"workspaces": [
"packages/*"
]
}
`)
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.true
})
})
describe('detectThirdPartyCTFrameworks', () => {
it('detects third party frameworks in global namespace', async () => {
const projectRoot = await scaffoldQwikApp(['cypress-ct-qwik'])
const thirdPartyFrameworks = await detectThirdPartyCTFrameworks(projectRoot)
expect(thirdPartyFrameworks[0].type).eq('cypress-ct-qwik')
expect(thirdPartyFrameworks.frameworks[0].type).eq('cypress-ct-qwik')
})
it('detects third party frameworks in org namespace', async () => {
@@ -127,7 +67,7 @@ describe('detectThirdPartyCTFrameworks', () => {
const thirdPartyFrameworks = await detectThirdPartyCTFrameworks(projectRoot)
expect(thirdPartyFrameworks[0].type).eq('@org/cypress-ct-qwik')
expect(thirdPartyFrameworks.frameworks[0].type).eq('@org/cypress-ct-qwik')
})
it('ignores misconfigured third party frameworks', async () => {
@@ -135,8 +75,8 @@ describe('detectThirdPartyCTFrameworks', () => {
const thirdPartyFrameworks = await detectThirdPartyCTFrameworks(projectRoot)
expect(thirdPartyFrameworks.length).eq(1)
expect(thirdPartyFrameworks[0].type).eq('cypress-ct-qwik')
expect(thirdPartyFrameworks.frameworks.length).eq(1)
expect(thirdPartyFrameworks.frameworks[0].type).eq('cypress-ct-qwik')
})
it('detects third party frameworks in monorepos with hoisted dependencies', async () => {
@@ -150,7 +90,7 @@ describe('detectThirdPartyCTFrameworks', () => {
// Look for third-party modules in packages/foo (where Cypress was launched from)
const thirdPartyFrameworks = await detectThirdPartyCTFrameworks(projectRoot)
expect(thirdPartyFrameworks[0].type).eq('cypress-ct-qwik')
expect(thirdPartyFrameworks.frameworks[0].type).eq('cypress-ct-qwik')
})
it('validates third party module', () => {

View File

@@ -0,0 +1,105 @@
import fs from 'fs-extra'
import path from 'path'
import { expect } from 'chai'
import os from 'os'
import { isRepositoryRoot, tryToFindPnpFile } from '../../src/searchUtils'
import dedent from 'dedent'
const TEMP_DIR = path.join(os.tmpdir(), 'is-repository-root-tmp')
beforeEach(async () => {
await fs.mkdir(TEMP_DIR)
})
afterEach(async () => {
await fs.rm(TEMP_DIR, { recursive: true })
})
describe('isRepositoryRoot', () => {
it('returns false if there is nothing in the directory', async () => {
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.false
})
it('returns true if there is a Git directory', async () => {
await fs.mkdir(path.join(TEMP_DIR, '.git'))
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.true
})
it('returns false if there is a package.json without workspaces field', async () => {
await fs.writeFile(path.join(TEMP_DIR, 'package.json'), `{
"name": "@packages/foo",
"private": true,
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}
`)
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.false
})
it('returns true if there is a package.json with workspaces field', async () => {
await fs.writeFile(path.join(TEMP_DIR, 'package.json'), `{
"name": "monorepo-repo",
"private": true,
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"workspaces": [
"packages/*"
]
}
`)
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)
expect(isCurrentRepositoryRoot).to.be.true
})
})
describe('tryToFindPnpFile', () => {
it('finds pnp.cjs at repo root', async () => {
const projectPath = path.join(TEMP_DIR, 'packages', 'tests')
const pnpcjs = path.join(TEMP_DIR, '.pnp.cjs')
await Promise.all([
fs.ensureFile(path.join(projectPath, 'package.json')),
fs.writeFile(pnpcjs, '/* pnp api */'),
fs.writeFile(path.join(TEMP_DIR, 'package.json'), dedent`
{
"workspaces": [
"packages/*"
]
}
`),
])
const pnpPath = await tryToFindPnpFile(projectPath)
expect(pnpPath).to.eq(pnpcjs)
})
it('does not find pnp.cjs at repo root', async () => {
const projectPath = path.join(TEMP_DIR, 'packages', 'tests')
await fs.ensureFile(path.join(projectPath, 'package.json'))
await fs.writeFile(path.join(TEMP_DIR, 'package.json'), dedent`
{
"workspaces": [
"packages/*"
]
}
`)
const pnpPath = await tryToFindPnpFile(projectPath)
expect(pnpPath).to.eq(undefined)
})
})

View File

@@ -9,6 +9,9 @@ const startCypress = async () => {
initializeStartTime()
// No typescript requires before this point please
// typescript isn't interpreted until the start cypress file
// Avoid putting much code here all together since this is prior to v8 snapshots.
const { hookRequire } = require('./hook-require')
hookRequire({ forceTypeScript: false })

View File

@@ -14,6 +14,7 @@ const { fs } = require('../util/fs')
const extension = require('@packages/extension')
const appData = require('../util/app_data')
const profileCleaner = require('../util/profile_cleaner')
const { telemetry } = require('@packages/telemetry')
const pathToBrowsers = appData.path('browsers')
const legacyProfilesWildcard = path.join(pathToBrowsers, '*')
@@ -124,8 +125,19 @@ const pathToExtension = extension.getPathToExtension()
async function executeBeforeBrowserLaunch (browser, launchOptions: typeof defaultLaunchOptions, options) {
if (plugins.has('before:browser:launch')) {
const span = telemetry.startSpan({ name: 'lifecycle:before:browser:launch' })
span?.setAttribute({
name: browser.name,
channel: browser.channel,
version: browser.version,
isHeadless: browser.isHeadless,
})
const pluginConfigResult = await plugins.execute('before:browser:launch', browser, launchOptions)
span?.end()
if (pluginConfigResult) {
extendLaunchOptionsFromPlugins(launchOptions, pluginConfigResult, options)
}

View File

@@ -14,6 +14,7 @@ const CLOUD_ENDPOINTS = {
instanceResults: 'instances/:id/results',
instanceStdout: 'instances/:id/stdout',
exceptions: 'exceptions',
telemetry: 'telemetry',
} as const
const parseArgs = function (url, args: any[] = []) {

View File

@@ -13,17 +13,31 @@ const Promise = require('bluebird')
const debug = require('debug')('cypress:server:cypress')
const { getPublicConfigKeys } = require('@packages/config')
const argsUtils = require('./util/args')
const { telemetry } = require('@packages/telemetry')
const { getCtx, hasCtx } = require('@packages/data-context')
const warning = (code, args) => {
return require('./errors').warning(code, args)
}
const exit = (code = 0) => {
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)
if (hasCtx()) {
await getCtx().lifecycleManager.mainProcessWillDisconnect().catch((err) => {
debug('mainProcessWillDisconnect errored with: ', err)
})
}
telemetry.getSpan('cypress')?.end()
await telemetry.shutdown().catch((err) => {
debug('telemetry shutdown errored with: ', err)
})
return process.exit(code)
}
@@ -136,6 +150,10 @@ module.exports = {
debug('from argv %o got options %o', argv, options)
if (options.key) {
telemetry.exporter()?.attachRecordKey(options.key)
}
if (options.headless) {
// --headless is same as --headed false
if (options.headed) {

View File

@@ -3,6 +3,7 @@ import _ from 'lodash'
import { makeDataContext } from '../makeDataContext'
import random from '../util/random'
import { telemetry } from '@packages/telemetry'
export = (mode, options) => {
if (mode === 'smokeTest') {
@@ -20,8 +21,14 @@ export = (mode, options) => {
})
}
const span = telemetry.startSpan({ name: `initialize:mode:${mode}` })
const ctx = setCtx(makeDataContext({ mode: mode === 'run' ? mode : 'open', modeOptions: options }))
const loadingPromise = ctx.initializeMode()
telemetry.getSpan('cypress')?.setAttribute('name', `cypress:${mode}`)
const loadingPromise = ctx.initializeMode().then(() => {
span?.end()
})
if (mode === 'run') {
// run must always be deterministic - if the user doesn't specify

View File

@@ -8,13 +8,12 @@ import menu from '../gui/menu'
import * as Windows from '../gui/windows'
import { makeGraphQLServer } from '@packages/graphql/src/makeGraphQLServer'
import { globalPubSub, getCtx, clearCtx } from '@packages/data-context'
import { telemetry } from '@packages/telemetry'
// eslint-disable-next-line no-duplicate-imports
import type { WebContents } from 'electron'
import type { LaunchArgs, Preferences } from '@packages/types'
import { debugElapsedTime } from '../util/performance_benchmark'
import debugLib from 'debug'
import { getPathToDesktopIndex } from '@packages/resolve-dist'
@@ -158,6 +157,10 @@ export = {
},
async run (options: LaunchArgs, _loading: Promise<void>) {
// Note: We do not await the `_loading` promise here since initializing
// the data context can significantly delay initial render of the UI
// https://github.com/cypress-io/cypress/issues/26388#issuecomment-1492616609
const [, port] = await Promise.all([
app.whenReady(),
makeGraphQLServer(),
@@ -190,11 +193,15 @@ export = {
debug('DataContext cleared, quitting app')
telemetry.getSpan('cypress')?.end()
await telemetry.shutdown()
app.quit()
})
})
debugElapsedTime('open mode ready')
telemetry.getSpan('startup:time')?.end()
return this.ready(options, port)
},

View File

@@ -24,9 +24,9 @@ import * as objUtils from '../util/obj_utils'
import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser, BrowserVideoController, VideoRecording, ProcessOptions } from '@packages/types'
import type { Cfg } from '../project-base'
import type { Browser } from '../browsers/types'
import { debugElapsedTime } from '../util/performance_benchmark'
import * as printResults from '../util/print-run'
import ProtocolManager from '../cloud/protocol'
import { telemetry } from '@packages/telemetry'
type SetScreenshotMetadata = (data: TakeScreenshotProps) => void
type ScreenshotMetadata = ReturnType<typeof screenshotMetadata>
@@ -462,7 +462,7 @@ async function waitForBrowserToConnect (options: { project: Project, socketId: s
const { project, socketId, onError, spec } = options
const browserTimeout = Number(process.env.CYPRESS_INTERNAL_BROWSER_CONNECT_TIMEOUT || 60000)
let attempts = 0
let browserLaunchAttempt = 1
// without this the run mode is only setting new spec
// path for next spec in launch browser.
@@ -483,6 +483,11 @@ async function waitForBrowserToConnect (options: { project: Project, socketId: s
// reset browser state to match default behavior when opening/closing a new tab
await openProject.resetBrowserState()
// Send the new telemetry context to the browser to set the parent/child relationship appropriately for tests
if (telemetry.isEnabled()) {
openProject.updateTelemetryContext(JSON.stringify(telemetry.getActiveContextObject()))
}
// since we aren't re-launching the browser, we have to navigate to the next spec instead
debug('navigating to next spec %s', spec)
@@ -490,6 +495,8 @@ async function waitForBrowserToConnect (options: { project: Project, socketId: s
}
const wait = async () => {
telemetry.startSpan({ name: `waitForBrowserToConnect:attempt:${browserLaunchAttempt}` })
debug('waiting for socket to connect and browser to launch...')
return Bluebird.all([
@@ -498,20 +505,23 @@ async function waitForBrowserToConnect (options: { project: Project, socketId: s
launchBrowser(options as typeof options & { setScreenshotMetadata: SetScreenshotMetadata }),
])
.timeout(browserTimeout)
.then(() => {
telemetry.getSpan(`waitForBrowserToConnect:attempt:${browserLaunchAttempt}`)?.end()
})
.catch(Bluebird.TimeoutError, async (err) => {
attempts += 1
telemetry.getSpan(`waitForBrowserToConnect:attempt:${browserLaunchAttempt}`)?.end()
console.log('')
// always first close the open browsers
// before retrying or dieing
await openProject.closeBrowser()
if (attempts === 1 || attempts === 2) {
if (browserLaunchAttempt === 1 || browserLaunchAttempt === 2) {
// try again up to 3 attempts
const word = attempts === 1 ? 'Retrying...' : 'Retrying again...'
const word = browserLaunchAttempt === 1 ? 'Retrying...' : 'Retrying again...'
errors.warning('TESTS_DID_NOT_START_RETRYING', word)
browserLaunchAttempt += 1
return await wait()
}
@@ -570,8 +580,11 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens
debug('received videoController %o', { videoController })
if (videoController) {
const span = telemetry.startSpan({ name: 'video:capture:delayToLetFinish' })
debug('delaying to extend video %o', { DELAY_TO_LET_VIDEO_FINISH_MS })
await Bluebird.delay(DELAY_TO_LET_VIDEO_FINISH_MS)
span?.end()
}
_.defaults(results, {
@@ -613,10 +626,15 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens
videoCaptureFailed = true
warnVideoRecordingFailed(err)
}
telemetry.getSpan('video:capture')?.setAttributes({ videoCaptureFailed })?.end()
}
const afterSpecSpan = telemetry.startSpan({ name: 'lifecycle:after:spec' })
debug('execute after:spec')
await runEvents.execute('after:spec', spec, results)
debug('executed after:spec')
afterSpecSpan?.end()
const videoName = videoRecording?.api.videoName
const videoExists = videoName && await fs.pathExists(videoName)
@@ -664,10 +682,18 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens
}
if (videoExists && !skippedSpec && !videoCaptureFailed) {
const span = telemetry.startSpan({ name: 'video:post:processing' })
const chaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests)
try {
debug('post processing recording')
span?.setAttributes({
videoName,
videoCompression,
compressedVideoName: videoRecording.api.compressedVideoName,
})
await postProcessRecording({
shouldUploadVideo,
quiet,
@@ -683,6 +709,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens
videoCaptureFailed = true
warnVideoRecordingFailed(err)
}
span?.end()
}
if (videoCaptureFailed) {
@@ -733,6 +760,17 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea
let isFirstSpec = true
async function runEachSpec (spec: SpecWithRelativeRoot, index: number, length: number, estimated: number) {
const span = telemetry.startSpan({
name: 'run:spec',
active: true,
})
span?.setAttributes({
specName: spec.name,
type: spec.specType,
firstSpec: isFirstSpec,
})
if (!options.quiet) {
printResults.displaySpecHeader(spec.relativeToCommonRoot, index + 1, length, estimated)
}
@@ -743,6 +781,8 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea
debug('spec results %o', results)
span?.end()
return results
}
@@ -760,7 +800,25 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea
autoCancelAfterFailures,
}
const runSpan = telemetry.startSpan({ name: 'run' })
runSpan?.setAttributes({
recordEnabled: !!runUrl,
...(runUrl && {
recordOpts: JSON.stringify({
runUrl,
parallel,
group,
tag,
autoCancelAfterFailures,
}),
}),
})
const beforeRunSpan = telemetry.startSpan({ name: 'lifecycle:before:run' })
await runEvents.execute('before:run', beforeRunDetails)
beforeRunSpan?.end()
const runs = await iterateThroughSpecs({
specs,
@@ -831,8 +889,13 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea
})),
})
const afterRunSpan = telemetry.startSpan({ name: 'lifecycle:after:run' })
await runEvents.execute('after:run', moduleAPIResults)
afterRunSpan?.end()
await writeOutput(outputPath, moduleAPIResults)
runSpan?.end()
return results
}
@@ -860,6 +923,8 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project:
const opts = { project, spec, videosFolder: options.videosFolder }
telemetry.startSpan({ name: 'video:capture' })
if (config.experimentalSingleTabRunMode && !isFirstSpec && project.videoRecording) {
// in single-tab mode, only the first spec needs to create a videoRecording object
// which is then re-used between specs
@@ -1087,7 +1152,8 @@ export async function run (options, loading: Promise<void>) {
debug('all BrowserWindows closed, not exiting')
})
debugElapsedTime('run mode ready')
telemetry.getSpan('binary:startup')?.end()
await app.whenReady()
}

View File

@@ -236,6 +236,15 @@ export class OpenProject {
this.projectBase.server._socket.changeToUrl(newSpecUrl)
}
/**
* Sends the new telemetry context to the browser
* @param context - telemetry context string
* @returns
*/
updateTelemetryContext (context: string) {
return this.projectBase?.server._socket.updateTelemetryContext(context)
}
// close existing open project if it exists, for example
// if you are switching from CT to E2E or vice versa.
// used by launchpad

View File

@@ -1,5 +1,19 @@
process.title = 'Cypress: Config Manager'
const { telemetry, OTLPTraceExporterIpc, decodeTelemetryContext } = require('@packages/telemetry')
const { file, projectRoot, telemetryCtx } = require('minimist')(process.argv.slice(2))
const { context, version } = decodeTelemetryContext(telemetryCtx)
const exporter = new OTLPTraceExporterIpc()
if (version && context) {
telemetry.init({ namespace: 'cypress:child:process', context, version, exporter })
}
const span = telemetry.startSpan({ name: 'child:process', active: true })
require('../../util/suppress_warnings').suppress()
process.on('disconnect', () => {
@@ -11,6 +25,15 @@ const util = require('../util')
const ipc = util.wrapIpc(process)
const run = require('./run_require_async_child')
const { file, projectRoot } = require('minimist')(process.argv.slice(2))
exporter.attachIPC(ipc)
ipc.on('main:process:will:disconnect', async () => {
if (span) {
span.end()
}
await telemetry.shutdown()
ipc.send('main:process:will:disconnect:ack')
})
run(ipc, file, projectRoot)

View File

@@ -46,6 +46,18 @@ const getTsNodeOptions = (tsPath, registeredFile) => {
const opts = {
compiler, // use the user's installed typescript
compilerOptions,
ignore: [
// default ignore
'(?:^|/)node_modules/',
// do not transpile cypress resources
// getIgnoreRegex({ configDir: dir, currentFileDir: __dirname, sep: path.sep }),
// This is not ideal, We are transpiling any pre-built cypress code along with the config file
// Ideally we'd only transpile the config file but deriving the correct has proven to be tricky
// due to differences between dev and prod, and quirks of ts-node's path handling
// We do not want to ignore too much or too little
// So for now we are only ignoring the explicit file that has issues
'/packages/telemetry/dist/span-exporters/ipc-span-exporter',
],
// resolves tsconfig.json starting from the plugins directory
// instead of the cwd (the project root)
dir: path.dirname(registeredFile),

View File

@@ -7,6 +7,7 @@ const debug = require('debug')('cypress:server:preprocessor')
const Promise = require('bluebird')
const appData = require('../util/app_data')
const plugins = require('../plugins')
const { telemetry } = require('@packages/telemetry')
const errorMessage = function (err = {}) {
return err.stack || err.annotated || err.message || err.toString()
@@ -97,7 +98,14 @@ const API = {
}
const preprocessor = (fileProcessors[filePath] = Promise.try(() => {
return plugins.execute('file:preprocessor', fileObject)
const span = telemetry.startSpan({ name: 'file:preprocessor' })
return plugins.execute('file:preprocessor', fileObject).then((arg) => {
span?.setAttribute('file', arg)
span?.end()
return arg
})
}))
return preprocessor

View File

@@ -22,6 +22,8 @@ import type { DestroyableHttpServer } from './util/server_destroy'
import * as session from './session'
import { cookieJar, SameSiteContext, automationCookieToToughCookie, SerializableAutomationCookie } from './util/cookies'
import runEvents from './plugins/run_events'
import type { OTLPTraceExporterCloud } from '@packages/telemetry'
import { telemetry } from '@packages/telemetry'
// eslint-disable-next-line no-duplicate-imports
import type { Socket } from '@packages/socket'
@@ -464,6 +466,10 @@ export class SocketBase {
return this.protocolManager?.commandLogAdded(args[0])
case 'protocol:command:log:changed':
return this.protocolManager?.commandLogChanged(args[0])
case 'telemetry':
return (telemetry.exporter() as OTLPTraceExporterCloud)?.send(args[0], () => {}, (err) => {
debug('error exporting telemetry data from browser %s', err)
})
default:
throw new Error(`You requested a backend event we cannot handle: ${eventName}`)
}
@@ -545,6 +551,10 @@ export class SocketBase {
if (this.supportsRunEvents) {
socket.on('plugins:before:spec', (spec, cb) => {
const beforeSpecSpan = telemetry.startSpan({ name: 'lifecycle:before:spec' })
beforeSpecSpan?.setAttributes({ spec })
runEvents.execute('before:spec', spec)
.then(cb)
.catch((error) => {
@@ -556,6 +566,9 @@ export class SocketBase {
// surfacing the error to the app in open mode
cb({ error })
})
.finally(() => {
beforeSpecSpan?.end()
})
})
}
@@ -608,4 +621,13 @@ export class SocketBase {
changeToUrl (url: string) {
return this.toRunner('change:to:url', url)
}
/**
* Sends the new telemetry context to the browser
* @param context - telemetry context string
* @returns
*/
updateTelemetryContext (context: string) {
return this.toRunner('update:telemetry:context', context)
}
}

View File

@@ -341,6 +341,7 @@ const _providerCiParams = () => {
'SEMAPHORE_GIT_REPO_SLUG',
'SEMAPHORE_GIT_SHA',
'SEMAPHORE_GIT_URL',
'SEMAPHORE_GIT_WORKING_BRANCH',
'SEMAPHORE_JOB_COUNT',
'SEMAPHORE_JOB_ID', // v2
'SEMAPHORE_JOB_NAME',
@@ -570,7 +571,7 @@ const _providerCommitParams = () => {
// Only from forks? https://semaphoreci.com/docs/available-environment-variables.html
semaphore: {
sha: env.SEMAPHORE_GIT_SHA,
branch: env.SEMAPHORE_GIT_BRANCH,
branch: env.SEMAPHORE_GIT_WORKING_BRANCH,
// message: ???
// authorName: ???
// authorEmail: ???

View File

@@ -1,22 +1,30 @@
const Debug = require('debug')
const { performance } = require('perf_hooks')
const debug = Debug('cypress:server:performance-benchmark')
const { isRunning } = require('./electron-app')
function threeDecimals (n) {
return Math.round(n * 1000) / 1000
}
const initializeStartTime = () => {
if (!isRunning()) {
return
}
// This needs to be a global since this file is included inside of and outside of the v8 snapshot
global.cypressBinaryStartTime = performance.timeOrigin
global.cypressServerStartTime = performance.now()
}
const debugElapsedTime = (event) => {
const Debug = require('debug')
const debug = Debug('cypress:server:performance-benchmark')
const now = performance.now()
const delta = now - global.cypressServerStartTime
debug(`elapsed time at ${event}: ${threeDecimals(delta)}ms`)
return delta
}
module.exports = {

View File

@@ -35,6 +35,7 @@
"@cypress/webpack-preprocessor": "0.0.0-development",
"@ffmpeg-installer/ffmpeg": "1.1.0",
"@packages/icons": "0.0.0-development",
"@packages/telemetry": "0.0.0-development",
"ansi_up": "5.0.0",
"ast-types": "0.13.3",
"base64url": "^3.0.1",

View File

@@ -1,3 +1,49 @@
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')
// are we in the main node process or the electron process?
const isRunningElectron = electronApp.isRunning()
const pkg = require('@packages/root')
if (isRunningElectron) {
// To pass unencrypted telemetry data to an independent open telemetry endpoint,
// disable the encryption header, update the url, and add any other required headers.
// For example:
// const exporter = new OTLPTraceExporterCloud({
// url: 'https://api.honeycomb.io/v1/traces',
// headers: {
// 'x-honeycomb-team': 'key',
// },
// })
// See additional information here: https://github.com/cypress-io/cypress/blob/develop/packages/telemetry/README.md#otlptraceexportercloud
const exporter = new OTLPTraceExporterCloud({
url: apiRoutes.telemetry(),
encryption,
})
telemetry.init({
namespace: 'cypress:server',
version: pkg.version,
exporter,
})
const { debugElapsedTime } = require('./lib/util/performance_benchmark')
const v8SnapshotStartupTime = debugElapsedTime('v8-snapshot-startup-time')
const endTime = v8SnapshotStartupTime + global.cypressServerStartTime
telemetry.startSpan({ name: 'cypress', attachType: 'root', active: true, opts: { startTime: global.cypressBinaryStartTime } })
const v8SnapshotSpan = telemetry.startSpan({ name: 'v8snapshot:startup', opts: { startTime: global.cypressServerStartTime } })
v8SnapshotSpan?.end(endTime)
telemetry.startSpan({ name: 'binary:startup' })
}
const { patchFs } = require('./lib/util/patch-fs')
const fs = require('fs')
@@ -7,11 +53,6 @@ patchFs(fs)
// override tty if we're being forced to
require('./lib/util/tty').override()
const electronApp = require('./lib/util/electron-app')
// are we in the main node process or the electron process?
const isRunningElectron = electronApp.isRunning()
if (process.env.CY_NET_PROFILE && isRunningElectron) {
const netProfiler = require('./lib/util/net_profiler')()

View File

@@ -25,6 +25,10 @@ describe('lib/plugins/child/ts_node', () => {
compilerOptions: {
module: 'commonjs',
},
ignore: [
'(?:^|/)node_modules/',
'/packages/telemetry/dist/span-exporters/ipc-span-exporter',
],
})
})
@@ -40,6 +44,10 @@ describe('lib/plugins/child/ts_node', () => {
module: 'commonjs',
preserveValueImports: false,
},
ignore: [
'(?:^|/)node_modules/',
'/packages/telemetry/dist/span-exporters/ipc-span-exporter',
],
})
})

View File

@@ -18,7 +18,7 @@ describe('lib/plugins/preprocessor', () => {
this.testPath = path.join(this.todosPath, 'test.coffee')
this.localPreprocessorPath = path.join(this.todosPath, 'prep.coffee')
this.plugin = sinon.stub().returns('/path/to/output.js')
this.plugin = sinon.stub().returns(new Promise((resolve) => resolve('/path/to/output.js')))
plugins.registerEvent('file:preprocessor', this.plugin)
preprocessor.close()

View File

@@ -885,6 +885,7 @@ describe('lib/util/ci_provider', () => {
SEMAPHORE_CURRENT_THREAD: 'semaphoreCurrentThread',
SEMAPHORE_EXECUTABLE_UUID: 'semaphoreExecutableUuid',
SEMAPHORE_GIT_BRANCH: 'show-semaphore-v2-266',
SEMAPHORE_GIT_WORKING_BRANCH: 'show-semaphore-v2-266',
SEMAPHORE_GIT_DIR: 'cypress-example-kitchensink',
SEMAPHORE_GIT_REF: 'refs/heads/show-semaphore-v2-266',
SEMAPHORE_GIT_REF_TYPE: 'branch',
@@ -917,6 +918,7 @@ describe('lib/util/ci_provider', () => {
semaphoreCurrentThread: 'semaphoreCurrentThread',
semaphoreExecutableUuid: 'semaphoreExecutableUuid',
semaphoreGitBranch: 'show-semaphore-v2-266',
semaphoreGitWorkingBranch: 'show-semaphore-v2-266',
semaphoreGitDir: 'cypress-example-kitchensink',
semaphoreGitRef: 'refs/heads/show-semaphore-v2-266',
semaphoreGitRefType: 'branch',

View File

@@ -0,0 +1,291 @@
# @packages/telemetry
This package is a convenience wrapper built around [open telemetry](https://opentelemetry.io/) to allow us to gain insights around how Cypress is used and help us prevent performance regressions.
## tl;dr
Telemetry in Cypress is disabled by default. To enable telemetry in Cypress set `CYPRESS_INTERNAL_ENABLE_TELEMETRY="true"`.
Telemetry data is sent to the cloud `/telemetry` endpoint.
For the **Cypress cloud project only** we forward the telemetry data to [honeycomb](https://ui.honeycomb.io/cypress). For all other projects telemetry data is not stored.
Environments:
* [Staging](https://ui.honeycomb.io/cypress/environments/cypress-app-staging/datasets/cypress-app/home)
* [Production](https://ui.honeycomb.io/cypress/environments/cypress-app/datasets/cypress-app/home)
## Design
At a very high level we use open telemetry to collect data and send it to honeycomb. There are three different processes that collect data, the server, the child process and the browser. The child process and browser forward collected spans to the server where the telemetry data is encrypted and sent to the Cypress Cloud. The Cypress Cloud then decrypts the telemetry data and decides what to do with it. Today, if the attached project is the Cypress project we forward the data to honeycomb, all other projects are ignored.
For each process a singleton telemetry instance is created and that instance can be used to create spans, or retrieve an already created span among other things.
```mermaid
sequenceDiagram
activate Cypress Server
Cypress Server->>Cypress Server: Initialize Main Process Telemetry Singleton
Cypress Server->>Cypress Child Process: Start Child Process - Send Context
activate Cypress Child Process
Cypress Child Process->>Cypress Child Process: Initialize Child Process Telemetry Singleton
deactivate Cypress Child Process
Cypress Server->>Cypress App: Launch Browser - Send Context
deactivate Cypress Server
activate Cypress App
Cypress App->>Cypress App: Initialize Browser Telemetry Singleton
deactivate Cypress App
par Browser Span lifecycle
Cypress App->>Cypress App: Span Starts
activate Cypress App
Cypress App->>Cypress Server: Span Ends, <br>send telemetry to Cypress server.
deactivate Cypress App
activate Cypress Server
Note right of Cypress App: Uses the web socket to avoid <br> network calls that would show up<br> in Cypress logs
and Child Process Span Lifecycle
Cypress Child Process->>Cypress Child Process: Span Starts
activate Cypress Child Process
Cypress Child Process->>Cypress Server: Span Ends, <br>send telemetry to Cypress server.
deactivate Cypress Child Process
Note over Cypress Child Process, Cypress App: Uses the IPC to avoid <br> encrypting in the child process
end
Cypress Server->>Cypress Cloud: Encrypt Span, forward to Cloud
deactivate Cypress Server
activate Cypress Cloud
Note right of Cypress Server: Telemetry data will always be <br>encrypted
alt Cypress project
Cypress Cloud->>Honeycomb: Decrypt span data, forward to honeycomb
else not Cypress project
Cypress Cloud->>Cypress OTEL Store: Decrypt span data, forward to OTEL Store(future)
deactivate Cypress Cloud
Note right of Cypress Cloud: For now we could dead end this data <br> until we're ready to store more.
end
```
## Setup
To prepare the telemetry singleton for use you first need to initialize it. This should be done at the start of whatever process you wish to monitor.
There are two different singletons included in the telemetry package, one for node and one for browser. They have different requirements to set up.
### Node
To access the node telemetry singleton use the default export.
```js
const { telemetry } = require('@packages/telemetry')
telemetry.init({options})
```
The node telemetry instance has a couple of exporters to choose from, the `OTLPTraceExporterCloud` and `OTLPTraceExporterIpc`.
#### OTLPTraceExporterCloud
The `OTLPTraceExporterCloud` will send telemetry as an http request to the supplied url and can be configured to encrypt the data. Within Cypress the exporter will encrypt data by default and send it to the cloud telemetry endpoint.
```js
const { OTLPTraceExporterCloud } = require('@packages/telemetry')
const exporter = new OTLPTraceExporterCloud({
url: apiRoutes.telemetry(),
encryption,
})
```
When sending data to the cloud telemetry endpoint you must attach the project id and the record key to the exporter as a header when it is available.
```js
const { telemetry, OTLPTraceExporterCloud } = require('@packages/telemetry')
(telemetry.exporter() as OTLPTraceExporterCloud)?.attachProjectId(config.projectId)
(telemetry.exporter() as OTLPTraceExporterCloud)?.attachRecordKey(recordkey)
```
When developing Cypress locally it is possible to override the telemetry endpoint and send unencrypted data to your own honeycomb instance. This is recommended when verifying newly added spans and any sort of local telemetry development.
```js
const { OTLPTraceExporterCloud } = require('@packages/telemetry')
const exporter = new OTLPTraceExporterCloud({
url: 'https://api.honeycomb.io/v1/traces',
headers: {
'x-honeycomb-team': 'key',
},
})
```
#### OTLPTraceExporterIPC
The `OTLPTraceExporterCloud` will send telemetry as an ipc message, the receiver of the message is responsible for forwarding it on to an OTEL collector. This is used in the child process. The ipc connection must be attached before telemetry data will be sent.
```js
const { OTLPTraceExporterIPC } = require('@packages/telemetry')
const exporter = new OTLPTraceExporterIpc.OTLPTraceExporterIpc()
exporter.attachIPC(ipc)
```
The `OTLPTraceExporterCloud` exporter has a handy send method you can use to forward on the telemetry requests.
```js
const { OTLPTraceExporterIPC } = require('@packages/telemetry')
(telemetry.exporter() as OTLPTraceExporterCloud)?.send(data, () => {}, (err) => {
debug('error exporting telemetry data %s', err)
})
```
### Browser
To access the browser telemetry singleton use the browser export directly.
```js
import { telemetry } from '@packages/telemetry/src/browser'
telemetry.init({options})
```
The browser singleton is also stored on window, in some cases when the telemetry package is included in multiple packages you can use the `attach` method to retrieve and setup the singleton from the instance saved on window.
```js
import { telemetry } from '@packages/telemetry/src/browser'
telemetry.attach()
```
The browser telemetry instance only supports the websocket exporter and the websocket must be attached to the exporter for spans to be sent.
```js
telemetry.attachWebSocket(ws)
```
The receiver of the websocket message is responsible for forwarding it on to an OTEL collector.
The `OTLPTraceExporterCloud` exporter has a handy send method you can use to forward on the telemetry requests.
```js
const { OTLPTraceExporterIPC } = require('@packages/telemetry')
(telemetry.exporter() as OTLPTraceExporterCloud)?.send(data, () => {}, (err) => {
debug('error exporting telemetry data %s', err)
})
```
### Shutting down
To ensure all telemetry data is sent, you must shut down any telemetry instances before exiting a process, or refreshing the browser.
```js
const { telemetry } = require('@packages/telemetry')
await telemetry.shutdown()
```
## Usage
To access the telemetry instance simply require or import the file.
Node:
```js
const { telemetry } = require('@packages/telemetry')
```
Browser:
```js
import { telemetry } from '@packages/telemetry/src/browser'
```
### Spans
Spans are the backbone of our telemetry system. At a basic level they measure the time between two points
#### Start
Starting a span indicates the start of a point that you with to time.
We've created a method for starting spans ourselves to allow us to start active spans without wrapping the observed code in a callback. We can also find previously started spans and stop them in other events.
```js
const { telemetry } = require('@packages/telemetry')
// The returned span is an otel span with all it's properties, if telemetry is disabled, the span may be undefined.
const span = telemetry.start({name: 'span'})
eventToTime()
// Attributes can be placed on a span to inform parameters that may influence timing, such as a filename.
span?.setAttributes({
fileName: 'filename',
})
// Spans must be ended for them to be sent to the telemetry endpoint.
span?.end()
```
#### Get
Sometimes spans aren't able to be closed in the same function that they're opened, or there are multiple paths that could close the span.
**Warning!** duplicate span names are not handled and the original span will no longer be able to be found.
```js
const { telemetry } = require('@packages/telemetry')
// The returned span is an otel span with all it's properties.
const span = telemetry.getSpan('span')
// Spans must be ended for them to be sent to the telemetry endpoint.
span?.end()
```
#### Active Spans
When you set a span as active, subsequent spans will be nested as children to the active span while it is not ended. You can override being set as a child span if you mark the span as a root span.
There can only be one active span at a time, but they will nest.
**Warning!** Active spans must be ended or the child relationship will be broken in your telemetry collector.
```js
const { telemetry } = require('@packages/telemetry')
// The returned span is an otel span with all it's properties.
const activeSpan = telemetry.start({name: 'activeSpan', active})
// This span will be set as a child of 'activeSpan'
const childSpan = telemetry.start({name: 'childSpan'})
// This span will not be set as a child of 'activeSpan'
const childSpan = telemetry.start({name: 'childSpan', attachType: 'root' })
// Spans must be ended for them to be sent to the telemetry endpoint.
activeSpan?.end()
// This span will not be set as a child of 'activeSpan' since the active span has ended.
const chidSpan2 = telemetry.start({name: 'childSpa2'})
```
### Metrics
The metrics api is tbd.
### Span Naming Guidelines
* Consider how you plan to use the span.
* Span name should be unique within the realm we wish to measure.
* Do you plan to compare the span against previous span instances? A span name that changes each run will be hard to compare.
* Is there a key attribute? If so consider making it part of the span name.
* Are you timing a function? Consider using the function name.
* Use `:` for separators
* Attributes can help make spans unique and provide clues why an instance of one span takes longer than another
* Remember the bests span names are human readable!
## Open Telemetry Links
* [otel docs](https://opentelemetry.io/docs/)
* [otel sdk](https://open-telemetry.github.io/opentelemetry-js/index.html)

View File

@@ -0,0 +1,36 @@
{
"name": "@packages/telemetry",
"version": "0.0.0-development",
"description": "open telemetry wrapper used throughout the cypress monorepo to instrument the cypress app",
"private": true,
"main": "dist/node.js",
"browser": "src/browser.ts",
"scripts": {
"build": "tsc",
"build-prod": "yarn build",
"check-ts": "tsc --noEmit && yarn -s tslint",
"test": "yarn test-unit",
"test-unit": "mocha --config ./test/.mocharc.js",
"tslint": "tslint --config ../ts/tslint.json --project .",
"watch": "tsc --watch"
},
"dependencies": {
"@opentelemetry/api": "1.4.1",
"@opentelemetry/auto-instrumentations-node": "0.36.4",
"@opentelemetry/exporter-trace-otlp-http": "0.36.1",
"@opentelemetry/instrumentation": "0.36.1",
"@opentelemetry/otlp-exporter-base": "0.36.1",
"@opentelemetry/sdk-trace-base": "1.10.1",
"@opentelemetry/sdk-trace-node": "1.10.1",
"@opentelemetry/sdk-trace-web": "1.10.1"
},
"devDependencies": {
"@packages/ts": "0.0.0-development",
"mocha": "7.0.1"
},
"files": [
"dist",
"src"
],
"types": "src/node.ts"
}

View File

@@ -0,0 +1,85 @@
import type { Span, Attributes } from '@opentelemetry/api'
import type { startSpanOptions, findActiveSpanOptions, contextObject } from './index'
import { Telemetry as TelemetryClass, TelemetryNoop } from './index'
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'
import { browserDetectorSync } from '@opentelemetry/resources'
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from './span-exporters/websocket-span-exporter'
declare global {
interface Window {
__CYPRESS_TELEMETRY__?: {context: {traceparent: string}, resources: Attributes}
cypressTelemetrySingleton?: TelemetryClass | TelemetryNoop
}
}
let telemetryInstance: TelemetryNoop | TelemetryClass = new TelemetryNoop
/**
* Initialize the telemetry singleton
* @param namespace - namespace to apply to the singleton
* @param config - object containing the version
* @returns void
*/
const init = ({ namespace, config }: { namespace: string, config: {version: string}}): void => {
// If we don't have cypress_telemetry setup on window don't even bother making the global instance
if (!window.__CYPRESS_TELEMETRY__) {
return
}
// Telemetry only needs to be initialized once.
if (telemetryInstance instanceof TelemetryClass) {
throw ('Telemetry instance has already be initialized')
}
const { context, resources } = window.__CYPRESS_TELEMETRY__
// We always use the websocket exporter for browser telemetry
const exporter = new OTLPTraceExporter()
telemetryInstance = new TelemetryClass({
namespace,
Provider: WebTracerProvider,
detectors: [
browserDetectorSync,
],
rootContextObject: context,
version: config?.version,
exporter,
// Because otel is lame we need to use the simple span processor instead of the batch processor
// or we risk losing spans when the browser navigates.
// TODO: create a browser batch span processor to account for navigation.
// See https://github.com/open-telemetry/opentelemetry-js/issues/2613
SpanProcessor: SimpleSpanProcessor,
resources,
})
window.cypressTelemetrySingleton = telemetryInstance
return
}
/**
* If telemetry has already been setup, attach this singleton to this instance
* @returns
*/
const attach = (): void => {
if (window.cypressTelemetrySingleton) {
telemetryInstance = window.cypressTelemetrySingleton
return
}
}
export const telemetry = {
init,
attach,
startSpan: (arg: startSpanOptions) => telemetryInstance.startSpan(arg),
getSpan: (arg: string) => telemetryInstance.getSpan(arg),
findActiveSpan: (arg: findActiveSpanOptions) => telemetryInstance.findActiveSpan(arg),
endActiveSpanAndChildren: (arg?: Span): void => telemetryInstance.endActiveSpanAndChildren(arg),
getActiveContextObject: () => telemetryInstance.getActiveContextObject(),
shutdown: () => telemetryInstance.shutdown(),
attachWebSocket: (ws: any) => (telemetryInstance.getExporter() as OTLPTraceExporter)?.attachWebSocket(ws),
setRootContext: (context?: contextObject) => (telemetryInstance.setRootContext(context)),
}

View File

@@ -0,0 +1,291 @@
import type { Span, SpanOptions, Tracer, Context, Attributes } from '@opentelemetry/api'
import type { BasicTracerProvider, SimpleSpanProcessor, BatchSpanProcessor, SpanExporter } from '@opentelemetry/sdk-trace-base'
import type { DetectorSync } from '@opentelemetry/resources'
import openTelemetry/*, { diag, DiagConsoleLogger, DiagLogLevel }*/ from '@opentelemetry/api'
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'
import { Resource, detectResourcesSync } from '@opentelemetry/resources'
const types = ['child', 'root'] as const
type AttachType = typeof types[number];
export type contextObject = { traceparent?: string }
export type startSpanOptions = {
name: string
attachType?: AttachType
active?: boolean
opts?: SpanOptions
}
// Extend the span type to include span.name
type NamedSpan = Span & { name: string }
export type findActiveSpanOptions = (element: NamedSpan, index: number) => boolean
export interface TelemetryApi {
startSpan(arg: startSpanOptions): Span | undefined | void
getSpan(name: string): Span | undefined
findActiveSpan(fn: findActiveSpanOptions): Span | undefined
endActiveSpanAndChildren (span?: Span | undefined): void
getActiveContextObject (): contextObject
getResources (): Attributes
shutdown (): Promise<void>
getExporter (): SpanExporter | undefined
setRootContext (rootContextObject?: contextObject): void
}
export class Telemetry implements TelemetryApi {
tracer: Tracer
spans: {[key: string]: Span}
activeSpanQueue: Span[]
rootContext?: Context
provider: BasicTracerProvider
exporter: SpanExporter
constructor ({
namespace,
Provider,
detectors,
rootContextObject,
version,
SpanProcessor,
exporter,
resources = {},
}: {
namespace?: string
Provider: typeof BasicTracerProvider
detectors: DetectorSync[]
rootContextObject?: contextObject
version: string
SpanProcessor: typeof SimpleSpanProcessor | typeof BatchSpanProcessor
exporter: SpanExporter
resources?: Attributes
}) {
// For troubleshooting, set the log level to DiagLogLevel.DEBUG
// diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ALL)
// Setup default resources
const resource = Resource.default().merge(
new Resource({
...resources,
[ SemanticResourceAttributes.SERVICE_NAME ]: 'cypress-app',
[ SemanticResourceAttributes.SERVICE_NAMESPACE ]: namespace,
[ SemanticResourceAttributes.SERVICE_VERSION ]: version,
}),
)
// Merge resources and create a new provider of the desired type.
this.provider = new Provider({ resource: resource.merge(detectResourcesSync({ detectors })) })
// Setup the console exporter
this.provider.addSpanProcessor(new SpanProcessor(exporter))
// Initialize the provider
this.provider.register()
// Save off the tracer
this.tracer = openTelemetry.trace.getTracer('cypress', version)
this.setRootContext(rootContextObject)
// store off the root context to apply to new spans
if (rootContextObject && rootContextObject.traceparent) {
this.rootContext = openTelemetry.propagation.extract(openTelemetry.context.active(), rootContextObject)
}
this.spans = {}
this.activeSpanQueue = []
this.exporter = exporter
}
/**
* Starts a span with the given name. Stores off the span with the name as a key for later retrieval.
* @param name - the span name
* @param attachType - Should this span be attached as a new root span or a child of the previous root span.
* @param name - Set true if this span should have child spans of it's own.
* @param opts - pass through for otel span opts
* @returns Span | undefined
*/
startSpan ({
name,
attachType = 'child',
active = false,
opts = {},
}: startSpanOptions) {
// Currently the latest span replaces any previous open or closed span and you can no longer access the replaced span.
// This works well enough for now but may cause issue in the future.
let span: Span
// If root or implied root
if (attachType === 'root' || this.activeSpanQueue.length < 1) {
if (this.rootContext) {
// Start span with external context
span = this.tracer.startSpan(name, opts, this.rootContext)
} else {
// Start span with no context
span = this.tracer.startSpan(name, opts)
}
} else { // attach type must be child
// Create a context from the active span.
const ctx = openTelemetry.trace.setSpan(openTelemetry.context.active(), this.activeSpanQueue[this.activeSpanQueue.length - 1]!)
// Start span with parent context.
span = this.tracer.startSpan(name, opts, ctx)
}
// Save off span, duplicate names currently not handled.
this.spans[name] = span
// If this is an active span, set it as the new active span
if (active) {
const _end = span.end
// override the end function to allow us to pop the span off the queue if found.
span.end = (endTime) => {
// find the span in the queue by spanId
const index = this.activeSpanQueue.findIndex((element: Span) => {
return element.spanContext().spanId === span.spanContext().spanId
})
// if span exists, remove it from the queue
if (index > -1) {
this.activeSpanQueue.splice(index, 1)
}
_end.call(span, endTime)
}
this.activeSpanQueue.push(span)
}
return span
}
/**
* Return requested span
* @param name - span name to retrieve
* @returns Span | undefined
*/
getSpan (name: string) {
return this.spans[name]
}
/**
* Search the span queue for the active span that meets the criteria
* @param fn - function to search the active spans
* @returns Span | undefined
*/
findActiveSpan (fn: findActiveSpanOptions): Span | undefined {
return (this.activeSpanQueue as Array<NamedSpan>).find(fn)
}
/**
* Ends specified active span and any active child spans
* @param span - span to end
*/
endActiveSpanAndChildren (span?: Span | undefined) {
if (!span) {
return
}
const startIndex = this.activeSpanQueue.findIndex((element: Span) => {
return element.spanContext().spanId === span.spanContext().spanId
})
this.activeSpanQueue.slice(startIndex).forEach((spanToEnd) => {
span.setAttribute('spanEndedPrematurely', true)
spanToEnd?.end()
})
}
/**
* Returns the context object for the active span.
* @returns the context
*/
getActiveContextObject (): contextObject {
const rootSpan = this.activeSpanQueue[this.activeSpanQueue.length - 1]
// If no root span, nothing to return
if (!rootSpan) {
return {}
}
const ctx = openTelemetry.trace.setSpan(openTelemetry.context.active(), rootSpan)
let myCtx = {}
openTelemetry.propagation.inject(ctx, myCtx)
return myCtx
}
/**
* Gets a list of the resources currently set on the provider.
* @returns Attributes of resources
*/
getResources (): Attributes {
return this.provider.resource.attributes
}
/**
* Shuts down telemetry and flushes any batched spans.
* @returns promise
*/
shutdown () {
return this.provider.shutdown()
}
/**
* Returns the assigned exporter
* @returns SpanExporter
*/
getExporter () {
return this.exporter
}
/**
* Sets or resets the root context for spans
* @param rootContextObject
*/
setRootContext (rootContextObject?: contextObject): void {
// store off the root context to apply to new spans
if (rootContextObject && rootContextObject.traceparent) {
this.rootContext = openTelemetry.propagation.extract(openTelemetry.context.active(), rootContextObject)
}
}
}
/**
* The telemetry Noop class is used when telemetry is disabled.
* It should mirror all the existing functions in telemetry, but no-op for
* all operations.
*/
export class TelemetryNoop implements TelemetryApi {
startSpan () {
return undefined
}
getSpan () {
return undefined
}
findActiveSpan () {
return undefined
}
endActiveSpanAndChildren () {
return undefined
}
getActiveContextObject (): contextObject {
return {}
}
getResources () {
return {}
}
shutdown () {
return Promise.resolve()
}
getExporter () {
return undefined
}
setRootContext () {}
}

View File

@@ -0,0 +1,92 @@
import type { Span } from '@opentelemetry/api'
import type { startSpanOptions, findActiveSpanOptions, contextObject } from './index'
import { Telemetry as TelemetryClass, TelemetryNoop } from './index'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { envDetectorSync, processDetectorSync, osDetectorSync, hostDetectorSync } from '@opentelemetry/resources'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter as OTLPTraceExporterIpc } from './span-exporters/ipc-span-exporter'
import { OTLPTraceExporter as OTLPTraceExporterCloud } from './span-exporters/cloud-span-exporter'
export { OTLPTraceExporterIpc, OTLPTraceExporterCloud }
let telemetryInstance: TelemetryNoop | TelemetryClass = new TelemetryNoop
/**
* Provide a single place to check if telemetry should be enabled.
* @returns boolean
*/
const isEnabled = (): boolean => process.env.CYPRESS_INTERNAL_ENABLE_TELEMETRY === 'true'
/**
* Initialize the telemetry singleton
* @param namespace - namespace to apply to the singleton
* @param context - context to apply if it exists
* @param version - cypress version
* @param exporter - the exporter to be used.
* @returns
*/
const init = ({
namespace,
context,
version,
exporter,
}: {
namespace: string
context?: contextObject
version: string
exporter: OTLPTraceExporterIpc | OTLPTraceExporterCloud
}): void => {
if (!isEnabled()) {
return
}
// Telemetry only needs to be initialized once.
if (telemetryInstance instanceof TelemetryClass) {
throw ('Telemetry instance has already be initialized')
}
telemetryInstance = new TelemetryClass({
namespace,
Provider: NodeTracerProvider,
detectors: [
envDetectorSync, processDetectorSync, osDetectorSync, hostDetectorSync,
],
rootContextObject: context,
version,
exporter,
SpanProcessor: BatchSpanProcessor,
})
return
}
export const telemetry = {
init,
isEnabled,
startSpan: (arg: startSpanOptions) => telemetryInstance.startSpan(arg),
getSpan: (arg: string) => telemetryInstance.getSpan(arg),
findActiveSpan: (arg: findActiveSpanOptions) => telemetryInstance.findActiveSpan(arg),
endActiveSpanAndChildren: (arg?: Span): void => telemetryInstance.endActiveSpanAndChildren(arg),
getActiveContextObject: () => telemetryInstance.getActiveContextObject(),
getResources: () => telemetryInstance.getResources(),
shutdown: () => telemetryInstance.shutdown(),
exporter: (): void | OTLPTraceExporterIpc | OTLPTraceExporterCloud => telemetryInstance.getExporter() as void | OTLPTraceExporterIpc | OTLPTraceExporterCloud,
}
export const decodeTelemetryContext = (telemetryCtx: string): {context?: contextObject, version?: string} => {
if (telemetryCtx) {
return JSON.parse(
Buffer.from(telemetryCtx, 'base64').toString('utf-8'),
)
}
return {}
}
export const encodeTelemetryContext = ({ context, version }: { context?: contextObject, version?: string }): string => {
return Buffer.from(JSON.stringify({
context,
version,
})).toString('base64')
}

View File

@@ -0,0 +1,160 @@
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import type {
OTLPExporterNodeConfigBase,
OTLPExporterError,
} from '@opentelemetry/otlp-exporter-base'
import { diag } from '@opentelemetry/api'
import { OTLPTraceExporter as OTLPTraceExporterHttp } from '@opentelemetry/exporter-trace-otlp-http'
import { sendWithHttp } from '@opentelemetry/otlp-exporter-base'
export interface OTLPExporterNodeConfigBasePlusEncryption extends OTLPExporterNodeConfigBase {
encryption?: {
encryptRequest: (requestOptions: {
url: string
method: string
body: string
}) => (Promise<{jwe: string}>)
}
}
/**
* Collector Trace Exporter for Node
*/
export class OTLPTraceExporter extends OTLPTraceExporterHttp {
delayedItemsToExport: {
serviceRequest: string
onSuccess: () => void
onError: (error: OTLPExporterError) => void
}[]
enc: OTLPExporterNodeConfigBasePlusEncryption['encryption'] | undefined
projectId?: string
recordKey?: string
sendWithHttp: typeof sendWithHttp
constructor (config: OTLPExporterNodeConfigBasePlusEncryption = {}) {
super(config)
this.enc = config.encryption
this.delayedItemsToExport = []
this.sendWithHttp = sendWithHttp
if (this.enc) {
this.headers['x-cypress-encrypted'] = '1'
}
}
/**
* Adds the projectId as a header and exports any delayed spans.
* @param projectId - the id of the project to export spans for.
*/
attachProjectId (projectId: string | null | undefined): void {
if (!projectId) {
return
}
// Continue to send this header for passivity until the cloud is released.
this.headers['x-project-id'] = projectId
this.projectId = projectId
this.setAuthorizationHeader()
}
/**
* Adds the recordKey as a header and exports any delayed spans.
* @param recordKey - the recordKey of the project to export spans for.
*/
attachRecordKey (recordKey: string | null | undefined): void {
if (!recordKey) {
return
}
this.recordKey = recordKey
this.setAuthorizationHeader()
}
/**
* Sets the auth header based on the project id and record key.
*/
setAuthorizationHeader () {
if (this.projectId && this.recordKey) {
this.headers.Authorization = `Basic ${Buffer.from(`${this.projectId}:${this.recordKey}`).toString('base64')}`
this.sendDelayedItems()
}
}
/**
* exports delayed spans if both the record key and project id are present
*/
sendDelayedItems () {
if (this.headers.Authorization) {
this.delayedItemsToExport.forEach((item) => {
this.send(item.serviceRequest, item.onSuccess, item.onError)
})
this.delayedItemsToExport = []
}
}
/**
* Overrides send if we need to encrypt the request.
* @param objects
* @param onSuccess
* @param onError
* @returns
*/
send (
objects: ReadableSpan[] | string,
onSuccess: () => void,
onError: (error: OTLPExporterError) => void,
): void {
if (this._shutdownOnce.isCalled) {
diag.debug('Shutdown already started. Cannot send objects')
return
}
let serviceRequest: string
if (typeof objects !== 'string') {
serviceRequest = JSON.stringify(this.convert(objects))
} else {
serviceRequest = objects
}
// Delay items if we want encryption but don't have an authorization header
if (this.enc && !this.headers.Authorization) {
this.delayedItemsToExport.push({ serviceRequest, onSuccess, onError })
return
}
const prepareRequest = (request: string): Promise<string> => {
if (this.enc) {
return this.enc.encryptRequest({ url: this.url, method: 'post', body: serviceRequest }).then(({ jwe }) => JSON.stringify(jwe))
}
return Promise.resolve(request)
}
const promise = prepareRequest(serviceRequest).then((body) => {
return new Promise<void>((resolve, reject) => {
this.sendWithHttp(
this,
body,
'application/json',
resolve,
reject,
)
})
}).then(onSuccess, onError)
this._sendingPromises.push(promise)
const popPromise = () => {
const index = this._sendingPromises.indexOf(promise)
this._sendingPromises.splice(index, 1)
}
promise.then(popPromise, popPromise)
}
}

View File

@@ -0,0 +1,86 @@
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import type {
OTLPExporterError,
} from '@opentelemetry/otlp-exporter-base'
import type { ExportResult } from '@opentelemetry/core'
import { diag } from '@opentelemetry/api'
import { OTLPTraceExporter as OTLPTraceExporterHttp } from '@opentelemetry/exporter-trace-otlp-http'
/**
* Collector Trace Exporter for Node
*/
export class OTLPTraceExporter
extends OTLPTraceExporterHttp {
ipc: any
delayedExport: {items: ReadableSpan[], resultCallback: (result: ExportResult) => void}[]
constructor () {
super({})
this.delayedExport = []
}
/**
* Attaches the ipc and replays any exports called without it
* @param ipc - the ipc used to send data
*/
attachIPC (ipc: any): void {
this.ipc = ipc
this.delayedExport.forEach(({ items, resultCallback }) => {
this.export(items, resultCallback)
})
}
/**
* Overrides export to delay sending spans if the ipc has not been attached
* @param items
* @param resultCallback
*/
export (
items: ReadableSpan[],
resultCallback: (result: ExportResult) => void,
): void {
if (!this.ipc) {
this.delayedExport.push({ items, resultCallback })
} else {
super.export(items, resultCallback)
}
}
/**
* Overrides send to use IPC instead of http
* @param objects
* @param onSuccess
* @param onError
* @returns
*/
send (
objects: ReadableSpan[],
onSuccess: () => void,
onError: (error: OTLPExporterError) => void,
): void {
if (this._shutdownOnce.isCalled) {
diag.debug('Shutdown already started. Cannot send objects')
return
}
const serviceRequest = JSON.stringify(this.convert(objects))
const promise = Promise.resolve().then(() => {
return new Promise<void>((resolve) => {
this.ipc.send('export:telemetry', serviceRequest)
resolve()
})
}).then(onSuccess, onError)
this._sendingPromises.push(promise)
const popPromise = () => {
const index = this._sendingPromises.indexOf(promise)
this._sendingPromises.splice(index, 1)
}
promise.then(popPromise, popPromise)
}
}

View File

@@ -0,0 +1,90 @@
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import type {
OTLPExporterError,
} from '@opentelemetry/otlp-exporter-base'
import type { ExportResult } from '@opentelemetry/core'
import { diag } from '@opentelemetry/api'
import { OTLPTraceExporter as OTLPTraceExporterHttp } from '@opentelemetry/exporter-trace-otlp-http'
/**
* Collector Trace Exporter for Node
*/
export class OTLPTraceExporter
extends OTLPTraceExporterHttp {
ws: any
delayedExport: {items: ReadableSpan[], resultCallback: (result: ExportResult) => void}[]
constructor () {
super({})
this.delayedExport = []
}
/**
* attaches the websocket and replays any exports called without it
* @param ws - the web socket.
*/
attachWebSocket (ws: any): void {
this.ws = ws
this.delayedExport.forEach(({ items, resultCallback }) => {
this.export(items, resultCallback)
})
}
/**
* Overrides export to delay sending spans if encryption is needed and there is no attached projectId
* @param items
* @param resultCallback
*/
export (
items: ReadableSpan[],
resultCallback: (result: ExportResult) => void,
): void {
if (!this.ws) {
this.delayedExport.push({ items, resultCallback })
} else {
super.export(items, resultCallback)
}
}
/**
* Overrides the send method to use a websocket instead of http
* @param objects
* @param onSuccess
* @param onError
* @returns
*/
send (
objects: ReadableSpan[],
onSuccess: () => void,
onError: (error: OTLPExporterError) => void,
): void {
if (this._shutdownOnce.isCalled) {
diag.debug('Shutdown already started. Cannot send objects')
return
}
const serviceRequest = JSON.stringify(this.convert(objects))
const promise = Promise.resolve().then(() => {
return new Promise<void>((resolve, reject) => {
this.ws.emit('backend:request', 'telemetry', serviceRequest, (res?: { error: Error }) => {
if (res && res.error) {
reject(res.error)
}
resolve()
})
})
}).then(onSuccess, onError)
this._sendingPromises.push(promise)
const popPromise = () => {
const index = this._sendingPromises.indexOf(promise)
this._sendingPromises.splice(index, 1)
}
promise.then(popPromise, popPromise)
}
}

View File

@@ -0,0 +1,9 @@
module.exports = {
require: '@packages/ts/register',
reporter: 'mocha-multi-reporters',
reporterOptions: {
configFile: '../../mocha-reporter-config.json',
},
spec: 'test/**/*.spec.ts',
watchFiles: ['test/**/*.ts', 'src/**/*.ts'],
}

View File

@@ -0,0 +1,181 @@
// @ts-expect-error
global.window = {}
import { expect } from 'chai'
import { telemetry } from '../src/browser'
import { Telemetry as TelemetryClass } from '../src/index'
describe('telemetry is disabled', () => {
describe('init', () => {
it('does not throw', () => {
expect(telemetry.init({
namespace: 'namespace',
config: { version: 'version' },
})).to.not.throw
expect(window.cypressTelemetrySingleton).to.be.undefined
})
})
describe('attach', () => {
it('returns void', () => {
expect(telemetry.attach()).to.not.throw
})
})
describe('startSpan', () => {
it('returns undefined', () => {
expect(telemetry.startSpan({ name: 'nope' })).to.be.undefined
})
})
describe('getSpan', () => {
it('returns undefined', () => {
expect(telemetry.getSpan('nope')).to.be.undefined
})
})
describe('findActiveSpan', () => {
it('returns undefined', () => {
expect(telemetry.findActiveSpan((span) => true)).to.be.undefined
})
})
describe('endActiveSpanAndChildren', () => {
it('does not throw', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.endActiveSpanAndChildren(spanny)).to.not.throw
})
})
describe('getActiveContextObject', () => {
it('returns an empty object', () => {
expect(telemetry.getActiveContextObject().traceparent).to.be.undefined
})
})
describe('shutdown', () => {
it('does not throw', () => {
expect(telemetry.shutdown()).to.not.throw
})
})
describe('attachWebSocket', () => {
it('does not throw', () => {
expect(telemetry.attachWebSocket('s')).to.not.throw
})
})
describe('setRootContext', () => {
it('does not throw', () => {
expect(telemetry.setRootContext()).to.not.throw
})
})
})
describe('telemetry is enabled', () => {
before('init', () => {
// @ts-expect-error
global.window.__CYPRESS_TELEMETRY__ = {
context: {
traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01',
},
resources: {
herp: 'derp',
},
}
expect(telemetry.init({
namespace: 'namespace',
config: { version: 'version' },
})).to.not.throw
expect(window.cypressTelemetrySingleton).to.be.instanceOf(TelemetryClass)
expect(window.cypressTelemetrySingleton.getResources()).to.contain({ herp: 'derp' })
})
describe('attachWebSocket', () => {
it('returns true', () => {
telemetry.attachWebSocket('ws')
expect(window.cypressTelemetrySingleton?.getExporter()?.ws).to.equal('ws')
})
})
describe('startSpan', () => {
it('returns undefined', () => {
expect(telemetry.startSpan({ name: 'nope' })).to.exist
})
})
describe('getSpan', () => {
it('returns undefined', () => {
telemetry.startSpan({ name: 'nope' })
expect(telemetry.getSpan('nope')).to.be.exist
})
})
describe('findActiveSpan', () => {
it('returns undefined', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.findActiveSpan((span) => true)).to.be.exist
spanny?.end()
})
})
describe('endActiveSpanAndChildren', () => {
it('does not throw', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.endActiveSpanAndChildren(spanny)).to.not.throw
expect(telemetry.getActiveContextObject().traceparent).to.be.undefined
})
})
describe('getActiveContextObject', () => {
it('returns an empty object', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.getActiveContextObject().traceparent).to.exist
spanny?.end()
})
})
describe('shutdown', () => {
it('does not throw', () => {
expect(telemetry.shutdown()).to.not.throw
})
})
describe('init', () => {
it('throws if called more than once', () => {
try {
telemetry.init({
namespace: 'namespace',
config: { version: 'version' },
})
} catch (err) {
expect(err).to.equal('Telemetry instance has already be initialized')
}
})
})
describe('setRootContext', () => {
it('it sets the context', () => {
console.log('bef', telemetry.getActiveContextObject())
// @ts-expect-error
expect(window.cypressTelemetrySingleton?.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b0')
telemetry.setRootContext({ traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b1-01' })
// @ts-expect-error
expect(window.cypressTelemetrySingleton?.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b1')
})
})
})

View File

@@ -0,0 +1,372 @@
import { expect } from 'chai'
import { Telemetry } from '../src'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter as OTLPTraceExporterCloud } from '../src/span-exporters/cloud-span-exporter'
describe('init', () => {
it('creates a new instance', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
SpanProcessor: BatchSpanProcessor,
})
expect(tel).to.not.be.undefined
expect(tel.provider).is.instanceOf(NodeTracerProvider)
expect(tel.provider.resource.attributes['service.namespace']).to.equal('namespace')
expect(tel.provider.resource.attributes['service.version']).to.equal('version')
expect(tel.provider.resource.attributes['service.name']).to.equal('cypress-app')
// @ts-expect-error
expect(tel.provider.activeSpanProcessor._spanProcessors[0]).is.instanceOf(BatchSpanProcessor)
expect(tel.getExporter()).to.equal(exporter)
expect(tel.rootContext).to.be.undefined
})
it('creates a new instance', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' },
SpanProcessor: BatchSpanProcessor,
})
expect(tel).to.not.be.undefined
expect(tel.rootContext).to.not.be.undefined
})
})
describe('startSpan', () => {
it('starts a span with an external parent id', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' },
SpanProcessor: BatchSpanProcessor,
})
const span = tel.startSpan({ name: 'span' })
// @ts-expect-error
expect(span.name).to.equal('span')
// @ts-expect-error
expect(span.parentSpanId).to.equal('4ad8bd26672a01b0')
expect(tel.activeSpanQueue.length).to.be.lessThan(1)
// @ts-expect-error
expect(tel.spans[span.name]).to.equal(span)
})
it('starts a span with no parent id', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
SpanProcessor: BatchSpanProcessor,
})
const span = tel.startSpan({ name: 'span' })
// @ts-expect-error
expect(span.name).to.equal('span')
// @ts-expect-error
expect(span.parentSpanId).to.be.undefined
})
it('starts an active span', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
SpanProcessor: BatchSpanProcessor,
})
const span = tel.startSpan({ name: 'span', active: true })
// @ts-expect-error
expect(span.name).to.equal('span')
// @ts-expect-error
expect(span.parentSpanId).to.be.undefined
// @ts-expect-error
expect(tel.activeSpanQueue[0].name).to.equal('span')
// Start a child that should have the previous span as a parent
const spanChild = tel.startSpan({ name: 'child' })
// @ts-expect-error
expect(spanChild.name).to.equal('child')
// @ts-expect-error
expect(spanChild.parentSpanId).to.equal(span._spanContext.spanId)
// Start a root child that does not have the active parent
const spanRoot = tel.startSpan({ name: 'root', attachType: 'root' })
// @ts-expect-error
expect(spanRoot.name).to.equal('root')
// @ts-expect-error
expect(spanRoot.parentSpanId).to.be.undefined
// end the active span to see it removed from the queue
span?.end()
expect(tel.activeSpanQueue.length).to.be.lessThan(1)
})
})
describe('getSpan', () => {
it('retrieves the span', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: 'id' },
SpanProcessor: BatchSpanProcessor,
})
const spanny = tel.startSpan({ name: 'spanny' })
expect(tel.getSpan('spanny')).to.equal(spanny)
expect(tel.getSpan('not-found')).to.be.undefined
})
})
describe('findActiveSpan', () => {
it('finds a span', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: 'id' },
SpanProcessor: BatchSpanProcessor,
})
const spanny = tel.startSpan({ name: 'spanny', active: true })
tel.startSpan({ name: 'spannyChild', active: true })
const foundSpan = tel.findActiveSpan((span) => {
return span.name === 'spanny'
})
expect(foundSpan).to.equal(spanny)
})
})
describe('endActiveSpanAndChildren', () => {
it('ends the active span', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: 'id' },
SpanProcessor: BatchSpanProcessor,
})
const spanny = tel.startSpan({ name: 'spanny', active: true })
expect(spanny).to.exist
tel.startSpan({ name: 'spannyChild', active: true })
expect(tel.activeSpanQueue.length).to.equal(2)
tel.endActiveSpanAndChildren(spanny)
expect(tel.activeSpanQueue.length).to.equal(0)
tel.endActiveSpanAndChildren(spanny)
expect(tel.activeSpanQueue.length).to.equal(0)
})
})
describe('getActiveContextObject', () => {
it('returns the active Context Object', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: 'id' },
SpanProcessor: BatchSpanProcessor,
})
const emptyContext = tel.getActiveContextObject()
expect(emptyContext.traceparent).to.be.undefined
tel.startSpan({ name: 'spanny', active: true })
const context = tel.getActiveContextObject()
expect(context.traceparent).to.exist
})
})
describe('getResources', () => {
it('returns the active resources', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: 'id' },
SpanProcessor: BatchSpanProcessor,
resources: {
herp: 'derp',
'service.name': 'not overridden',
},
})
expect(tel.getResources()).to.contain({
'service.name': 'cypress-app',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.name': 'opentelemetry',
herp: 'derp',
'service.namespace': 'namespace',
'service.version': 'version',
})
})
})
describe('shutdown', () => {
it('confirms shutdown is called', async () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: 'id' },
SpanProcessor: BatchSpanProcessor,
})
let shutdownCalled = false
// @ts-expect-error
tel.provider = { shutdown: () => {
shutdownCalled = true
return Promise.resolve()
} }
await tel.shutdown()
expect(shutdownCalled).to.be.true
})
})
describe('getExporter', () => {
it('returns the exporter', async () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: 'id' },
SpanProcessor: BatchSpanProcessor,
})
expect(tel.getExporter()).to.equal(exporter)
})
})
describe('setRootContext', () => {
it('sets root context', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' },
SpanProcessor: BatchSpanProcessor,
})
// @ts-expect-error
expect(tel.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b0')
tel.setRootContext({ traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b1-01' })
// @ts-expect-error
expect(tel.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b1')
})
it('sets root context', () => {
const exporter = new OTLPTraceExporterCloud()
const tel = new Telemetry({
namespace: 'namespace',
Provider: NodeTracerProvider,
detectors: [],
exporter,
version: 'version',
rootContextObject: { traceparent: '00-a14c8519972996a2a0748f2c8db5a775-4ad8bd26672a01b0-01' },
SpanProcessor: BatchSpanProcessor,
})
// @ts-expect-error
expect(tel.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b0')
tel.setRootContext()
// @ts-expect-error
expect(tel.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b0')
tel.setRootContext({})
// @ts-expect-error
expect(tel.rootContext?.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))._spanContext.spanId).to.equal('4ad8bd26672a01b0')
})
})

View File

@@ -0,0 +1,198 @@
import { expect } from 'chai'
import { telemetry, encodeTelemetryContext, decodeTelemetryContext } from '../src/node'
import { OTLPTraceExporter as OTLPTraceExporterCloud } from '../src/span-exporters/cloud-span-exporter'
describe('telemetry is disabled', () => {
describe('init', () => {
it('does not throw', () => {
const exporter = new OTLPTraceExporterCloud()
expect(telemetry.init({
namespace: 'namespace',
version: 'version',
exporter,
})).to.not.throw
})
})
describe('isEnabled', () => {
it('returns false', () => {
expect(telemetry.isEnabled()).to.be.false
})
})
describe('startSpan', () => {
it('returns undefined', () => {
expect(telemetry.startSpan({ name: 'nope' })).to.be.undefined
})
})
describe('getSpan', () => {
it('returns undefined', () => {
expect(telemetry.getSpan('nope')).to.be.undefined
})
})
describe('findActiveSpan', () => {
it('returns undefined', () => {
expect(telemetry.findActiveSpan((span) => true)).to.be.undefined
})
})
describe('endActiveSpanAndChildren', () => {
it('does not throw', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.endActiveSpanAndChildren(spanny)).to.not.throw
})
})
describe('getActiveContextObject', () => {
it('returns an empty object', () => {
expect(telemetry.getActiveContextObject().traceparent).to.be.undefined
})
})
describe('getResources', () => {
it('returns an empty object', () => {
expect(telemetry.getResources()).to.not.be.undefined
})
})
describe('shutdown', () => {
it('does not throw', () => {
expect(telemetry.shutdown()).to.not.throw
})
})
describe('exporter', () => {
it('returns undefined', () => {
expect(telemetry.exporter()).to.be.undefined
})
})
})
describe('telemetry is enabled', () => {
before('init', () => {
process.env.CYPRESS_INTERNAL_ENABLE_TELEMETRY = 'true'
const exporter = new OTLPTraceExporterCloud()
expect(telemetry.init({
namespace: 'namespace',
version: 'version',
exporter,
})).to.not.throw
})
describe('isEnabled', () => {
it('returns true', () => {
expect(telemetry.isEnabled()).to.be.true
})
})
describe('startSpan', () => {
it('returns undefined', () => {
expect(telemetry.startSpan({ name: 'nope' })).to.exist
})
})
describe('getSpan', () => {
it('returns undefined', () => {
telemetry.startSpan({ name: 'nope' })
expect(telemetry.getSpan('nope')).to.be.exist
})
})
describe('findActiveSpan', () => {
it('returns undefined', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.findActiveSpan((span) => true)).to.be.exist
spanny?.end()
})
})
describe('endActiveSpanAndChildren', () => {
it('does not throw', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.endActiveSpanAndChildren(spanny)).to.not.throw
expect(telemetry.getActiveContextObject().traceparent).to.be.undefined
})
})
describe('getActiveContextObject', () => {
it('returns an empty object', () => {
const spanny = telemetry.startSpan({ name: 'active', active: true })
expect(telemetry.getActiveContextObject().traceparent).to.exist
spanny?.end()
})
})
describe('getResources', () => {
it('returns an empty object', () => {
expect(telemetry.getResources()).to.include({
'service.name': 'cypress-app',
'telemetry.sdk.language': 'nodejs',
'telemetry.sdk.name': 'opentelemetry',
'service.namespace': 'namespace',
'service.version': 'version',
})
})
})
describe('shutdown', () => {
it('does not throw', () => {
expect(telemetry.shutdown()).to.not.throw
})
})
describe('exporter', () => {
it('returns undefined', () => {
expect(telemetry.exporter()).to.exist
})
})
describe('init', () => {
it('throws if called more than once', () => {
const exporter = new OTLPTraceExporterCloud()
try {
telemetry.init({
namespace: 'namespace',
version: 'version',
exporter,
})
} catch (err) {
expect(err).to.equal('Telemetry instance has already be initialized')
}
})
})
})
describe('encode/decode', () => {
it('encodes and decodes telemetry context', () => {
const context = {
context: { traceparent: 'abc' },
version: '123',
}
const decodedContext = decodeTelemetryContext(encodeTelemetryContext(context))
expect(decodedContext.context.traceparent).to.equal(context.context.traceparent)
expect(decodedContext.version).to.equal(context.version)
})
it('it does not throw if passed an empty context', () => {
const context = {
}
const decodedContext = decodeTelemetryContext(encodeTelemetryContext(context))
expect(decodedContext.context).to.be.undefined
expect(decodedContext.version).to.be.undefined
})
})

View File

@@ -0,0 +1,399 @@
import { expect } from 'chai'
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '../../src/span-exporters/cloud-span-exporter'
const genericRequest = { encryption: { encryptRequest: ({ url, method, body }: {url: string, method: string, body: string}) => Promise.resolve({ jwe: 'req' }) } }
describe('cloudSpanExporter', () => {
describe('new', () => {
it('sets encrypted header if set', () => {
const exporter = new OTLPTraceExporter(genericRequest)
expect(exporter.headers['x-cypress-encrypted']).to.equal('1')
expect(exporter.enc).to.not.be.undefined
})
it('does not set encrypted header if not set', () => {
const exporter = new OTLPTraceExporter()
expect(exporter.headers['x-cypress-encrypted']).to.be.undefined
expect(exporter.enc).to.be.undefined
})
})
describe('attachProjectId', () => {
it('sets the project id header', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.setAuthorizationHeader = () => {
callCount++
}
expect(exporter.headers['x-project-id']).to.be.undefined
expect(exporter.projectId).to.be.undefined
exporter.attachProjectId('123')
expect(exporter.headers['x-project-id']).to.equal('123')
expect(exporter.projectId).to.equal('123')
expect(callCount).to.equal(1)
})
it('does nothing if id is not passed', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.setAuthorizationHeader = () => {
callCount++
}
expect(exporter.headers['x-project-id']).to.be.undefined
expect(exporter.projectId).to.be.undefined
exporter.attachProjectId(undefined)
expect(exporter.headers['x-project-id']).to.be.undefined
expect(exporter.projectId).to.be.undefined
expect(callCount).to.equal(0)
})
})
describe('attachRecordKey', () => {
it('sets the record key header', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.setAuthorizationHeader = () => {
callCount++
}
expect(exporter.recordKey).to.be.undefined
exporter.attachRecordKey('123')
expect(exporter.recordKey).to.equal('123')
expect(callCount).to.equal(1)
})
it('does nothing if record key is not passed', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.setAuthorizationHeader = () => {
callCount++
}
expect(exporter.recordKey).to.be.undefined
exporter.attachRecordKey(undefined)
expect(exporter.recordKey).to.be.undefined
expect(callCount).to.equal(0)
})
})
describe('setAuthorizationHeader', () => {
it('sets the header if projectId and recordKey are present', () => {
const exporter = new OTLPTraceExporter()
exporter.projectId = '123'
exporter.recordKey = '456'
exporter.setAuthorizationHeader()
const authorization = exporter.headers.Authorization
console.log('auth', authorization)
// MTIzOjQ1Ng== is 123:456 base64 encoded
expect(authorization).to.equal(`Basic MTIzOjQ1Ng==`)
})
})
describe('sendDelayedItems', () => {
it('does not send if both project id and record key are not set', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.send = () => {
callCount++
}
exporter.delayedItemsToExport.push({
serviceRequest: 'req',
onSuccess: () => {},
onError: () => {},
})
exporter.sendDelayedItems()
expect(callCount).to.equal(0)
expect(exporter.delayedItemsToExport.length).to.equal(1)
})
it('does not send if project id is not set', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.send = () => {
callCount++
}
exporter.delayedItemsToExport.push({
serviceRequest: 'req',
onSuccess: () => {},
onError: () => {},
})
exporter.attachRecordKey('123')
exporter.sendDelayedItems()
expect(callCount).to.equal(0)
expect(exporter.delayedItemsToExport.length).to.equal(1)
})
it('does not send if record key is not set', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.send = () => {
callCount++
}
exporter.delayedItemsToExport.push({
serviceRequest: 'req',
onSuccess: () => {},
onError: () => {},
})
exporter.attachProjectId('123')
exporter.sendDelayedItems()
expect(callCount).to.equal(0)
expect(exporter.delayedItemsToExport.length).to.equal(1)
})
it('does send if record key and project id are set', () => {
const exporter = new OTLPTraceExporter()
let callCount = 0
exporter.send = () => {
callCount++
}
exporter.delayedItemsToExport.push({
serviceRequest: 'req',
onSuccess: () => {},
onError: () => {},
})
exporter.attachProjectId('123')
exporter.attachRecordKey('123')
exporter.sendDelayedItems()
expect(callCount).to.equal(1)
expect(exporter.delayedItemsToExport.length).to.equal(0)
})
})
describe('send', () => {
it('returns if shutdownOnce.isCalled is true', () => {
const exporter = new OTLPTraceExporter()
exporter.convert = (objects) => {
throw 'convert should not be called'
}
exporter.sendWithHttp = (collector, body, contentType, resolve, reject) => {
throw 'sendWithHTTP should not be called'
}
const onSuccess = () => {
throw 'onSuccess should not be called'
}
const onError = () => {
throw 'onError should not be called'
}
// @ts-expect-error
exporter._shutdownOnce = { isCalled: true }
expect(exporter.send([{ name: 'string' }] as ReadableSpan[], onSuccess, onError)).to.be.undefined
})
it('sends a string', (done) => {
const exporter = new OTLPTraceExporter()
exporter.convert = (objects) => {
throw 'convert should not be called'
}
exporter.sendWithHttp = (collector, body, contentType, resolve, reject) => {
expect(collector).to.not.be.undefined
expect(body).to.equal('string')
expect(contentType).to.equal('application/json')
expect(resolve).to.not.be.undefined
expect(reject).to.not.be.undefined
resolve()
}
const onSuccess = () => {
done()
}
const onError = () => {
throw 'onError should not be called'
}
exporter.send('string', onSuccess, onError)
})
it('sends an array of readable spans', (done) => {
const exporter = new OTLPTraceExporter()
// @ts-expect-error
exporter.convert = (objects) => {
expect(objects[0].name).to.equal('string')
return 'string'
}
exporter.sendWithHttp = (collector, body, contentType, resolve, reject) => {
expect(collector).to.not.be.undefined
expect(body).to.equal(JSON.stringify('string'))
expect(contentType).to.equal('application/json')
expect(resolve).to.not.be.undefined
expect(reject).to.not.be.undefined
resolve()
}
const onSuccess = () => {
done()
}
const onError = () => {
throw 'onError should not be called'
}
exporter.send([{ name: 'string' }] as ReadableSpan[], onSuccess, onError)
})
it('fails to send the request', (done) => {
const exporter = new OTLPTraceExporter()
// @ts-expect-error
exporter.convert = (objects) => {
expect(objects[0].name).to.equal('string')
return 'string'
}
exporter.sendWithHttp = (collector, body, contentType, resolve, reject) => {
expect(collector).to.not.be.undefined
expect(body).to.equal(JSON.stringify('string'))
expect(contentType).to.equal('application/json')
expect(resolve).to.not.be.undefined
expect(reject).to.not.be.undefined
// @ts-expect-error;'
reject('err')
}
const onSuccess = () => {
throw 'onSuccess should not be called'
}
const onError = (err) => {
expect(err).to.equal('err')
done()
}
exporter.send([{ name: 'string' }] as ReadableSpan[], onSuccess, onError)
})
it('encrypts the request', (done) => {
const encryption = {
encryptRequest: ({ url, method, body }) => {
expect(body).to.equal('string')
return Promise.resolve({ jwe: 'encrypted' })
},
}
const exporter = new OTLPTraceExporter({
encryption,
headers: {
Authorization: `Basic ${Buffer.from((`${123}:${456}`)).toString('base64')}`,
},
})
exporter.convert = (objects) => {
throw 'convert should not be called'
}
exporter.sendWithHttp = (collector, body, contentType, resolve, reject) => {
expect(collector).to.not.be.undefined
expect(body).to.equal(JSON.stringify('encrypted'))
expect(contentType).to.equal('application/json')
expect(resolve).to.not.be.undefined
expect(reject).to.not.be.undefined
resolve()
}
const onSuccess = () => {
done()
}
const onError = () => {
throw 'onError should not be called'
}
exporter.send('string', onSuccess, onError)
})
it('delays the request if encryption is enabled authorization is not present', () => {
const encryption = {
encryptRequest: ({ url, method, body }) => {
throw 'encryptRequest should not be called'
},
}
const exporter = new OTLPTraceExporter({
encryption,
})
exporter.convert = (objects) => {
throw 'convert should not be called'
}
exporter.sendWithHttp = (collector, body, contentType, resolve, reject) => {
throw 'sendWithHttp should not be called'
}
const onSuccess = () => {
throw 'onSuccess should not be called'
}
const onError = () => {
throw 'onError should not be called'
}
expect(exporter.delayedItemsToExport.length).to.equal(0)
exporter.send('string', onSuccess, onError)
expect(exporter.delayedItemsToExport.length).to.equal(1)
expect(exporter.delayedItemsToExport[0].serviceRequest).to.equal('string')
})
})
})

View File

@@ -0,0 +1,150 @@
import { expect } from 'chai'
import { OTLPTraceExporter } from '../../src/span-exporters/ipc-span-exporter'
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
describe('ipcSpanExporter', () => {
describe('new', () => {
it('new sets delayedExport to an empty array', () => {
const exporter = new OTLPTraceExporter()
expect(exporter.delayedExport.length).to.equal(0)
})
})
describe('attachIPC', () => {
it('attaches the supplied ipc', () => {
const exporter = new OTLPTraceExporter()
exporter.delayedExport.push({ items: [{ name: 'span' }] as ReadableSpan[], resultCallback: () => {} })
exporter.export = (items, resultCallback) => {
expect(items[0].name).to.equal('span')
}
exporter.attachIPC({ name: 'ipc', send: () => {} })
expect(exporter.ipc.name).to.equal('ipc')
})
})
describe('export', () => {
it('delays export if ipc is not present', () => {
const exporter = new OTLPTraceExporter()
exporter.send = () => {
throw 'send should not be called'
}
expect(exporter.delayedExport.length).to.equal(0)
exporter.export([{ name: 'span' }] as ReadableSpan[], (result) => {})
expect(exporter.delayedExport.length).to.equal(1)
expect(exporter.delayedExport[0].items[0].name).to.equal('span')
})
it('does not delay if ipc is present', () => {
const exporter = new OTLPTraceExporter()
exporter.ipc = { name: 'ipc', send: () => {} }
exporter.send = (objects, onSuccess, onError) => {
expect(objects[0].name).to.equal('span')
expect(onSuccess).to.not.be.undefined
expect(onError).to.not.be.undefined
}
expect(exporter.delayedExport.length).to.equal(0)
exporter.export([{ name: 'span' }] as ReadableSpan[], (result) => {})
expect(exporter.delayedExport.length).to.equal(0)
})
})
describe('send', () => {
it('returns if shutdownOnce.isCalled is true', () => {
const exporter = new OTLPTraceExporter()
exporter.convert = (objects) => {
throw 'convert should not be called'
}
exporter.ipc = {
send: (event, request) => {
throw 'sendWithHTTP should not be called'
},
}
const onSuccess = () => {
throw 'onSuccess should not be called'
}
const onError = () => {
throw 'onError should not be called'
}
// @ts-expect-error
exporter._shutdownOnce = { isCalled: true }
expect(exporter.send([{ name: 'string' }] as ReadableSpan[], onSuccess, onError)).to.be.undefined
})
it('sends via ipc', (done) => {
const exporter = new OTLPTraceExporter()
// @ts-expect-error
exporter.convert = (objects) => {
expect(objects[0].name).to.equal('span')
return 'span'
}
exporter.ipc = {
send: (event, request) => {
expect(event).to.equal('export:telemetry')
expect(request).to.equal(JSON.stringify('span'))
},
}
const onSuccess = () => {
done()
}
const onError = () => {
throw 'onError should not be called'
}
exporter.send([{ name: 'span' }] as ReadableSpan[], onSuccess, onError)
})
it('handles an exception in the ipc send command', (done) => {
const exporter = new OTLPTraceExporter()
// @ts-expect-error
exporter.convert = (objects) => {
expect(objects[0].name).to.equal('span')
return 'span'
}
exporter.ipc = {
send: (event, request) => {
throw 'ipc error'
},
}
const onSuccess = () => {
done()
}
const onError = (err) => {
expect(err).to.equal('ipc error')
done()
}
exporter.send([{ name: 'span' }] as ReadableSpan[], onSuccess, onError)
})
})
})

View File

@@ -0,0 +1,161 @@
import { expect } from 'chai'
import { OTLPTraceExporter } from '../../src/span-exporters/websocket-span-exporter'
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'
describe('ipcSpanExporter', () => {
describe('new', () => {
it('new sets delayedExport to an empty array', () => {
const exporter = new OTLPTraceExporter()
expect(exporter.delayedExport.length).to.equal(0)
})
})
describe('attachWebSocket', () => {
it('attaches the supplied ipc', () => {
const exporter = new OTLPTraceExporter()
exporter.delayedExport.push({ items: [{ name: 'span' }] as ReadableSpan[], resultCallback: () => {} })
exporter.export = (items, resultCallback) => {
expect(items[0].name).to.equal('span')
}
exporter.attachWebSocket({ name: 'socket', emit: () => {} })
expect(exporter.ws.name).to.equal('socket')
})
})
describe('export', () => {
it('delays export if ws is not present', () => {
const exporter = new OTLPTraceExporter()
exporter.send = () => {
throw 'send should not be called'
}
expect(exporter.delayedExport.length).to.equal(0)
exporter.export([{ name: 'span' }] as ReadableSpan[], (result) => {})
expect(exporter.delayedExport.length).to.equal(1)
expect(exporter.delayedExport[0].items[0].name).to.equal('span')
})
it('does not delay if ws is present', () => {
const exporter = new OTLPTraceExporter()
exporter.ws = { name: 'ws', emit: () => {} }
exporter.send = (objects, onSuccess, onError) => {
expect(objects[0].name).to.equal('span')
expect(onSuccess).to.not.be.undefined
expect(onError).to.not.be.undefined
}
expect(exporter.delayedExport.length).to.equal(0)
exporter.export([{ name: 'span' }] as ReadableSpan[], (result) => {})
expect(exporter.delayedExport.length).to.equal(0)
})
})
describe('send', () => {
it('returns if shutdownOnce.isCalled is true', () => {
const exporter = new OTLPTraceExporter()
exporter.convert = (objects) => {
throw 'convert should not be called'
}
exporter.ws = {
emit: (event, subEvent, request, callback) => {
throw 'sendWithHTTP should not be called'
},
}
const onSuccess = () => {
throw 'onSuccess should not be called'
}
const onError = () => {
throw 'onError should not be called'
}
// @ts-expect-error
exporter._shutdownOnce = { isCalled: true }
expect(exporter.send([{ name: 'string' }] as ReadableSpan[], onSuccess, onError)).to.be.undefined
})
it('sends via websocket', (done) => {
const exporter = new OTLPTraceExporter()
// @ts-expect-error
exporter.convert = (objects) => {
expect(objects[0].name).to.equal('span')
return 'span'
}
exporter.ws = {
emit: (event, subEvent, request, callback) => {
expect(event).to.equal('backend:request')
expect(subEvent).to.equal('telemetry')
expect(request).to.equal(JSON.stringify('span'))
expect(callback).to.not.be.undefined
callback({})
},
}
const onSuccess = () => {
done()
}
const onError = () => {
throw 'onError should not be called'
}
exporter.send([{ name: 'span' }] as ReadableSpan[], onSuccess, onError)
})
it('handles an exception in the ipc send command', (done) => {
const exporter = new OTLPTraceExporter()
// @ts-expect-error
exporter.convert = (objects) => {
expect(objects[0].name).to.equal('span')
return 'span'
}
exporter.ws = {
emit: (event, subEvent, request, callback) => {
expect(event).to.equal('backend:request')
expect(subEvent).to.equal('telemetry')
expect(request).to.equal(JSON.stringify('span'))
expect(callback).to.not.be.undefined
callback({
res: {
error: 'this broke',
},
})
},
}
const onSuccess = () => {
done()
}
const onError = (err) => {
expect(err).to.equal('this broke')
done()
}
exporter.send([{ name: 'span' }] as ReadableSpan[], onSuccess, onError)
})
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../ts/tsconfig.json",
"include": ["src"],
"compilerOptions": {
"outDir": "./dist",
"lib": ["esnext", "DOM"],
"strict": true,
"allowJs": false,
"noImplicitAny": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"noUnusedLocals": false,
"noUncheckedIndexedAccess": true,
"types": ["cypress"],
}
}

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