mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-21 06:30:49 -06:00
Merge pull request #26494 from cypress-io/ryanm/chore/merge-develop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
2
.github/workflows/triage_add_to_project.yml
vendored
2
.github/workflows/triage_add_to_project.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'] = `
|
||||
[33m⚠[39m 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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
10
packages/app/index.d.ts
vendored
10
packages/app/index.d.ts
vendored
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
39
packages/app/src/runner/events/telemetry.ts
Normal file
39
packages/app/src/runner/events/telemetry.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`)
|
||||
|
||||
@@ -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`, () => {
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1
packages/driver/types/internal-types.d.ts
vendored
1
packages/driver/types/internal-types.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -63,4 +63,5 @@ export const stubWizard: MaybeResolver<Wizard> = {
|
||||
isDetected: false,
|
||||
}
|
||||
}),
|
||||
erroredFrameworks: [],
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
71
packages/scaffold-config/src/searchUtils.ts
Normal file
71
packages/scaffold-config/src/searchUtils.ts
Normal 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 })
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
105
packages/scaffold-config/test/unit/searchUtils.spec.ts
Normal file
105
packages/scaffold-config/test/unit/searchUtils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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[] = []) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ???
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')()
|
||||
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
291
packages/telemetry/README.md
Normal file
291
packages/telemetry/README.md
Normal 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)
|
||||
36
packages/telemetry/package.json
Normal file
36
packages/telemetry/package.json
Normal 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"
|
||||
}
|
||||
85
packages/telemetry/src/browser.ts
Normal file
85
packages/telemetry/src/browser.ts
Normal 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)),
|
||||
}
|
||||
291
packages/telemetry/src/index.ts
Normal file
291
packages/telemetry/src/index.ts
Normal 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 () {}
|
||||
}
|
||||
92
packages/telemetry/src/node.ts
Normal file
92
packages/telemetry/src/node.ts
Normal 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')
|
||||
}
|
||||
160
packages/telemetry/src/span-exporters/cloud-span-exporter.ts
Normal file
160
packages/telemetry/src/span-exporters/cloud-span-exporter.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
86
packages/telemetry/src/span-exporters/ipc-span-exporter.ts
Normal file
86
packages/telemetry/src/span-exporters/ipc-span-exporter.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
9
packages/telemetry/test/.mocharc.js
Normal file
9
packages/telemetry/test/.mocharc.js
Normal 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'],
|
||||
}
|
||||
181
packages/telemetry/test/browser.spec.ts
Normal file
181
packages/telemetry/test/browser.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
372
packages/telemetry/test/index.spec.ts
Normal file
372
packages/telemetry/test/index.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
198
packages/telemetry/test/node.spec.ts
Normal file
198
packages/telemetry/test/node.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
150
packages/telemetry/test/span-exporters/ipc-span-exporter.spec.ts
Normal file
150
packages/telemetry/test/span-exporters/ipc-span-exporter.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
16
packages/telemetry/tsconfig.json
Normal file
16
packages/telemetry/tsconfig.json
Normal 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
Reference in New Issue
Block a user