diff --git a/.github/workflows/snyk_sca_scan.yaml b/.github/workflows/snyk_sca_scan.yaml new file mode 100644 index 000000000000..d9b21b0ab8e2 --- /dev/null +++ b/.github/workflows/snyk_sca_scan.yaml @@ -0,0 +1,33 @@ +name: Snyk Software Composition Analysis Scan +# This git workflow leverages Snyk actions to perform a Software Composition +# Analysis scan on our Opensource libraries upon Pull Requests to Master & +# Develop branches. We use this as a control to prevent vulnerable packages +# from being introduced into the codebase. +on: + pull_request_target: + types: + - opened + branches: + - master + - develop +jobs: + Snyk_SCA_Scan: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.x] + steps: + - uses: actions/checkout@v2 + - name: Setting up Node + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Installing snyk-delta and dependencies + run: npm i -g snyk-delta + - uses: snyk/actions/setup@master + - name: Perform SCA Scan + continue-on-error: false + run: | + snyk test --yarn-workspaces --strict-out-of-sync=false --detection-depth=6 --exclude=docker,Dockerfile --severity-threshold=critical + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} diff --git a/.github/workflows/snyk_static_analysis_scan.yaml b/.github/workflows/snyk_static_analysis_scan.yaml new file mode 100644 index 000000000000..f34b3de41e1c --- /dev/null +++ b/.github/workflows/snyk_static_analysis_scan.yaml @@ -0,0 +1,29 @@ +name: Snyk Static Analysis Scan +# This git workflow leverages Snyk actions to perform a Static Application +# Testing scan (SAST) on our first-party code upon Pull Requests to Master & +# Develop branches. We use this as a control to prevent vulnerabilities +# from being introduced into the codebase. +on: + pull_request_target: + types: + - opened + branches: + - master + - develop +jobs: + Snyk_SAST_Scan : + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: snyk/actions/setup@master + - name: Perform Static Analysis Test + continue-on-error: true + run: | + snyk code test --yarn-workspaces --strict-out-of-sync=false --detection-depth=6 --exclude=docker,Dockerfile --severity-threshold=high + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + # The Following Requires Advanced Security License + # - name: Upload results to Github Code Scanning + # uses: github/codeql-action/upload-sarif@v1 + # with: + # sarif_file: snyk_sarif diff --git a/.github/workflows/add_to_triage_project.yml b/.github/workflows/triage_add_to_project.yml similarity index 85% rename from .github/workflows/add_to_triage_project.yml rename to .github/workflows/triage_add_to_project.yml index 235874028d3c..4dcf519a2a17 100644 --- a/.github/workflows/add_to_triage_project.yml +++ b/.github/workflows/triage_add_to_project.yml @@ -1,10 +1,10 @@ -name: Add issue/PR to project +name: 'Triage: add issue/PR to project' on: issues: types: - opened - pull_request: + pull_request_target: types: - opened diff --git a/.github/workflows/triage_add_to_routed_project.yml b/.github/workflows/triage_add_to_routed_project.yml new file mode 100644 index 000000000000..6bb13d85fa3a --- /dev/null +++ b/.github/workflows/triage_add_to_routed_project.yml @@ -0,0 +1,39 @@ +name: 'Triage: route to team project board' +on: + issues: + types: + - labeled +jobs: + route-to-e2e: + if: github.event.label.name == 'routed-to-e2e' + runs-on: ubuntu-latest + steps: + - name: Get project data + env: + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_TOKEN }} + ORGANIZATION: 'cypress-io' + PROJECT_NUMBER: 10 + run: | + gh api graphql -f query=' + query($org: String!, $number: Int!) { + organization(login: $org){ + projectV2(number: $number) { + id + } + } + }' -f org=$ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json + + echo 'PROJECT_ID='$(jq -r '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV + - name: add issue to e2e project + env: + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_TOKEN }} + ISSUE_ID: ${{ github.event.issue.node_id }} + run: | + gh api graphql -f query=' + mutation($project:ID!, $issue:ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) { + item { + id + } + } + }' -f project=$PROJECT_ID -f issue=$ISSUE_ID diff --git a/.github/workflows/triage_closed_issue_comment.yml b/.github/workflows/triage_closed_issue_comment.yml new file mode 100644 index 000000000000..1caf8abdbb3a --- /dev/null +++ b/.github/workflows/triage_closed_issue_comment.yml @@ -0,0 +1,93 @@ +name: 'Triage: closed issue comment' +on: + issue_comment: + types: + - created +jobs: + move-to-new-issue-status: + if: | + !github.event.issue.pull_request && + github.event.issue.state == 'closed' && + github.event.comment.created_at != github.event.issue.closed_at && + github.event.sender.login != 'cypress-bot' + runs-on: ubuntu-latest + steps: + - name: Get project data + env: + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_TOKEN }} + ORGANIZATION: 'cypress-io' + REPOSITORY: 'cypress' + PROJECT_NUMBER: 9 + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + gh api graphql -f query=' + query($org: String!, $repo: String!, $project: Int!, $issue: Int!) { + organization(login: $org) { + repository(name: $repo) { + issue(number: $issue) { + projectItems(first: 10, includeArchived: false) { + nodes { + id + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { + project { + ... on ProjectV2 { + id + number + } + } + } + } + } + } + } + } + } + } + projectV2(number: $project) { + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { + id + name + } + } + } + } + } + }' -f org=$ORGANIZATION -f repo=$REPOSITORY -F issue=$ISSUE_NUMBER -F project=$PROJECT_NUMBER > project_data.json + + echo 'PROJECT_ID='$(jq -r '.data.organization.repository.issue.projectItems.nodes[].fieldValueByName.field.project | select(.number == ${{ env.PROJECT_NUMBER }}) | .id' project_data.json) >> $GITHUB_ENV + echo 'PROJECT_ITEM_ID='$(jq -r '.data.organization.repository.issue.projectItems.nodes[] | select(.fieldValueByName.field.project.number == ${{ env.PROJECT_NUMBER }}) | .id' project_data.json) >> $GITHUB_ENV + echo 'STATUS_FIELD_ID='$(jq -r '.data.organization.projectV2.field | .id' project_data.json) >> $GITHUB_ENV + echo 'STATUS='$(jq -r '.data.organization.repository.issue.projectItems.nodes[].fieldValueByName | select(.field.project.number == ${{ env.PROJECT_NUMBER }}) | .name' project_data.json) >> $GITHUB_ENV + echo 'NEW_ISSUE_OPTION_ID='$(jq -r '.data.organization.projectV2.field.options[] | select(.name== "New Issue") | .id' project_data.json) >> $GITHUB_ENV + - name: Move issue to New Issue status + env: + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_TOKEN }} + if: env.STATUS == 'Closed' + run: | + gh api graphql -f query=' + mutation ( + $project: ID! + $item: ID! + $status_field: ID! + $status_value: String! + ) { + updateProjectV2ItemFieldValue(input: { + projectId: $project + itemId: $item + fieldId: $status_field + value: { + singleSelectOptionId: $status_value + } + }) { + projectV2Item { + id + } + } + }' -f project=$PROJECT_ID -f item=$PROJECT_ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=$NEW_ISSUE_OPTION_ID diff --git a/.github/workflows/triage_issue_metrics.yml b/.github/workflows/triage_issue_metrics.yml new file mode 100644 index 000000000000..55c8101d3c65 --- /dev/null +++ b/.github/workflows/triage_issue_metrics.yml @@ -0,0 +1,114 @@ +name: 'Triage: issue metrics' + +on: + workflow_dispatch: + inputs: + startDate: + description: 'Start date (YYYY-MM-DD)' + type: date + endDate: + description: 'End date (YYYY-MM-DD)' + type: date +jobs: + seven-day-close: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v6 + env: + ORGANIZATION: 'cypress-io' + REPOSITORY: 'cypress' + PROJECT_NUMBER: 9 + with: + github-token: ${{ secrets.ADD_TO_PROJECT_TOKEN }} + script: | + const ROUTED_TO_LABELS = ['routed-to-e2e', 'routed-to-ct'] + const MS_PER_DAY = 1000 * 60 * 60 * 24 + const { REPOSITORY, ORGANIZATION, PROJECT_NUMBER } = process.env + + const issues = [] + + const determineDateRange = () => { + const inputStartDate = '${{ inputs.startDate }}' + const inputEndDate = '${{ inputs.endDate }}' + + if (inputStartDate && inputEndDate) { + return { startDate: inputStartDate, endDate: inputEndDate } + } + + if (inputStartDate || inputEndDate) { + core.setFailed('Both startDate and endDate are required if one is provided.') + } + + const startDate = new Date() + + startDate.setDate(startDate.getDate() - 6) + + return { startDate: startDate.toISOString().split('T')[0], endDate: (new Date()).toISOString().split('T')[0] } + } + + const dateRange = determineDateRange() + const query = `is:issue+repo:${ORGANIZATION}/${REPOSITORY}+project:${ORGANIZATION}/${PROJECT_NUMBER}+created:${dateRange.startDate}..${dateRange.endDate}` + + const findLabelDateTime = async (issueNumber) => { + const iterator = github.paginate.iterator(github.rest.issues.listEventsForTimeline, { + owner: ORGANIZATION, + repo: REPOSITORY, + issue_number: issueNumber, + }) + + for await (const { data: timelineData } of iterator) { + for (const timelineItem of timelineData) { + if (timelineItem.event === 'labeled' && ROUTED_TO_LABELS.includes(timelineItem.label.name)) { + return timelineItem.created_at + } + } + } + } + + const calculateElapsedDays = (createdAt, routedOrClosedAt) => { + return Math.round((new Date(routedOrClosedAt) - new Date(createdAt)) / MS_PER_DAY, 0) + } + + const iterator = github.paginate.iterator(github.rest.search.issuesAndPullRequests, { + q: query, + per_page: 100, + }) + + for await (const { data } of iterator) { + for (const issue of data) { + let routedOrClosedAt + + if (!issue.pull_request) { + const routedLabel = issue.labels.find((label) => ROUTED_TO_LABELS.includes(label.name)) + + if (routedLabel) { + routedOrClosedAt = await findLabelDateTime(issue.number) + } else if (issue.state === 'closed') { + routedOrClosedAt = issue.closed_at + } + + let elapsedDays + + if (routedOrClosedAt) { + elapsedDays = calculateElapsedDays(issue.created_at, routedOrClosedAt) + } + + issues.push({ + number: issue.number, + title: issue.title, + state: issue.state, + url: issue.html_url, + createdAt: issue.created_at, + routedOrClosedAt, + elapsedDays, + }) + } + } + } + + const issuesRoutedOrClosedIn7Days = issues.filter((issue) => issue.elapsedDays <= 7).length + const percentage = Number(issues.length > 0 ? issuesRoutedOrClosedIn7Days / issues.length : 0).toLocaleString(undefined, { style: 'percent', minimumFractionDigits: 2 }) + + console.log(`Triage Metrics (${dateRange.startDate} - ${dateRange.endDate})`) + console.log('Total issues:', issues.length) + console.log(`Issues routed/closed within 7 days: ${issuesRoutedOrClosedIn7Days} (${percentage})`) diff --git a/browser-versions.json b/browser-versions.json index ba37d9cb46b0..3152d1338694 100644 --- a/browser-versions.json +++ b/browser-versions.json @@ -1,4 +1,5 @@ { "chrome:beta": "105.0.5195.28", - "chrome:stable": "104.0.5112.101" + "chrome:stable": "104.0.5112.101", + "chrome:minimum": "64.0.3282.0" } diff --git a/circle.yml b/circle.yml index ac2a45859dbf..e35a6ceeefc3 100644 --- a/circle.yml +++ b/circle.yml @@ -27,6 +27,7 @@ mainBuildFilters: &mainBuildFilters branches: only: - develop + - fix-ci-deps # 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 @@ -35,8 +36,7 @@ macWorkflowFilters: &darwin-workflow-filters when: or: - equal: [ develop, << pipeline.git.branch >> ] - - equal: [ 'tbiethman/22272-globbing-working-dir', << pipeline.git.branch >> ] - - equal: [ 'skip-or-fix-flaky-tests-2', << pipeline.git.branch >> ] + - equal: [ 'correct-dashboard-results', << pipeline.git.branch >> ] - matches: pattern: "-release$" value: << pipeline.git.branch >> @@ -45,8 +45,6 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters when: or: - equal: [ develop, << pipeline.git.branch >> ] - - equal: [ "lmiller/experimental-single-tab-component-testing", << pipeline.git.branch >> ] - - equal: [ 'skip-or-fix-flaky-tests-2', << pipeline.git.branch >> ] - matches: pattern: "-release$" value: << pipeline.git.branch >> @@ -66,8 +64,7 @@ windowsWorkflowFilters: &windows-workflow-filters or: - equal: [ develop, << pipeline.git.branch >> ] - equal: [ linux-arm64, << pipeline.git.branch >> ] - - equal: [ 'marktnoonan/windows-path-fix', << pipeline.git.branch >> ] - - equal: [ 'skip-or-fix-flaky-tests-2', << pipeline.git.branch >> ] + - equal: [ 'lmiller/fixing-flake-1', << pipeline.git.branch >> ] - matches: pattern: "-release$" value: << pipeline.git.branch >> @@ -132,7 +129,7 @@ commands: - run: name: Check current branch to persist artifacts command: | - if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "tbiethman/23380-root-spec-pattern" ]]; then + if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "webkit-experimental" ]]; then echo "Not uploading artifacts or posting install comment for this branch." circleci-agent step halt fi @@ -178,6 +175,14 @@ commands: mv ~/cypress/system-tests/node_modules /tmp/node_modules_cache/system-tests_node_modules mv ~/cypress/globbed_node_modules /tmp/node_modules_cache/globbed_node_modules + install-webkit-deps: + steps: + - run: + name: Install WebKit dependencies + command: | + npx playwright install webkit + npx playwright install-deps webkit + build-and-persist: description: Save entire folder as artifact for other jobs to run without reinstalling steps: @@ -462,6 +467,11 @@ commands: - install-chrome: channel: <> version: $(node ./scripts/get-browser-version.js chrome:<>) + - when: + condition: + equal: [ webkit, << parameters.browser >> ] + steps: + - install-webkit-deps - run: name: Run driver tests in Cypress environment: @@ -470,11 +480,6 @@ commands: echo Current working directory is $PWD echo Total containers $CIRCLE_NODE_TOTAL - if [[ "<>" = "webkit" ]]; then - npx playwright install webkit - npx playwright install-deps webkit - fi - if [[ -v MAIN_RECORD_KEY ]]; then # internal PR if <>; then @@ -610,6 +615,11 @@ commands: steps: - restore_cached_workspace - restore_cached_system_tests_deps + - when: + condition: + equal: [ webkit, << parameters.browser >> ] + steps: + - install-webkit-deps - run: name: Run system tests command: | @@ -1272,9 +1282,12 @@ jobs: run-webpack-dev-server-integration-tests, run-vite-dev-server-integration-tests - run: + # Sometimes, even though all the circle jobs have finished, Percy times out during `build:finalize` + # If all other jobs finish but `build:finalize` fails, we retry it once + name: Finalize percy build - allows single retry command: | PERCY_PARALLEL_NONCE=$CIRCLE_WORKFLOW_WORKSPACE_ID \ - yarn percy build:finalize + yarn percy build:finalize || yarn percy build:finalize cli-visual-tests: <<: *defaults @@ -1324,7 +1337,7 @@ jobs: steps: - run: yarn test-scripts # make sure packages with TypeScript can be transpiled to JS - - run: yarn lerna run build-prod --stream + - run: yarn lerna run build-prod --stream --concurrency 4 # run unit tests from each individual package - run: yarn test # run type checking for each individual package @@ -1448,6 +1461,13 @@ jobs: - run-system-tests: browser: firefox + system-tests-webkit: + <<: *defaults + parallelism: 8 + steps: + - run-system-tests: + browser: webkit + system-tests-non-root: <<: *defaults steps: @@ -2363,6 +2383,10 @@ linux-x64-workflow: &linux-x64-workflow context: test-runner:performance-tracking requires: - system-tests-node-modules-install + - system-tests-webkit: + context: test-runner:performance-tracking + requires: + - system-tests-node-modules-install - system-tests-non-root: context: test-runner:performance-tracking executor: non-root-docker-user diff --git a/cli/package.json b/cli/package.json index b350b423acba..0cb51269c791 100644 --- a/cli/package.json +++ b/cli/package.json @@ -38,7 +38,7 @@ "dayjs": "^1.10.4", "debug": "^4.3.2", "enquirer": "^2.3.6", - "eventemitter2": "^6.4.3", + "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", "extract-zip": "2.0.1", diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index a9c7e65d5b92..8906b3530738 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -82,7 +82,7 @@ declare namespace Cypress { type BrowserChannel = 'stable' | 'canary' | 'beta' | 'dev' | 'nightly' | string - type BrowserFamily = 'chromium' | 'firefox' + type BrowserFamily = 'chromium' | 'firefox' | 'webkit' /** * Describes a browser Cypress can control @@ -2720,6 +2720,13 @@ declare namespace Cypress { * @default 60000 */ pageLoadTimeout: number + /** + * Whether Cypress will search for and replace + * obstructive JS code in .js or .html files. + * + * @see https://on.cypress.io/configuration#modifyObstructiveCode + */ + modifyObstructiveCode: boolean /** * Time, in milliseconds, to wait for an XHR request to go out in a [cy.wait()](https://on.cypress.io/wait) command * @default 5000 @@ -2876,6 +2883,11 @@ declare namespace Cypress { * @default false */ experimentalStudio: boolean + /** + * Adds support for testing in the WebKit browser engine used by Safari. See https://on.cypress.io/webkit-experiment for more information. + * @default false + */ + experimentalWebKitSupport: boolean /** * Number of times to retry a failed test. * If a number is set, tests will retry in both runMode and openMode. @@ -2968,13 +2980,6 @@ declare namespace Cypress { * Whether Cypress was launched via 'cypress open' (interactive mode) */ isInteractive: boolean - /** - * Whether Cypress will search for and replace - * obstructive JS code in .js or .html files. - * - * @see https://on.cypress.io/configuration#modifyObstructiveCode - */ - modifyObstructiveCode: boolean /** * The platform Cypress is running on. */ diff --git a/cli/types/tests/cypress-npm-api-test.ts b/cli/types/tests/cypress-npm-api-test.ts index 4e968fc3ea75..3a0feebd7df6 100644 --- a/cli/types/tests/cypress-npm-api-test.ts +++ b/cli/types/tests/cypress-npm-api-test.ts @@ -1,6 +1,6 @@ // type tests for Cypress NPM module // https://on.cypress.io/module-api -import cypress from 'cypress' +import cypress, { defineConfig } from 'cypress' cypress.run // $ExpectType (options?: Partial | undefined) => Promise cypress.open // $ExpectType (options?: Partial | undefined) => Promise @@ -51,6 +51,10 @@ cypress.run().then(results => { } }) +const config = defineConfig({ + modifyObstructiveCode: true +}) + // component options const componentConfigNextWebpack: Cypress.ConfigOptions = { component: { diff --git a/guides/release-process.md b/guides/release-process.md index ad06cbb37c7d..10f52f058f3a 100644 --- a/guides/release-process.md +++ b/guides/release-process.md @@ -77,7 +77,7 @@ In the following instructions, "X.Y.Z" is used to denote the [next version of Cy 3. If there is a new [`cypress-example-kitchensink`](https://github.com/cypress-io/cypress-example-kitchensink/releases) version, update the corresponding dependency in [`packages/example`](../packages/example) to that new version. -4. Once the `develop` branch is passing for all test projects with the new changes and the `linux-x64` binary is present at `https://cdn.cypress.io/beta/binary/X.Y.Z/linux-x64//cypress.zip`, and the `linux-x64` cypress npm package is present at `https://cdn.cypress.io/beta/binary/X.Y.Z/linux-x64//cypress.tgz`, publishing can proceed. +4. Once the `develop` branch is passing for all test projects with the new changes and the `linux-x64` binary is present at `https://cdn.cypress.io/beta/binary/X.Y.Z/linux-x64/develop-/cypress.zip`, and the `linux-x64` cypress npm package is present at `https://cdn.cypress.io/beta/npm/X.Y.Z/linux-x64/develop-/cypress.tgz`, publishing can proceed. 5. Install and test the pre-release version to make sure everything is working. - Get the pre-release version that matches your system from the latest develop commit. @@ -169,7 +169,7 @@ In the following instructions, "X.Y.Z" is used to denote the [next version of Cy git pull origin develop git log --pretty=oneline # copy sha of the previous commit - git tag -a vX.Y.Z + git tag -a vX.Y.Z -m vX.Y.Z git push origin vX.Y.Z ``` diff --git a/npm/react/package.json b/npm/react/package.json index a8acf12c8781..32ef2e880cf3 100644 --- a/npm/react/package.json +++ b/npm/react/package.json @@ -27,7 +27,7 @@ "react-router-dom": "6.0.0-alpha.1", "semver": "^7.3.2", "typescript": "^4.7.4", - "vite": "3.0.3", + "vite": "3.1.0", "vite-plugin-require-transform": "1.0.3" }, "peerDependencies": { diff --git a/npm/react18/src/index.ts b/npm/react18/src/index.ts index 67beea54010a..ba402fe21a4d 100644 --- a/npm/react18/src/index.ts +++ b/npm/react18/src/index.ts @@ -11,12 +11,14 @@ import type { UnmountArgs, } from '@cypress/react' -let root: any +let root: ReactDOM.Root | null const cleanup = () => { if (root) { root.unmount() + root = null + return true } @@ -27,7 +29,9 @@ export function mount (jsx: React.ReactNode, options: MountOptions = {}, rerende const internalOptions: InternalMountOptions = { reactDom: ReactDOM, render: (reactComponent: ReturnType, el: HTMLElement) => { - root = ReactDOM.createRoot(el) + if (!root) { + root = ReactDOM.createRoot(el) + } return root.render(reactComponent) }, diff --git a/npm/svelte/src/mount.ts b/npm/svelte/src/mount.ts index b90581f895d0..3fad1ac21274 100644 --- a/npm/svelte/src/mount.ts +++ b/npm/svelte/src/mount.ts @@ -76,7 +76,7 @@ export function mount ( // by waiting, we are delaying test execution for the next tick of event loop // and letting hooks and component lifecycle methods to execute mount return cy.wait(0, { log: false }).then(() => { - if (options.log) { + if (options.log !== false) { const mountMessage = `<${getComponentDisplayName(Component)} ... />` Cypress.log({ diff --git a/npm/vite-dev-server/package.json b/npm/vite-dev-server/package.json index 751a25976e4f..51fbb3a17153 100644 --- a/npm/vite-dev-server/package.json +++ b/npm/vite-dev-server/package.json @@ -26,8 +26,8 @@ "dedent": "^0.7.0", "mocha": "^9.2.2", "sinon": "^13.0.1", - "ts-node": "^10.2.1", - "vite": "3.0.3", + "ts-node": "^10.9.1", + "vite": "3.1.0", "vite-plugin-inspect": "0.4.3" }, "files": [ diff --git a/npm/vue/package.json b/npm/vue/package.json index 5bbb2d623b80..ade741d517bc 100644 --- a/npm/vue/package.json +++ b/npm/vue/package.json @@ -25,7 +25,7 @@ "globby": "^11.0.1", "tailwindcss": "1.1.4", "typescript": "^4.7.4", - "vite": "3.0.3", + "vite": "3.1.0", "vue": "3.2.31", "vue-i18n": "9.0.0-rc.6", "vue-router": "^4.0.0", diff --git a/npm/webpack-dev-server/package.json b/npm/webpack-dev-server/package.json index f2e369047448..086e9880a07f 100644 --- a/npm/webpack-dev-server/package.json +++ b/npm/webpack-dev-server/package.json @@ -37,7 +37,7 @@ "proxyquire": "2.1.3", "sinon": "^13.0.1", "snap-shot-it": "^7.9.6", - "ts-node": "^10.2.1", + "ts-node": "^10.9.1", "webpack": "npm:webpack@^5", "webpack-4": "npm:webpack@^4", "webpack-dev-server-3": "npm:webpack-dev-server@^3" diff --git a/npm/webpack-preprocessor/package.json b/npm/webpack-preprocessor/package.json index 390f12d8f08b..894b78f5765f 100644 --- a/npm/webpack-preprocessor/package.json +++ b/npm/webpack-preprocessor/package.json @@ -58,7 +58,7 @@ "sinon": "^9.0.0", "sinon-chai": "^3.5.0", "snap-shot-it": "7.9.2", - "ts-node": "^10.2.1", + "ts-node": "^10.9.1", "webpack": "^4.44.2" }, "peerDependencies": { diff --git a/package.json b/package.json index 5fc4ad282b57..ce005710c83e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cypress", - "version": "10.7.0", + "version": "10.8.0", "description": "Cypress.io end to end testing tool", "private": true, "scripts": { @@ -214,7 +214,7 @@ "through": "2.3.8", "through2": "^4.0.2", "tree-kill": "1.2.2", - "ts-node": "^10.2.1", + "ts-node": "^10.9.1", "typescript": "^4.7.4", "yarn-deduplicate": "3.1.0" }, diff --git a/packages/app/cypress/e2e/runner/pluginEvents.cy.ts b/packages/app/cypress/e2e/runner/pluginEvents.cy.ts new file mode 100644 index 000000000000..223b3019a026 --- /dev/null +++ b/packages/app/cypress/e2e/runner/pluginEvents.cy.ts @@ -0,0 +1,78 @@ +import { getPathForPlatform } from '../../../src/paths' + +describe('plugin events', () => { + it('supports "before:run" event', () => { + let projectRoot: string + + cy.scaffoldProject('plugin-run-events') + .then((projectPath) => { + projectRoot = projectPath + + cy.openProject('plugin-run-events') + cy.startAppServer('e2e') + cy.visitApp() + + cy.get('[data-cy-row="run_events_spec_1.cy.js"]').eq(1).click() + cy.waitForSpecToFinish({ + passCount: 1, + }) + + cy.readFile(`${projectRoot}/beforeRun.json`) + .then((details) => { + expect(details).to.have.property('config') + expect(details).to.have.property('cypressVersion') + expect(details).to.have.property('system') + }) + }) + }) + + it('supports "before:spec" event', () => { + let projectRoot: string + + cy.scaffoldProject('plugin-run-events') + .then((projectPath) => { + projectRoot = projectPath + + cy.openProject('plugin-run-events') + cy.startAppServer('e2e') + cy.visitApp() + + cy.get('[data-cy-row="run_events_spec_1.cy.js"]').eq(1).click() + cy.waitForSpecToFinish({ + passCount: 1, + }) + + cy.readFile(`${projectRoot}/beforeSpec.json`) + .then((spec) => { + expect(spec).to.deep.contains({ + baseName: 'run_events_spec_1.cy.js', + fileExtension: '.js', + fileName: 'run_events_spec_1', + name: 'run_events_spec_1.cy.js', + relative: getPathForPlatform('cypress/e2e/run_events_spec_1.cy.js'), + specFileExtension: '.cy.js', + specType: 'integration', + }) + }) + + cy.get('body').type('f') + cy.get('div[title="run_events_spec_2.cy.js"]').click() + cy.waitForSpecToFinish({ + passCount: 1, + }) + + cy.readFile(`${projectRoot}/beforeSpec.json`) + .then((spec) => { + expect(spec).to.deep.contains({ + baseName: 'run_events_spec_2.cy.js', + fileExtension: '.js', + fileName: 'run_events_spec_2', + name: 'run_events_spec_2.cy.js', + relative: getPathForPlatform('cypress/e2e/run_events_spec_2.cy.js'), + specFileExtension: '.cy.js', + specType: 'integration', + }) + }) + }) + }) +}) diff --git a/packages/app/cypress/e2e/runner/reporter.errors.cy.ts b/packages/app/cypress/e2e/runner/reporter.errors.cy.ts index 0d5fd57d257f..becbf40bde67 100644 --- a/packages/app/cypress/e2e/runner/reporter.errors.cy.ts +++ b/packages/app/cypress/e2e/runner/reporter.errors.cy.ts @@ -34,27 +34,24 @@ describe('errors ui', { }) verify('with expect().', { + line: 3, column: 25, message: `expected 'actual' to equal 'expected'`, verifyOpenInIde: true, - ideLine: 3, - ideColumn: 25, }) verify('with assert()', { - column: '(5|12)', // (chrome|firefox) + line: 7, + column: [5, 12], // [chrome, firefox] message: `should be true`, verifyOpenInIde: true, - ideLine: 7, - ideColumn: 5, }) verify('with assert.()', { + line: 11, column: 12, message: `expected 'actual' to equal 'expected'`, verifyOpenInIde: true, - ideLine: 11, - ideColumn: 12, }) }) @@ -85,7 +82,8 @@ describe('errors ui', { verify('in file outside project', { message: 'An outside error', - regex: /\/throws\-error\.js:5:9/, + stackRegex: /\/throws\-error\.js:5:8/, + codeFrameRegex: /\/throws\-error\.js:5:9/, codeFrameText: `thrownewError('An outside error')`, }) }) @@ -100,7 +98,7 @@ describe('errors ui', { // https://github.com/cypress-io/cypress/issues/8288 // https://github.com/cypress-io/cypress/issues/8350 verify('test', { - column: '(7|18)', // (chrome|firefox) + column: [7, 18], // [chrome, firefox] codeFrameText: 'beforeEach(()=>', message: `Cypress detected you registered a(n) beforeEach hook while a test was running`, }) @@ -483,7 +481,7 @@ describe('errors ui', { }) verify('from chai expect', { - column: '(5|12)', // (chrome|firefox) + column: [5, 12], // [chrome, firefox] message: 'Invalid Chai property: nope', stack: ['proxyGetter', 'From Your Spec Code:'], }) diff --git a/packages/app/cypress/e2e/runner/reporter.hooks.cy.ts b/packages/app/cypress/e2e/runner/reporter.hooks.cy.ts index 12f05fd603b2..8b15e7d3f49c 100644 --- a/packages/app/cypress/e2e/runner/reporter.hooks.cy.ts +++ b/packages/app/cypress/e2e/runner/reporter.hooks.cy.ts @@ -57,7 +57,7 @@ describe('hooks', { cy.withCtx((ctx, o) => { expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`hooks/basic\.cy\.js$`)), o.ideLine, o.ideColumn) - }, { ideLine: 2, ideColumn: Cypress.browser.family === 'firefox' ? 6 : 3 }) + }, { ideLine: 2, ideColumn: Cypress.browser.family === 'firefox' ? 5 : 2 }) }) it('does not display commands from skipped tests', () => { diff --git a/packages/app/cypress/e2e/runner/retries.mochaEvents.snapshots.ts b/packages/app/cypress/e2e/runner/retries.mochaEvents.snapshots.ts index 9bfabdb791f2..60a7322e91cd 100644 --- a/packages/app/cypress/e2e/runner/retries.mochaEvents.snapshots.ts +++ b/packages/app/cypress/e2e/runner/retries.mochaEvents.snapshots.ts @@ -78,7 +78,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -104,7 +103,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -119,7 +117,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -448,7 +445,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -495,7 +491,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -545,7 +540,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -1065,7 +1059,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -1112,7 +1105,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -1162,7 +1154,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -1757,7 +1748,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -1810,7 +1800,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -1861,7 +1850,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -2586,7 +2574,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -2644,7 +2631,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -2660,7 +2646,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'body': '[body]', @@ -2685,7 +2670,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -2876,7 +2860,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'body': '[body]', @@ -3863,7 +3846,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -3902,7 +3884,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -3918,7 +3899,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'body': '[body]', @@ -3979,7 +3959,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -4043,7 +4022,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'body': '[body]', @@ -4067,7 +4045,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -4105,7 +4082,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -4121,7 +4097,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'body': '[body]', @@ -4181,7 +4156,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -4244,7 +4218,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'body': '[body]', @@ -4740,7 +4713,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -4758,7 +4730,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -4823,7 +4794,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -4869,7 +4839,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -5244,7 +5213,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -5262,7 +5230,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -5278,7 +5245,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -5366,7 +5332,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -5878,7 +5843,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -5918,7 +5882,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -5969,7 +5932,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -6068,7 +6030,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -6107,7 +6068,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -6157,7 +6117,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -6826,7 +6785,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -6852,7 +6810,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -6867,7 +6824,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -6916,7 +6872,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -6941,7 +6896,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -6955,7 +6909,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -7003,7 +6956,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -7027,7 +6979,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -7041,7 +6992,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -7073,7 +7023,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', diff --git a/packages/app/cypress/e2e/runner/runner.mochaEvents.snapshots.ts b/packages/app/cypress/e2e/runner/runner.mochaEvents.snapshots.ts index d3819b964d8c..36def78adbc2 100644 --- a/packages/app/cypress/e2e/runner/runner.mochaEvents.snapshots.ts +++ b/packages/app/cypress/e2e/runner/runner.mochaEvents.snapshots.ts @@ -80,7 +80,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -98,7 +97,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -128,7 +126,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -167,7 +164,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -314,7 +310,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -332,7 +327,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -348,7 +342,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -401,7 +394,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -548,7 +540,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -566,7 +557,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -582,7 +572,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -639,7 +628,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -905,7 +893,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -923,7 +910,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -939,7 +925,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -996,7 +981,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -1235,7 +1219,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -1288,7 +1271,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, ], @@ -1373,7 +1355,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', @@ -1449,7 +1430,6 @@ export const snapshots = { 'message': '[error message]', 'name': 'AssertionError', 'stack': 'match.string', - 'sourceMappedStack': 'match.string', 'parsedStack': 'match.array', }, 'state': 'failed', diff --git a/packages/app/cypress/e2e/runner/runner.ui.cy.ts b/packages/app/cypress/e2e/runner/runner.ui.cy.ts index bc2d1a66e4d8..be616e889512 100644 --- a/packages/app/cypress/e2e/runner/runner.ui.cy.ts +++ b/packages/app/cypress/e2e/runner/runner.ui.cy.ts @@ -296,16 +296,5 @@ describe('src/cypress/runner', () => { cy.get('.runnable-err-message').should('not.contain', 'ran afterEach even though specs were stopped') cy.get('.runnable-err-message').should('contain', 'Cypress test was stopped while running this command.') }) - - // TODO: blocked by UNIFY-1077 - it.skip('supports disabling command log reporter with env var NO_COMMAND_LOG', () => { - loadSpec({ - filePath: 'runner/disabled-command-log.runner.cy.js', - passCount: 0, - failCount: 0, - }) - - cy.get('.reporter').should('not.exist') - }) }) }) diff --git a/packages/app/cypress/e2e/runner/support/mochaEventsUtils.ts b/packages/app/cypress/e2e/runner/support/mochaEventsUtils.ts index d8cf18c49f15..a1fc586c302c 100644 --- a/packages/app/cypress/e2e/runner/support/mochaEventsUtils.ts +++ b/packages/app/cypress/e2e/runner/support/mochaEventsUtils.ts @@ -43,7 +43,6 @@ const eventCleanseMap = { stack: () => 'match.string', file: (arg: string) => arg ? 'relative/path/to/spec.js' : undefined, message: () => '[error message]', - sourceMappedStack: () => 'match.string', parsedStack: () => 'match.array', name: (n: string) => n === 'Error' ? 'AssertionError' : n, err: () => { @@ -51,7 +50,6 @@ const eventCleanseMap = { message: '[error message]', name: 'AssertionError', stack: 'match.string', - sourceMappedStack: 'match.string', parsedStack: 'match.array', } }, diff --git a/packages/app/cypress/e2e/runner/support/verify-failures.ts b/packages/app/cypress/e2e/runner/support/verify-failures.ts index c0f3676058b3..1b3e14d92c35 100644 --- a/packages/app/cypress/e2e/runner/support/verify-failures.ts +++ b/packages/app/cypress/e2e/runner/support/verify-failures.ts @@ -3,7 +3,7 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' // Assert that either the the dialog is presented or the mutation is emitted, depending on // whether the test has a preferred IDE defined. -const verifyIdeOpen = ({ fileName, action, hasPreferredIde, ideLine, ideColumn }) => { +const verifyIdeOpen = ({ fileName, action, hasPreferredIde, line, column }) => { if (hasPreferredIde) { cy.withCtx((ctx, o) => { // @ts-expect-error - check if we've stubbed it already, only need to stub it once @@ -15,8 +15,8 @@ const verifyIdeOpen = ({ fileName, action, hasPreferredIde, ideLine, ideColumn } action() cy.withCtx((ctx, o) => { - expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`${o.fileName}$`)), o.ideLine, o.ideColumn) - }, { fileName, ideLine, ideColumn }) + expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`${o.fileName}$`)), o.line, o.column) + }, { fileName, line, column }) } else { action() @@ -40,16 +40,22 @@ const verifyFailure = (options) => { fileName, uncaught = false, uncaughtMessage, - ideLine, - ideColumn, + line, + regex, } = options - let { regex, line, codeFrameText } = options + let { codeFrameText, stackRegex, codeFrameRegex } = options if (!codeFrameText) { codeFrameText = specTitle } - regex = regex || new RegExp(`${fileName}:${line || '\\d+'}:${column}`) + const codeFrameColumnArray = [].concat(column) + const codeFrameColumn = codeFrameColumnArray[0] + const stackColumnArray = codeFrameColumnArray.map((col) => col - 1) + const stackColumn = stackColumnArray[0] + + stackRegex = regex || stackRegex || new RegExp(`${fileName}:${line || '\\d+'}:(${stackColumnArray.join('|')})`) + codeFrameRegex = regex || codeFrameRegex || new RegExp(`${fileName}:${line || '\\d+'}:(${codeFrameColumnArray.join('|')})`) cy.contains('.runnable-title', specTitle).closest('.runnable').as('Root') @@ -89,7 +95,7 @@ const verifyFailure = (options) => { cy.log('stack trace matches the specified pattern') cy.get('.runnable-err-stack-trace') .invoke('text') - .should('match', regex) + .should('match', stackRegex) if (stack) { const stackLines = [].concat(stack) @@ -126,8 +132,8 @@ const verifyFailure = (options) => { cy.get('@Root').contains('.runnable-err-stack-trace .runnable-err-file-path a', fileName) .click('left') }, - ideLine, - ideColumn, + line, + column: stackColumn, }) } @@ -160,7 +166,7 @@ const verifyFailure = (options) => { cy .get('.test-err-code-frame .runnable-err-file-path') .invoke('text') - .should('match', regex) + .should('match', codeFrameRegex) cy.get('.test-err-code-frame pre span').should('include.text', codeFrameText) }) @@ -173,8 +179,8 @@ const verifyFailure = (options) => { cy.get('@Root').contains('.test-err-code-frame .runnable-err-file-path a', fileName) .click() }, - ideLine, - ideColumn, + line, + column: codeFrameColumn, }) } } diff --git a/packages/app/cypress/e2e/runs.cy.ts b/packages/app/cypress/e2e/runs.cy.ts index e5c9aac554b2..a3f054548990 100644 --- a/packages/app/cypress/e2e/runs.cy.ts +++ b/packages/app/cypress/e2e/runs.cy.ts @@ -75,9 +75,20 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { it('clicking the login button will open the login modal', () => { cy.visitApp() moveToRunsPage() - cy.contains('Log In').click() + cy.contains(defaultMessages.runs.connect.buttonUser).click() + cy.withCtx((ctx, o) => { + o.sinon.spy(ctx._apis.authApi, 'logIn') + }) + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { - cy.get('button').contains('Log In') + cy.contains('button', 'Log In').click() + }) + + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: App') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Runs Tab') }) }) @@ -139,10 +150,8 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.startAppServer('component') cy.remoteGraphQLIntercept(async (obj) => { - if ((obj.operationName === 'CheckCloudOrganizations_cloudViewerChange_cloudViewer' || obj.operationName === 'Runs_cloudViewer' || obj.operationName === 'SpecsPageContainer_cloudViewer')) { - if (obj.result.data?.cloudViewer?.organizations?.nodes) { - obj.result.data.cloudViewer.organizations.nodes = [] - } + if (obj?.result?.data?.cloudViewer?.organizations?.nodes) { + obj.result.data.cloudViewer.organizations.nodes = [] } return obj.result @@ -156,10 +165,45 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.findByText(defaultMessages.runs.connect.buttonProject).click() cy.get('[aria-modal="true"]').should('exist') + // Clear existing remote GQL intercept to allow new queries to execute normally + cy.remoteGraphQLIntercept(async (obj) => { + return obj.result + }) + cy.contains('button', defaultMessages.runs.connect.modal.createOrg.refreshButton).click() cy.findByText(defaultMessages.runs.connect.modal.selectProject.manageOrgs) }) + + it('refetches cloudViewer data on open', () => { + cy.scaffoldProject('component-tests') + cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js']) + cy.startAppServer('component') + + cy.remoteGraphQLIntercept(async (obj, testState) => { + if (obj.operationName === 'CloudConnectModals_RefreshCloudViewer_refreshCloudViewer_cloudViewer') { + testState.refetchCalled = true + } + + if (obj.result.data?.cloudViewer?.organizations?.nodes) { + obj.result.data.cloudViewer.organizations.nodes = [] + } + + return obj.result + }) + + cy.loginUser() + cy.visitApp() + + moveToRunsPage() + + cy.findByText(defaultMessages.runs.connect.buttonProject).click() + cy.get('[aria-modal="true"]').should('exist') + + cy.withCtx((_, o) => { + expect(o.testState.refetchCalled).to.eql(true) + }) + }) }) context('Runs - Connect Project', () => { @@ -206,6 +250,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { moveToRunsPage() cy.withCtx(async (ctx, options) => { + ctx.coreData.app.browserStatus = 'open' options.sinon.stub(ctx._apis.electronApi, 'isMainWindowFocused').returns(false) options.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { setTimeout(() => { @@ -239,8 +284,8 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) context('Runs - Create Project', () => { - it('when a project is created, injects new projectId into the config file', () => { - cy.remoteGraphQLIntercept(async (obj) => { + it('when a project is created, injects new projectId into the config file, and sends expected UTM params', () => { + cy.remoteGraphQLIntercept((obj) => { if (obj.operationName === 'SelectCloudProjectModal_CreateCloudProject_cloudProjectCreate') { obj.result.data!.cloudProjectCreate = { slug: 'newProjectId', @@ -257,7 +302,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.loginUser() cy.visitApp() - cy.withCtx(async (ctx) => { + cy.withCtx(async (ctx, o) => { + o.sinon.spy(ctx.cloud, 'executeRemoteGraphQL') + const config = await ctx.project.getConfig() expect(config.projectId).to.not.equal('newProjectId') @@ -272,6 +319,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { const config = await ctx.project.getConfig() expect(config.projectId).to.equal('newProjectId') + expect(ctx.cloud.executeRemoteGraphQL).to.have.been.calledWithMatch({ + fieldName: 'cloudProjectCreate', + operationVariables: { + medium: 'Runs Tab', + source: 'Binary: App', + } }) }) }) diff --git a/packages/app/cypress/e2e/settings.cy.ts b/packages/app/cypress/e2e/settings.cy.ts index 6c36b4425b73..e296eda4c084 100644 --- a/packages/app/cypress/e2e/settings.cy.ts +++ b/packages/app/cypress/e2e/settings.cy.ts @@ -16,17 +16,12 @@ describe('App: Settings', () => { cy.visitApp() cy.get(SidebarSettingsLinkSelector).click() - cy.get('div[data-cy="app-header-bar"]').should('contain', 'Settings') + cy.contains('[data-cy="app-header-bar"]', 'Settings') + cy.contains('[data-cy="app-header-bar"] button', 'Log In').should('be.visible') + cy.findByText('Device Settings').should('be.visible') cy.findByText('Project Settings').should('be.visible') - }) - - it('shows a button to log in if user is not connected', () => { - cy.startAppServer('e2e') - cy.visitApp() - cy.get(SidebarSettingsLinkSelector).click() - cy.findByText('Project Settings').click() - cy.get('button').contains('Log In') + cy.findByText('Dashboard Settings').should('be.visible') }) describe('Cloud Settings', () => { @@ -406,7 +401,7 @@ describe('App: Settings', () => { }) describe('App: Settings without cloud', () => { - it('the projectId section shows a prompt to connect when there is no projectId', () => { + it('the projectId section shows a prompt to log in when there is no projectId, and uses correct UTM params', () => { cy.scaffoldProject('simple-ct') cy.openProject('simple-ct') cy.startAppServer('component') @@ -415,7 +410,21 @@ describe('App: Settings without cloud', () => { cy.get(SidebarSettingsLinkSelector).click() cy.findByText('Dashboard Settings').click() cy.findByText('Project ID').should('exist') - cy.contains('button', 'Log in to the Cypress Dashboard').should('be.visible') + cy.withCtx((ctx, o) => { + o.sinon.spy(ctx._apis.authApi, 'logIn') + }) + + cy.contains('button', 'Log in to the Cypress Dashboard').click() + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.contains('button', 'Log In').click() + }) + + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: App') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Settings Tab') + }) }) it('have returned browsers', () => { diff --git a/packages/app/cypress/e2e/specs_list_e2e.cy.ts b/packages/app/cypress/e2e/specs_list_e2e.cy.ts index 1cb0dda473f4..2c778a8b6a5b 100644 --- a/packages/app/cypress/e2e/specs_list_e2e.cy.ts +++ b/packages/app/cypress/e2e/specs_list_e2e.cy.ts @@ -237,6 +237,14 @@ describe('App: Spec List (E2E)', () => { cy.get('button').contains('23 Matches') }) + it('normalizes directory path separators for Windows', function () { + // On Windows, when a user types `e2e/accounts`, it should match `e2e\accounts` + clearSearchAndType('e2e/accounts') + cy.findAllByTestId('spec-item').should('have.length', 2) + + cy.findByText('No specs matched your search:').should('not.be.visible') + }) + // TODO: fix flaky test https://github.com/cypress-io/cypress/issues/23305 it.skip('saves the filter when navigating to a spec and back', function () { const targetSpecFile = 'accounts_list.spec.js' diff --git a/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts b/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts index 9cf1ccfb4497..db4757616f12 100644 --- a/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts +++ b/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts @@ -17,6 +17,37 @@ function averageDurationSelector (specFileName: string) { return `${specRowSelector(specFileName)} [data-cy="average-duration"]` } +function makeTestingCloudLink (status: string) { + return `https://google.com?utm_medium=Specs+Latest+Runs+Dots&utm_campaign=${status.toUpperCase()}&utm_source=Binary%3A+App` +} + +function assertCorrectRunsLink (specFileName: string, status: string) { + // we avoid the full `cy.validateExternalLink` here because that command + // clicks the link, which focuses the link causing tooltips to appear, + // which produces problems elsewhere testing tooltip behavior + cy.findByRole('link', { name: specFileName }) + .should('have.attr', 'href', makeTestingCloudLink(status)) + .should('have.attr', 'data-cy', 'external') // to confirm the ExternalLink component is used +} + +function validateTooltip (status: string) { + cy.validateExternalLink({ + // TODO: (#23778) This name is so long because the entire tooltip is wrapped in a link, + // we can make this more accessible by having the name of the link describe the destination + // (which is currently not described) and keeping the other content separate. + name: `accounts_new.spec.js ${status} 4 months ago 2:23 - 2:39 skipped pending passed failed`, + // the main thing about testing this link is that is gets composed with the expected UTM params + href: makeTestingCloudLink(status), + }) + .should('contain.text', 'accounts_new.spec.js') + .and('contain.text', '4 months ago') + .and('contain.text', '2:23 - 2:39') + .and('contain.text', 'skipped 0') + .and('contain.text', 'pending 1-2') + .and('contain.text', `passed 22-23`) + .and('contain.text', 'failed 1-2') +} + function specShouldShow (specFileName: string, runDotsClasses: string[], latestRunStatus: CloudRunStatus|'PLACEHOLDER') { const latestStatusSpinning = latestRunStatus === 'RUNNING' @@ -31,10 +62,11 @@ function specShouldShow (specFileName: string, runDotsClasses: string[], latestR .should(`${latestStatusSpinning ? '' : 'not.'}have.class`, 'animate-spin') .and('have.attr', 'data-cy-run-status', latestRunStatus) - // TODO: add link verification - // if (latestRunStatus !== 'PLACEHOLDER') { - // cy.get(`${specRowSelector(specFileName)} [data-cy="run-status-dots"]`).validateExternalLink('https://google.com') - // } + if (runDotsClasses?.length) { + assertCorrectRunsLink(`${specFileName} test results`, latestRunStatus) + } else { + cy.findByRole('link', { name: `${specFileName} test results` }).should('not.exist') + } } function simulateRunData () { @@ -330,7 +362,9 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW specShouldShow('accounts_new.spec.js', ['gray-300', 'gray-300', 'jade-400'], 'RUNNING') cy.get(dotSelector('accounts_new.spec.js', 'latest')).trigger('mouseenter') cy.get('.v-popper__popper--shown').should('exist') - // TODO: verify the contents of the tooltip + + validateTooltip('Running') + cy.get(dotSelector('accounts_new.spec.js', 'latest')).trigger('mouseleave') cy.get(averageDurationSelector('accounts_new.spec.js')).contains('2:03') }) @@ -601,7 +635,8 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW specShouldShow('accounts_list.spec.js', ['orange-400', 'gray-300', 'red-400'], 'PASSED') cy.get(dotSelector('accounts_new.spec.js', 'latest')).trigger('mouseenter') cy.get('.v-popper__popper--shown').should('exist') - // TODO: verify the contents of the tooltip + + validateTooltip('Passed') cy.get(dotSelector('accounts_new.spec.js', 'latest')).trigger('mouseleave') cy.get(averageDurationSelector('accounts_list.spec.js')).contains('0:12') @@ -611,7 +646,8 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW specShouldShow('accounts_list.spec.js', ['orange-400', 'gray-300', 'red-400'], 'PASSED') cy.get(dotSelector('accounts_new.spec.js', 'latest')).trigger('mouseenter') cy.get('.v-popper__popper--shown').should('exist') - // TODO: verify the contents of the tooltip + + validateTooltip('Passed') cy.get(dotSelector('accounts_new.spec.js', 'latest')).trigger('mouseleave') cy.get(averageDurationSelector('accounts_list.spec.js')).contains('0:12') }) diff --git a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts index d667d7e15685..41f607621d12 100644 --- a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts +++ b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts @@ -18,10 +18,8 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { // Simulate no orgs cy.remoteGraphQLIntercept(async (obj) => { - if ((obj.operationName === 'CheckCloudOrganizations_cloudViewerChange_cloudViewer' || obj.operationName === 'Runs_cloudViewer' || obj.operationName === 'SpecsPageContainer_cloudViewer')) { - if (obj.result.data?.cloudViewer?.organizations?.nodes) { - obj.result.data.cloudViewer.organizations.nodes = [] - } + if (obj.result.data?.cloudViewer?.organizations?.nodes) { + obj.result.data.cloudViewer.organizations.nodes = [] } return obj.result diff --git a/packages/app/cypress/e2e/top-nav.cy.ts b/packages/app/cypress/e2e/top-nav.cy.ts index 871c9186595b..d3a05f6515ab 100644 --- a/packages/app/cypress/e2e/top-nav.cy.ts +++ b/packages/app/cypress/e2e/top-nav.cy.ts @@ -407,6 +407,7 @@ describe('App Top Nav Workflows', () => { const mockLogInActionsForUser = (user) => { cy.withCtx(async (ctx, options) => { + ctx.coreData.app.browserStatus = 'open' options.sinon.stub(ctx._apis.electronApi, 'isMainWindowFocused').returns(false) options.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { setTimeout(() => { @@ -455,6 +456,13 @@ describe('App Top Nav Workflows', () => { mockLogInActionsForUser(mockUser) logIn({ expectedNextStepText: 'Connect project', displayName: mockUser.name }) + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: App') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Nav') + }) + cy.findByRole('dialog', { name: 'Create project' }).should('be.visible') }) }) diff --git a/packages/app/package.json b/packages/app/package.json index 1cc9c3ef0135..31f139565be4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -32,6 +32,7 @@ "@types/faker": "5.5.8", "@urql/core": "2.4.4", "@urql/vue": "0.6.2", + "@vitejs/plugin-legacy": "^2.1.0", "@vitejs/plugin-vue": "2.2.4", "@vitejs/plugin-vue-jsx": "1.3.8", "@vueuse/core": "7.2.2", @@ -54,13 +55,14 @@ "just-my-luck": "3.0.0", "lodash": "4.17.21", "mobx": "5.15.4", + "nanoid": "3.3.4", "pinia": "2.0.0-rc.14", "rimraf": "3.0.2", "rollup-plugin-copy": "3.4.0", "rollup-plugin-polyfill-node": "^0.7.0", "unplugin-icons": "0.13.2", "unplugin-vue-components": "^0.15.2", - "vite": "3.0.3", + "vite": "3.1.0", "vite-plugin-components": "0.11.3", "vite-plugin-pages": "0.18.1", "vite-plugin-vue-layouts": "0.6.0", diff --git a/packages/app/src/layouts/default.vue b/packages/app/src/layouts/default.vue index 963367fa77ff..c83cbf74f9c4 100644 --- a/packages/app/src/layouts/default.vue +++ b/packages/app/src/layouts/default.vue @@ -49,10 +49,13 @@ + diff --git a/packages/app/src/main.ts b/packages/app/src/main.ts index 823e76761253..ded76864c6bd 100644 --- a/packages/app/src/main.ts +++ b/packages/app/src/main.ts @@ -12,17 +12,11 @@ import Toast, { POSITION } from 'vue-toastification' import 'vue-toastification/dist/index.css' import { createWebsocket, getRunnerConfigFromWindow } from './runner' -// set a global so we can run -// conditional code in the vite branch -// so that the existing runner code -// @ts-ignore -window.__vite__ = true - const app = createApp(App) const config = getRunnerConfigFromWindow() -const ws = createWebsocket(config.socketIoRoute) +const ws = createWebsocket(config) window.ws = ws diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 8a907ba023b3..b072b1991c66 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -181,27 +181,27 @@ export class EventManager { }) }) - const logCommand = (logId) => { - const consoleProps = Cypress.runner.getConsolePropsForLogById(logId) + const logCommand = (testId, logId) => { + const consoleProps = Cypress.runner.getConsolePropsForLog(testId, logId) logger.logFormatted(consoleProps) } - this.reporterBus.on('runner:console:error', ({ err, commandId }) => { + this.reporterBus.on('runner:console:error', ({ err, testId, logId }) => { if (!Cypress) return - if (commandId || err) logger.clearLog() + if (logId || err) logger.clearLog() - if (commandId) logCommand(commandId) + if (logId) logCommand(testId, logId) if (err) logger.logError(err.stack) }) - this.reporterBus.on('runner:console:log', (logId) => { + this.reporterBus.on('runner:console:log', (testId, logId) => { if (!Cypress) return logger.clearLog() - logCommand(logId) + logCommand(testId, logId) }) this.reporterBus.on('set:user:editor', (editor) => { @@ -210,24 +210,24 @@ export class EventManager { this.reporterBus.on('runner:restart', rerun) - const sendEventIfSnapshotProps = (logId, event) => { + const sendEventIfSnapshotProps = (testId, logId, event) => { if (!Cypress) return - const snapshotProps = Cypress.runner.getSnapshotPropsForLogById(logId) + const snapshotProps = Cypress.runner.getSnapshotPropsForLog(testId, logId) if (snapshotProps) { this.localBus.emit(event, snapshotProps) } } - this.reporterBus.on('runner:show:snapshot', (logId) => { - sendEventIfSnapshotProps(logId, 'show:snapshot') + this.reporterBus.on('runner:show:snapshot', (testId, logId) => { + sendEventIfSnapshotProps(testId, logId, 'show:snapshot') }) this.reporterBus.on('runner:hide:snapshot', this._hideSnapshot.bind(this)) - this.reporterBus.on('runner:pin:snapshot', (logId) => { - sendEventIfSnapshotProps(logId, 'pin:snapshot') + this.reporterBus.on('runner:pin:snapshot', (testId, logId) => { + sendEventIfSnapshotProps(testId, logId, 'pin:snapshot') }) this.reporterBus.on('runner:unpin:snapshot', this._unpinSnapshot.bind(this)) @@ -258,9 +258,7 @@ export class EventManager { if (!Cypress) return Cypress.backend('clear:session') - .then(() => { - rerun() - }) + .then(rerun) }) this.reporterBus.on('external:open', (url) => { @@ -384,6 +382,10 @@ export class EventManager { this._addListeners() this.ws.emit('watch:test:file', config.spec) + + if (config.isTextTerminal || config.experimentalInteractiveRunEvents) { + this.ws.emit('plugins:before:spec', config.spec) + } } isBrowser (browserName) { @@ -406,9 +408,11 @@ export class EventManager { return } + const hideCommandLog = window.__CYPRESS_CONFIG__.hideCommandLog + this.studioStore.initialize(config, state) - const runnables = Cypress.runner.normalizeAll(state.tests) + const runnables = Cypress.runner.normalizeAll(state.tests, hideCommandLog) const run = () => { performance.mark('initialize-end') @@ -417,7 +421,9 @@ export class EventManager { this._runDriver(state) } - this.reporterBus.emit('runnables:ready', runnables) + if (!hideCommandLog) { + this.reporterBus.emit('runnables:ready', runnables) + } if (state?.numLogs) { Cypress.runner.setNumLogs(state.numLogs) @@ -878,7 +884,7 @@ export class EventManager { this.localBus.emit('save:app:state', state) } - // usefulf for testing + // useful for testing _testingOnlySetCypress (cypress: any) { Cypress = cypress } diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index feccc70a4bf9..ad7ccf599662 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -30,18 +30,11 @@ import { useStudioStore } from '../store/studio-store' let _eventManager: EventManager | undefined -export function createWebsocket (socketIoRoute: string) { - const socketConfig = { - path: socketIoRoute, - transports: ['websocket'], - } - - const ws = client(socketConfig) - - ws.on('connect_error', () => { - // fall back to polling if websocket fails to connect (webkit) - // https://github.com/socketio/socket.io/discussions/3998#discussioncomment-972316 - ws.io.opts.transports = ['polling', 'websocket'] +export function createWebsocket (config: Cypress.Config) { + const ws = client({ + path: config.socketIoRoute, + // TODO(webkit): the websocket socket.io transport is busted in WebKit, need polling + transports: config.browser.family === 'webkit' ? ['polling'] : ['websocket'], }) ws.on('connect', () => { @@ -225,18 +218,7 @@ export function addCrossOriginIframe (location) { * Cypress on it. * */ -function runSpecCT (spec: SpecFile) { - // TODO: UNIFY-1318 - figure out how to manage window.config. - const config = getRunnerConfigFromWindow() - - // this is how the Cypress driver knows which spec to run. - config.spec = setSpecForDriver(spec) - - // creates a new instance of the Cypress driver for this spec, - // initializes a bunch of listeners - // watches spec file for changes. - getEventManager().setup(config) - +function runSpecCT (config, spec: SpecFile) { const $runnerRoot = getRunnerElement() // clear AUT, if there is one. @@ -290,18 +272,7 @@ function setSpecForDriver (spec: SpecFile) { * a Spec IFrame to load the spec's source code, and * initialize Cypress on the AUT. */ -function runSpecE2E (spec: SpecFile) { - // TODO: UNIFY-1318 - manage config with GraphQL, don't put it on window. - const config = getRunnerConfigFromWindow() - - // this is how the Cypress driver knows which spec to run. - config.spec = setSpecForDriver(spec) - - // creates a new instance of the Cypress driver for this spec, - // initializes a bunch of listeners - // watches spec file for changes. - getEventManager().setup(config) - +function runSpecE2E (config, spec: SpecFile) { const $runnerRoot = getRunnerElement() // clear AUT, if there is one. @@ -427,12 +398,22 @@ async function executeSpec (spec: SpecFile, isRerun: boolean = false) { UnifiedReporterAPI.setupReporter() + // TODO: UNIFY-1318 - figure out how to manage window.config. + const config = getRunnerConfigFromWindow() + + // this is how the Cypress driver knows which spec to run. + config.spec = setSpecForDriver(spec) + + // creates a new instance of the Cypress driver for this spec, + // initializes a bunch of listeners watches spec file for changes. + getEventManager().setup(config) + if (window.__CYPRESS_TESTING_TYPE__ === 'e2e') { - return runSpecE2E(spec) + return runSpecE2E(config, spec) } if (window.__CYPRESS_TESTING_TYPE__ === 'component') { - return runSpecCT(spec) + return runSpecCT(config, spec) } throw Error('Unknown or undefined testingType on window.__CYPRESS_TESTING_TYPE__') diff --git a/packages/app/src/runs/CloudConnectButton.cy.tsx b/packages/app/src/runs/CloudConnectButton.cy.tsx index 7a52d1ae8341..100c229de39d 100644 --- a/packages/app/src/runs/CloudConnectButton.cy.tsx +++ b/packages/app/src/runs/CloudConnectButton.cy.tsx @@ -9,7 +9,7 @@ describe('', () => { result.cloudViewer = null }, render (gqlVal) { - return
+ return
}, }) @@ -56,7 +56,7 @@ describe('', () => { result.cloudViewer = cloudViewer }, render (gqlVal) { - return
+ return
}, }) @@ -69,7 +69,7 @@ describe('', () => { result.cloudViewer = cloudViewer }, render (gqlVal) { - return
+ return
}, }) diff --git a/packages/app/src/runs/CloudConnectButton.vue b/packages/app/src/runs/CloudConnectButton.vue index bb76f8b2e6d0..93325eab0e2f 100644 --- a/packages/app/src/runs/CloudConnectButton.vue +++ b/packages/app/src/runs/CloudConnectButton.vue @@ -10,7 +10,7 @@ @@ -18,6 +18,7 @@ v-if="isProjectConnectOpen" :show="isProjectConnectOpen" :gql="props.gql" + :utm-medium="props.utmMedium" @cancel="isProjectConnectOpen = false" @success="isProjectConnectOpen = false; emit('success')" /> @@ -54,6 +55,7 @@ const emit = defineEmits<{ const props = defineProps<{ gql: CloudConnectButtonFragment class?: string + utmMedium: string }>() const isLoginOpen = ref(false) diff --git a/packages/app/src/runs/RunsConnect.vue b/packages/app/src/runs/RunsConnect.vue index 9b672ac6f89d..79587fb47909 100644 --- a/packages/app/src/runs/RunsConnect.vue +++ b/packages/app/src/runs/RunsConnect.vue @@ -20,6 +20,7 @@ diff --git a/packages/app/src/runs/RunsErrorRenderer.vue b/packages/app/src/runs/RunsErrorRenderer.vue index d9278287d8a8..a67e6ff035bf 100644 --- a/packages/app/src/runs/RunsErrorRenderer.vue +++ b/packages/app/src/runs/RunsErrorRenderer.vue @@ -62,6 +62,7 @@ v-if="showConnectDialog" :show="showConnectDialog" :gql="props.gql" + utm-medium="Runs Tab" @cancel="showConnectDialog = false" @success="showConnectDialog = false" /> diff --git a/packages/app/src/runs/modals/CloudConnectModals.spec.tsx b/packages/app/src/runs/modals/CloudConnectModals.spec.tsx index 000a8c77bb9c..4536838f238d 100644 --- a/packages/app/src/runs/modals/CloudConnectModals.spec.tsx +++ b/packages/app/src/runs/modals/CloudConnectModals.spec.tsx @@ -4,9 +4,15 @@ import { CloudUserStubs, } from '@packages/graphql/test/stubCloudTypes' import { CloudConnectModalsFragmentDoc } from '../../generated/graphql-test' import CloudConnectModals from './CloudConnectModals.vue' +import cloneDeep from 'lodash/cloneDeep' + +type MountOptions = { + hasOrg: boolean + hasProjects: boolean +} describe('', () => { - function mountDialog (noOrg = false) { + function mountDialog ({ hasOrg, hasProjects }: MountOptions) { cy.mountFragment(CloudConnectModalsFragmentDoc, { onResult: (result) => { result.currentProject = { @@ -18,21 +24,63 @@ describe('', () => { result.cloudViewer = { ...CloudUserStubs.me, - organizations: noOrg ? null : CloudOrganizationConnectionStubs, + organizations: hasOrg ? cloneDeep(CloudOrganizationConnectionStubs) : null, + } + + if (!hasProjects) { + result.cloudViewer.organizations?.nodes.forEach((org) => { + org.projects = { + ...org.projects, + nodes: [], + } + }) } }, render (gql) { return (
- +
) }, }) } - it('shows the select org modal when orgs are added', () => { - mountDialog() - cy.contains(defaultMessages.runs.connect.modal.selectProject.connectProject).should('be.visible') + context('has no organization', () => { + beforeEach(() => { + mountDialog({ hasOrg: false, hasProjects: false }) + }) + + it('shows the create/select org modal when orgs are added', () => { + cy.contains(defaultMessages.runs.connect.modal.createOrg.button).should('be.visible') + + cy.percySnapshot() + }) + }) + + context('has organizations', () => { + context('with no projects', () => { + beforeEach(() => { + mountDialog({ hasOrg: true, hasProjects: false }) + }) + + it('shows the select project modal with create new project action', () => { + cy.contains(defaultMessages.runs.connect.modal.selectProject.createProject).should('be.visible') + + cy.contains('a', defaultMessages.links.needHelp).should('have.attr', 'href', 'https://on.cypress.io/adding-new-project') + + cy.percySnapshot() + }) + }) + + context('with projects', () => { + beforeEach(() => { + mountDialog({ hasOrg: true, hasProjects: true }) + }) - cy.contains('a', defaultMessages.links.needHelp).should('have.attr', 'href', 'https://on.cypress.io/adding-new-project') + it('shows the select project modal with list of projects', () => { + cy.contains(defaultMessages.runs.connect.modal.selectProject.connectProject).should('be.visible') + + cy.percySnapshot() + }) + }) }) }) diff --git a/packages/app/src/runs/modals/CloudConnectModals.vue b/packages/app/src/runs/modals/CloudConnectModals.vue index 3f4578b6f04f..e6ccd07e0748 100644 --- a/packages/app/src/runs/modals/CloudConnectModals.vue +++ b/packages/app/src/runs/modals/CloudConnectModals.vue @@ -11,6 +11,7 @@ v-else-if="props.gql.cloudViewer?.organizations?.nodes.length ?? 0 > 0" :gql="props.gql" show + :utm-medium="props.utmMedium" @update-project-id-failed="showManualUpdate" @success="emit('success')" @cancel="emit('cancel')" @@ -23,13 +24,13 @@ diff --git a/packages/app/src/specs/banners/ConnectProjectBanner.cy.tsx b/packages/app/src/specs/banners/ConnectProjectBanner.cy.tsx index aea9e561b263..0fc078252aff 100644 --- a/packages/app/src/specs/banners/ConnectProjectBanner.cy.tsx +++ b/packages/app/src/specs/banners/ConnectProjectBanner.cy.tsx @@ -1,5 +1,6 @@ import { defaultMessages } from '@cy/i18n' import ConnectProjectBanner from './ConnectProjectBanner.vue' +import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql' describe('', () => { it('should render expected content', () => { @@ -11,4 +12,23 @@ describe('', () => { cy.percySnapshot() }) + + it('should record expected event on mount', () => { + const recordEvent = cy.stub().as('recordEvent') + + cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => { + recordEvent(event) + + return defineResult({ recordEvent: true }) + }) + + cy.mount({ render: () => }) + + cy.get('@recordEvent').should('have.been.calledWith', { + campaign: 'Create project', + medium: 'Specs Create Project Banner', + messageId: Cypress.sinon.match.string, + cohort: null, + }) + }) }) diff --git a/packages/app/src/specs/banners/ConnectProjectBanner.vue b/packages/app/src/specs/banners/ConnectProjectBanner.vue index 994acbc6c729..97315fd73216 100644 --- a/packages/app/src/specs/banners/ConnectProjectBanner.vue +++ b/packages/app/src/specs/banners/ConnectProjectBanner.vue @@ -8,6 +8,12 @@ class="mb-16px" :icon="ConnectIcon" dismissible + :has-banner-been-shown="hasBannerBeenShown" + :event-data="{ + campaign: 'Create project', + medium: 'Specs Create Project Banner', + cohort: '' // TODO Connect cohort + }" @update:model-value="value => emit('update:modelValue', value)" >

@@ -26,6 +32,7 @@ @@ -51,8 +58,10 @@ query ConnectProjectBanner { withDefaults(defineProps<{ modelValue: boolean + hasBannerBeenShown: boolean }>(), { modelValue: false, + hasBannerBeenShown: true, }) const emit = defineEmits<{ diff --git a/packages/app/src/specs/banners/CreateOrganizationBanner.cy.tsx b/packages/app/src/specs/banners/CreateOrganizationBanner.cy.tsx index 7399398e23bb..b7297ee1fb8c 100644 --- a/packages/app/src/specs/banners/CreateOrganizationBanner.cy.tsx +++ b/packages/app/src/specs/banners/CreateOrganizationBanner.cy.tsx @@ -1,5 +1,6 @@ import { defaultMessages } from '@cy/i18n' import CreateOrganizationBanner from './CreateOrganizationBanner.vue' +import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql' describe('', () => { it('should render expected content', () => { @@ -23,4 +24,23 @@ describe('', () => { cy.percySnapshot() }) + + it('should record expected event on mount', () => { + const recordEvent = cy.stub().as('recordEvent') + + cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => { + recordEvent(event) + + return defineResult({ recordEvent: true }) + }) + + cy.mount({ render: () => }) + + cy.get('@recordEvent').should('have.been.calledWith', { + campaign: 'Set up your organization', + medium: 'Specs Create Organization Banner', + messageId: Cypress.sinon.match.string, + cohort: null, + }) + }) }) diff --git a/packages/app/src/specs/banners/CreateOrganizationBanner.vue b/packages/app/src/specs/banners/CreateOrganizationBanner.vue index 9edbd18f4911..21ef93f3c2e2 100644 --- a/packages/app/src/specs/banners/CreateOrganizationBanner.vue +++ b/packages/app/src/specs/banners/CreateOrganizationBanner.vue @@ -8,6 +8,12 @@ class="mb-16px" :icon="OrganizationIcon" dismissible + :has-banner-been-shown="hasBannerBeenShown" + :event-data="{ + campaign: 'Set up your organization', + medium: 'Specs Create Organization Banner', + cohort: '' // TODO Connect cohort + }" @update:model-value="value => emit('update:modelValue', value)" >

@@ -48,8 +54,10 @@ query CreateOrganizationBanner { withDefaults(defineProps<{ modelValue: boolean + hasBannerBeenShown: boolean }>(), { modelValue: false, + hasBannerBeenShown: true, }) const emit = defineEmits<{ diff --git a/packages/app/src/specs/banners/LoginBanner.cy.tsx b/packages/app/src/specs/banners/LoginBanner.cy.tsx index be35a5586ea4..5336a9c5338a 100644 --- a/packages/app/src/specs/banners/LoginBanner.cy.tsx +++ b/packages/app/src/specs/banners/LoginBanner.cy.tsx @@ -1,5 +1,6 @@ import { defaultMessages } from '@cy/i18n' import LoginBanner from './LoginBanner.vue' +import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql' describe('', () => { it('should render expected content', () => { @@ -11,4 +12,23 @@ describe('', () => { cy.percySnapshot() }) + + it('should record expected event on mount', () => { + const recordEvent = cy.stub().as('recordEvent') + + cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => { + recordEvent(event) + + return defineResult({ recordEvent: true }) + }) + + cy.mount({ render: () => }) + + cy.get('@recordEvent').should('have.been.calledWith', { + campaign: 'Log In', + medium: 'Specs Login Banner', + messageId: Cypress.sinon.match.string, + cohort: null, + }) + }) }) diff --git a/packages/app/src/specs/banners/LoginBanner.vue b/packages/app/src/specs/banners/LoginBanner.vue index d98a2279c474..b48a4e7663b1 100644 --- a/packages/app/src/specs/banners/LoginBanner.vue +++ b/packages/app/src/specs/banners/LoginBanner.vue @@ -8,6 +8,12 @@ class="mb-16px" :icon="ConnectIcon" dismissible + :has-banner-been-shown="hasBannerBeenShown" + :event-data="{ + campaign: 'Log In', + medium: 'Specs Login Banner', + cohort: '' // TODO Connect cohort + }" @update:model-value="value => emit('update:modelValue', value)" >

@@ -50,8 +56,10 @@ query LoginBanner { withDefaults(defineProps<{ modelValue: boolean + hasBannerBeenShown: boolean }>(), { modelValue: false, + hasBannerBeenShown: true, }) const emit = defineEmits<{ diff --git a/packages/app/src/specs/banners/RecordBanner.cy.tsx b/packages/app/src/specs/banners/RecordBanner.cy.tsx index ff7f8acf8d2b..7ceac5a438e8 100644 --- a/packages/app/src/specs/banners/RecordBanner.cy.tsx +++ b/packages/app/src/specs/banners/RecordBanner.cy.tsx @@ -1,5 +1,6 @@ import { defaultMessages } from '@cy/i18n' import RecordBanner from './RecordBanner.vue' +import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql' describe('', () => { it('should render expected content', () => { @@ -27,4 +28,23 @@ describe('', () => { cy.percySnapshot() }) + + it('should record expected event on mount', () => { + const recordEvent = cy.stub().as('recordEvent') + + cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => { + recordEvent(event) + + return defineResult({ recordEvent: true }) + }) + + cy.mount({ render: () => }) + + cy.get('@recordEvent').should('have.been.calledWith', { + campaign: 'Record Runs', + medium: 'Specs Record Runs Banner', + messageId: Cypress.sinon.match.string, + cohort: null, + }) + }) }) diff --git a/packages/app/src/specs/banners/RecordBanner.vue b/packages/app/src/specs/banners/RecordBanner.vue index 3e364cc99b07..3e09530500f2 100644 --- a/packages/app/src/specs/banners/RecordBanner.vue +++ b/packages/app/src/specs/banners/RecordBanner.vue @@ -8,6 +8,12 @@ class="mb-16px" :icon="RecordIcon" dismissible + :has-banner-been-shown="hasBannerBeenShown" + :event-data="{ + campaign: 'Record Runs', + medium: 'Specs Record Runs Banner', + cohort: '' // TODO Connect cohort + }" @update:model-value="value => emit('update:modelValue', value)" >

@@ -54,8 +60,10 @@ query RecordBanner { withDefaults(defineProps<{ modelValue: boolean + hasBannerBeenShown: boolean }>(), { modelValue: false, + hasBannerBeenShown: true, }) const emit = defineEmits<{ diff --git a/packages/app/src/specs/banners/TrackedBanner.cy.tsx b/packages/app/src/specs/banners/TrackedBanner.cy.tsx index 8a1feb89c0d7..b2ed41d430d8 100644 --- a/packages/app/src/specs/banners/TrackedBanner.cy.tsx +++ b/packages/app/src/specs/banners/TrackedBanner.cy.tsx @@ -1,10 +1,10 @@ import TrackedBanner from './TrackedBanner.vue' import { ref } from 'vue' -import { TrackedBanner_SetProjectStateDocument } from '../../generated/graphql' +import { TrackedBanner_RecordBannerSeenDocument, TrackedBanner_SetProjectStateDocument } from '../../generated/graphql' describe('', () => { it('should pass through props and child content', () => { - cy.mount({ render: () => Test Content }) + cy.mount({ render: () => Test Content }) cy.findByText('Test Content').should('be.visible') cy.findByTestId('alert-suffix-icon').should('be.visible') @@ -25,7 +25,7 @@ describe('', () => { // Initially mount as visible // @ts-ignore - cy.mount({ render: () => }) + cy.mount({ render: () => }) cy.get('[data-cy="banner"]').as('banner') @@ -48,7 +48,7 @@ describe('', () => { // Initially mount as visible // @ts-ignore - cy.mount({ render: () => }) + cy.mount({ render: () => }) cy.get('[data-cy="banner"]').as('banner') @@ -61,4 +61,47 @@ describe('', () => { expect(recordStub).to.have.been.calledWith('{"banners":{"test-banner":{"dismissed":1234}}}') }) }) + + describe('event recording', () => { + beforeEach(() => { + const recordEventStub = cy.stub().as('recordEvent') + + cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => { + recordEventStub(event) + + return defineResult({ recordEvent: true }) + }) + }) + + context('when banner not previously shown', () => { + beforeEach(() => { + cy.mount({ + render: () => , + }) + }) + + it('should record event', () => { + cy.get('@recordEvent').should('have.been.calledOnce') + cy.get('@recordEvent').should( + 'have.been.calledWith', + Cypress.sinon.match({ campaign: 'CAM', messageId: Cypress.sinon.match.string, medium: 'MED', cohort: 'COH' }), + ) + }) + + it('should debounce event recording', () => { + cy.wait(250) + cy.get('@recordEvent').should('have.been.calledOnce') + }) + }) + + context('when banner has been previously shown', () => { + beforeEach(() => { + cy.mount({ render: () => }) + }) + + it('should not record event', () => { + cy.get('@recordEvent').should('not.have.been.called') + }) + }) + }) }) diff --git a/packages/app/src/specs/banners/TrackedBanner.vue b/packages/app/src/specs/banners/TrackedBanner.vue index e9f8b925e32a..c4c71be73373 100644 --- a/packages/app/src/specs/banners/TrackedBanner.vue +++ b/packages/app/src/specs/banners/TrackedBanner.vue @@ -10,16 +10,25 @@ diff --git a/packages/app/src/specs/spec-utils.ts b/packages/app/src/specs/spec-utils.ts index 6806757bd187..728028e3c9d1 100644 --- a/packages/app/src/specs/spec-utils.ts +++ b/packages/app/src/specs/spec-utils.ts @@ -125,8 +125,10 @@ function getHighlightIndexes (node: SpecTreeNode) { } export function fuzzySortSpecs (specs: T[], searchValue: string) { + const normalizedSearchValue = getPlatform() === 'win32' ? searchValue.replaceAll('/', '\\') : searchValue + const fuzzySortResult = fuzzySort - .go(searchValue, specs, { keys: ['relative', 'baseName'], allowTypo: false, threshold: -3000 }) + .go(normalizedSearchValue, specs, { keys: ['relative', 'baseName'], allowTypo: false, threshold: -3000 }) .map((result) => { const [relative, baseName] = result diff --git a/packages/app/vite.config.mjs b/packages/app/vite.config.mjs index 021497ed8f98..c87d0fe78255 100644 --- a/packages/app/vite.config.mjs +++ b/packages/app/vite.config.mjs @@ -2,6 +2,7 @@ import { makeConfig } from '../frontend-shared/vite.config.mjs' import Layouts from 'vite-plugin-vue-layouts' import Pages from 'vite-plugin-pages' import Copy from 'rollup-plugin-copy' +import Legacy from '@vitejs/plugin-legacy' import { resolve } from 'path' export default makeConfig({ @@ -29,5 +30,10 @@ export default makeConfig({ dest: 'dist', }], }), + Legacy({ + targets: ['Chrome >= 64', 'Firefox >= 86', 'Edge >= 79'], + modernPolyfills: true, + renderLegacyChunks: false, + }) ], }) diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index 260e9ba18c67..0a367f0dbcc1 100644 --- a/packages/config/__snapshots__/index.spec.ts.js +++ b/packages/config/__snapshots__/index.spec.ts.js @@ -40,6 +40,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1 "experimentalSourceRewriting": false, "experimentalSingleTabRunMode": false, "experimentalStudio": false, + "experimentalWebKitSupport": false, "fileServerFolder": "", "fixturesFolder": "cypress/fixtures", "excludeSpecPattern": "*.hot-update.js", @@ -123,6 +124,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f "experimentalSourceRewriting": false, "experimentalSingleTabRunMode": false, "experimentalStudio": false, + "experimentalWebKitSupport": false, "fileServerFolder": "", "fixturesFolder": "cypress/fixtures", "excludeSpecPattern": "*.hot-update.js", @@ -202,6 +204,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key "experimentalSourceRewriting", "experimentalSingleTabRunMode", "experimentalStudio", + "experimentalWebKitSupport", "fileServerFolder", "fixturesFolder", "excludeSpecPattern", diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 843fb6e4ac71..dd39341ae123 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -227,6 +227,12 @@ const driverConfigOptions: Array = [ validation: validate.isBoolean, isExperimental: true, requireRestartOnChange: 'browser', + }, { + name: 'experimentalWebKitSupport', + defaultValue: false, + validation: validate.isBoolean, + isExperimental: true, + requireRestartOnChange: 'server', }, { name: 'fileServerFolder', defaultValue: '', diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index 2af4ff0b36be..843b5069d358 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -1044,6 +1044,7 @@ describe('config/src/project/utils', () => { experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, experimentalSourceRewriting: { value: false, from: 'default' }, + experimentalWebKitSupport: { value: false, from: 'default' }, fileServerFolder: { value: '', from: 'default' }, fixturesFolder: { value: 'cypress/fixtures', from: 'default' }, hosts: { value: null, from: 'default' }, @@ -1137,6 +1138,7 @@ describe('config/src/project/utils', () => { experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, experimentalSourceRewriting: { value: false, from: 'default' }, + experimentalWebKitSupport: { value: false, from: 'default' }, env: { foo: { value: 'foo', diff --git a/packages/data-context/src/DataActions.ts b/packages/data-context/src/DataActions.ts index 8a8276264bf0..24d02ba7abde 100644 --- a/packages/data-context/src/DataActions.ts +++ b/packages/data-context/src/DataActions.ts @@ -12,6 +12,7 @@ import { AuthActions, } from './actions' import { ErrorActions } from './actions/ErrorActions' +import { EventCollectorActions } from './actions/EventCollectorActions' import { VersionsActions } from './actions/VersionsActions' import { cached } from './util' @@ -77,4 +78,9 @@ export class DataActions { get versions () { return new VersionsActions(this.ctx) } + + @cached + get eventCollector () { + return new EventCollectorActions(this.ctx) + } } diff --git a/packages/data-context/src/actions/AuthActions.ts b/packages/data-context/src/actions/AuthActions.ts index 4fd173e7e23d..69bd610d1255 100644 --- a/packages/data-context/src/actions/AuthActions.ts +++ b/packages/data-context/src/actions/AuthActions.ts @@ -4,7 +4,7 @@ import type { AuthenticatedUserShape, AuthStateShape } from '../data' export interface AuthApiShape { getUser(): Promise> - logIn(onMessage: (message: AuthStateShape) => void, utmSource: string, utmMedium: string): Promise + logIn(onMessage: (message: AuthStateShape) => void, utmSource: string, utmMedium: string, utmContent: string | null): Promise logOut(): Promise resetAuthState(): void } @@ -48,7 +48,7 @@ export class AuthActions { } } - async login (utmSource: string, utmMedium: string) { + async login (utmSource: string, utmMedium: string, utmContent?: string | null) { const onMessage = (authState: AuthStateShape) => { this.ctx.update((coreData) => { coreData.authState = authState @@ -66,7 +66,7 @@ export class AuthActions { this.#cancelActiveLogin = () => resolve(null) // NOTE: auth.logIn should never reject, it uses `onMessage` to propagate state changes (including errors) to the frontend. - this.authApi.logIn(onMessage, utmSource, utmMedium).then(resolve, reject) + this.authApi.logIn(onMessage, utmSource, utmMedium, utmContent || null).then(resolve, reject) }) const isMainWindowFocused = this.ctx._apis.electronApi.isMainWindowFocused() @@ -75,7 +75,9 @@ export class AuthActions { const isBrowserFocusSupported = this.ctx.coreData.activeBrowser && await this.ctx.browser.isFocusSupported(this.ctx.coreData.activeBrowser) - if (!isBrowserFocusSupported) { + const isBrowserOpen = this.ctx.coreData.app.browserStatus === 'open' + + if (!isBrowserFocusSupported || !isBrowserOpen) { this.ctx._apis.electronApi.focusMainWindow() } else { await this.ctx.actions.browser.focusActiveBrowserWindow() diff --git a/packages/data-context/src/actions/EventCollectorActions.ts b/packages/data-context/src/actions/EventCollectorActions.ts new file mode 100644 index 000000000000..84fc84c8a009 --- /dev/null +++ b/packages/data-context/src/actions/EventCollectorActions.ts @@ -0,0 +1,38 @@ +import type { DataContext } from '..' +import Debug from 'debug' + +const debug = Debug('cypress:data-context:actions:EventCollectorActions') + +interface CollectableEvent { + campaign: string + messageId: string + medium: string + cohort?: string +} + +const cloudEnv = (process.env.CYPRESS_INTERNAL_EVENT_COLLECTOR_ENV || 'staging') as 'development' | 'staging' | 'production' + +export class EventCollectorActions { + constructor (private ctx: DataContext) { + debug('Using %s environment for Event Collection', cloudEnv) + } + + async recordEvent (event: CollectableEvent): Promise { + try { + const dashboardUrl = this.ctx.cloud.getDashboardUrl(cloudEnv) + + await this.ctx.util.fetch( + `${dashboardUrl}/anon-collect`, + { method: 'POST', body: JSON.stringify(event) }, + ) + + debug(`Recorded event: %o`, event) + + return true + } catch (err) { + debug(`Failed to record event %o due to error %o`, event, err) + + return false + } + } +} diff --git a/packages/data-context/src/actions/index.ts b/packages/data-context/src/actions/index.ts index 927c68695cea..aa499b57a7cf 100644 --- a/packages/data-context/src/actions/index.ts +++ b/packages/data-context/src/actions/index.ts @@ -8,6 +8,7 @@ export * from './DataEmitterActions' export * from './DevActions' export * from './ElectronActions' export * from './ErrorActions' +export * from './EventCollectorActions' export * from './FileActions' export * from './LocalSettingsActions' export * from './MigrationActions' diff --git a/packages/data-context/src/data/ProjectConfigIpc.ts b/packages/data-context/src/data/ProjectConfigIpc.ts index a2125ae9844f..3a96e89f9912 100644 --- a/packages/data-context/src/data/ProjectConfigIpc.ts +++ b/packages/data-context/src/data/ProjectConfigIpc.ts @@ -271,7 +271,9 @@ export class ProjectConfigIpc extends EventEmitter { // ts-node for CommonJS // ts-node/esm for ESM if (hasTypeScriptInstalled(this.projectRoot)) { + debug('found typescript in %s', this.projectRoot) if (isProjectUsingESModules) { + debug(`using --experimental-specifier-resolution=node with --loader ${tsNodeEsm}`) // Use the ts-node/esm loader so they can use TypeScript with `"type": "module". // The loader API is experimental and will change. // The same can be said for the other alternative, esbuild, so this is the @@ -294,6 +296,8 @@ export class ProjectConfigIpc extends EventEmitter { // so we need to load and evaluate the hook first using the `--require` module API. const tsNodeLoader = `--require "${tsNode}"` + debug(`using cjs with --require ${tsNode}`) + if (childOptions.env.NODE_OPTIONS) { childOptions.env.NODE_OPTIONS += ` ${tsNodeLoader}` } else { @@ -303,6 +307,7 @@ export class ProjectConfigIpc extends EventEmitter { } else { // Just use Node's built-in ESM support. // TODO: Consider using userland `esbuild` with Node's --loader API to handle ESM. + debug(`no typescript found, just use regular Node.js`) } return fork(CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions) diff --git a/packages/data-context/src/data/ProjectConfigManager.ts b/packages/data-context/src/data/ProjectConfigManager.ts index 258ff7209fe9..7b2bbe4e9b12 100644 --- a/packages/data-context/src/data/ProjectConfigManager.ts +++ b/packages/data-context/src/data/ProjectConfigManager.ts @@ -191,12 +191,6 @@ export class ProjectConfigManager { return } - const result = await isDependencyInstalled(bundler, this.options.projectRoot) - - if (!result.satisfied) { - unsupportedDeps.set(result.dependency.type, result) - } - const isFrameworkSatisfied = async (bundler: typeof WIZARD_BUNDLERS[number], framework: typeof WIZARD_FRAMEWORKS[number]) => { for (const dep of await (framework.dependencies(bundler.type, this.options.projectRoot))) { const res = await isDependencyInstalled(dep.dependency, this.options.projectRoot) @@ -506,14 +500,22 @@ export class ProjectConfigManager { } fullConfig.browsers = fullConfig.browsers?.map((browser) => { - if (browser.family === 'chromium' || fullConfig.chromeWebSecurity) { - return browser + if (browser.family === 'webkit' && !fullConfig.experimentalWebKitSupport) { + return { + ...browser, + disabled: true, + warning: '`playwright-webkit` is installed and WebKit is detected, but `experimentalWebKitSupport` is not enabled in your Cypress config. Set it to `true` to use WebKit.', + } } - return { - ...browser, - warning: browser.warning || getError('CHROME_WEB_SECURITY_NOT_SUPPORTED', browser.name).message, + if (browser.family !== 'chromium' && fullConfig.chromeWebSecurity) { + return { + ...browser, + warning: browser.warning || getError('CHROME_WEB_SECURITY_NOT_SUPPORTED', browser.name).message, + } } + + return browser }) // If we have withBrowsers set to false, it means we're coming from the legacy config.get API diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index db8d09eca653..2dfdc3de732b 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -380,6 +380,8 @@ export class ProjectLifecycleManager { } private _setCurrentProject (projectRoot: string) { + process.chdir(projectRoot) + this._projectRoot = projectRoot this._initializedProject = undefined diff --git a/packages/data-context/src/sources/CloudDataSource.ts b/packages/data-context/src/sources/CloudDataSource.ts index a63bd4681437..1fca98c176ee 100644 --- a/packages/data-context/src/sources/CloudDataSource.ts +++ b/packages/data-context/src/sources/CloudDataSource.ts @@ -99,7 +99,7 @@ export class CloudDataSource { reset () { return this.#cloudUrqlClient = createClient({ - url: `${REMOTE_SCHEMA_URLS[cloudEnv]}/test-runner-graphql`, + url: `${this.getDashboardUrl(cloudEnv)}/test-runner-graphql`, exchanges: [ dedupExchange, cacheExchange({ @@ -332,4 +332,8 @@ export class CloudDataSource { return JSON.parse(this.#lastCache ?? '') } + + getDashboardUrl (env: keyof typeof REMOTE_SCHEMA_URLS) { + return REMOTE_SCHEMA_URLS[env] + } } diff --git a/packages/data-context/test/unit/actions/AuthActions.spec.ts b/packages/data-context/test/unit/actions/AuthActions.spec.ts index 0f4535223791..fb0b54d84426 100644 --- a/packages/data-context/test/unit/actions/AuthActions.spec.ts +++ b/packages/data-context/test/unit/actions/AuthActions.spec.ts @@ -49,9 +49,37 @@ describe('AuthActions', () => { expect(ctx._apis.browserApi.focusActiveBrowserWindow).to.not.be.called }) - it('focuses the browser if the activeBrowser does support focus', async () => { + it('focuses the main window if the activeBrowser supports focus, but browser is closed', async () => { const browser = ctx.coreData.activeBrowser = { name: 'foo' } as FoundBrowser + ctx.coreData.app.browserStatus = 'closed' + + sinon.stub(ctx.browser, 'isFocusSupported').withArgs(browser).resolves(true) + + await actions.login() + + expect(ctx._apis.electronApi.focusMainWindow).to.be.calledOnce + expect(ctx._apis.browserApi.focusActiveBrowserWindow).to.not.be.called + }) + + it('focuses the main window if the activeBrowser supports focus, but browser is opening', async () => { + const browser = ctx.coreData.activeBrowser = { name: 'foo' } as FoundBrowser + + ctx.coreData.app.browserStatus = 'opening' + + sinon.stub(ctx.browser, 'isFocusSupported').withArgs(browser).resolves(true) + + await actions.login() + + expect(ctx._apis.electronApi.focusMainWindow).to.be.calledOnce + expect(ctx._apis.browserApi.focusActiveBrowserWindow).to.not.be.called + }) + + it('focuses the browser if the activeBrowser does support focus and browser is open', async () => { + const browser = ctx.coreData.activeBrowser = { name: 'foo' } as FoundBrowser + + ctx.coreData.app.browserStatus = 'open' + sinon.stub(ctx.browser, 'isFocusSupported').withArgs(browser).resolves(true) await actions.login() diff --git a/packages/data-context/test/unit/actions/EventCollectorActions.spec.ts b/packages/data-context/test/unit/actions/EventCollectorActions.spec.ts new file mode 100644 index 000000000000..ae1f3c5c4d2a --- /dev/null +++ b/packages/data-context/test/unit/actions/EventCollectorActions.spec.ts @@ -0,0 +1,55 @@ +import type { DataContext } from '../../../src' +import { EventCollectorActions } from '../../../src/actions/EventCollectorActions' +import { createTestDataContext } from '../helper' +import sinon, { SinonStub } from 'sinon' +import sinonChai from 'sinon-chai' +import chai, { expect } from 'chai' + +chai.use(sinonChai) + +describe('EventCollectorActions', () => { + let ctx: DataContext + let actions: EventCollectorActions + + beforeEach(() => { + sinon.restore() + + ctx = createTestDataContext('open') + + sinon.stub(ctx.util, 'fetch').resolves({} as any) + + actions = new EventCollectorActions(ctx) + }) + + context('.recordEvent', () => { + it('makes expected request', async () => { + await actions.recordEvent({ + campaign: 'abc', + medium: 'def', + messageId: 'ghi', + cohort: '123', + }) + + expect(ctx.util.fetch).to.have.been.calledOnceWith( + sinon.match(/anon-collect$/), // Verify URL ends with expected 'anon-collect' path + { method: 'POST', body: '{"campaign":"abc","medium":"def","messageId":"ghi","cohort":"123"}' }, + ) + }) + + it('resolve true if request succeeds', async () => { + (ctx.util.fetch as SinonStub).resolves({} as any) + + const result = await actions.recordEvent({ campaign: '', medium: '', messageId: '', cohort: '' }) + + expect(result).to.eql(true) + }) + + it('resolves false if request fails', async () => { + (ctx.util.fetch as SinonStub).rejects({} as any) + + const result = await actions.recordEvent({ campaign: '', medium: '', messageId: '', cohort: '' }) + + expect(result).to.eql(false) + }) + }) +}) diff --git a/packages/data-context/test/unit/sources/WizardDataSource.spec.ts b/packages/data-context/test/unit/sources/WizardDataSource.spec.ts index 334fe28c00c9..6bb6367d8877 100644 --- a/packages/data-context/test/unit/sources/WizardDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/WizardDataSource.spec.ts @@ -27,7 +27,7 @@ describe('packagesToInstall', () => { const actual = await ctx.wizard.installDependenciesCommand() - expect(actual).to.eq(`npm install -D react-scripts webpack react-dom react`) + expect(actual).to.eq(`npm install -D react-scripts react-dom react`) }) it('vueclivue2-unconfigured', async () => { @@ -43,7 +43,7 @@ describe('packagesToInstall', () => { const actual = await ctx.wizard.installDependenciesCommand() - expect(actual).to.eq(`npm install -D @vue/cli-service webpack vue@2`) + expect(actual).to.eq(`npm install -D @vue/cli-service vue@2`) }) it('vueclivue3-unconfigured', async () => { @@ -59,7 +59,7 @@ describe('packagesToInstall', () => { const actual = await ctx.wizard.installDependenciesCommand() - expect(actual).to.eq(`npm install -D @vue/cli-service webpack vue`) + expect(actual).to.eq(`npm install -D @vue/cli-service vue`) }) it('vuecli5vue3-unconfigured', async () => { @@ -75,7 +75,7 @@ describe('packagesToInstall', () => { const actual = await ctx.wizard.installDependenciesCommand() - expect(actual).to.eq(`npm install -D @vue/cli-service webpack vue`) + expect(actual).to.eq(`npm install -D @vue/cli-service vue`) }) it('regular react project with vite', async () => { diff --git a/packages/driver/README.md b/packages/driver/README.md index be0eb98b900c..1c469c7a1145 100644 --- a/packages/driver/README.md +++ b/packages/driver/README.md @@ -82,7 +82,6 @@ TODO: this data is accurate but also somewhat out of date. | mocha:fail | Mocha | Cypress | when mocha runner fires its 'fail' event | | test:end | Mocha | Cypress | when mocha runner fires its 'test end' event | | test:results:ready | Runner | Anyone | when we receive the 'test:end' event | -| test:after:hooks | Cypress | Cypress | after all hooks have run for a test | | test:after:run | Cypress | Anyone | after any code has run for a test | | mocha:end | Mocha | Cypress | when mocha runner fires its 'end' event | | after:run | Runner | Anyone | after run has finished | diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index f555c0fd7527..c803aac6b845 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -1,21 +1,23 @@ import { defineConfig } from 'cypress' export default defineConfig({ - 'projectId': 'ypt4pf', - 'experimentalStudio': true, - 'hosts': { + projectId: 'ypt4pf', + experimentalStudio: true, + experimentalWebKitSupport: true, + hosts: { '*.foobar.com': '127.0.0.1', + '*.barbaz.com': '127.0.0.1', '*.idp.com': '127.0.0.1', 'localalias': '127.0.0.1', }, - 'reporter': 'cypress-multi-reporters', - 'reporterOptions': { - 'configFile': '../../mocha-reporter-config.json', + reporter: 'cypress-multi-reporters', + reporterOptions: { + configFile: '../../mocha-reporter-config.json', }, - 'e2e': { - 'setupNodeEvents': (on, config) => { + e2e: { + setupNodeEvents: (on, config) => { return require('./cypress/plugins')(on, config) }, - 'baseUrl': 'http://localhost:3500', + baseUrl: 'http://localhost:3500', }, }) diff --git a/packages/driver/cypress/e2e/commands/aliasing.cy.js b/packages/driver/cypress/e2e/commands/aliasing.cy.js index 0e0a2544ff41..524c7c44259b 100644 --- a/packages/driver/cypress/e2e/commands/aliasing.cy.js +++ b/packages/driver/cypress/e2e/commands/aliasing.cy.js @@ -101,6 +101,15 @@ describe('src/cy/commands/aliasing', () => { expect(this.input).to.eq(obj) }) }) + + it('retries previous commands invoked inside custom commands', () => { + Cypress.Commands.add('get2', (selector) => cy.get(selector)) + + cy.get2('body').children('div').as('divs') + cy.visit('/fixtures/dom.html') + + cy.get('@divs') + }) }) context('#assign', () => { diff --git a/packages/driver/cypress/e2e/commands/assertions.cy.js b/packages/driver/cypress/e2e/commands/assertions.cy.js index 2ccbb233d8d7..d09ed0d52bbb 100644 --- a/packages/driver/cypress/e2e/commands/assertions.cy.js +++ b/packages/driver/cypress/e2e/commands/assertions.cy.js @@ -182,6 +182,29 @@ describe('src/cy/commands/assertions', () => { }) }) + /* + * There was a bug (initially discovered as part of https://github.com/cypress-io/cypress/issues/23699 but not + * directly related) in our copy of chai where, when an element with a trailing space was asserted on, + * the log message would oscilate rapidly between two states. This happened because we were re-using a global + * regular expression - which tracks internal state. + * + * https://stackoverflow.com/questions/15276873/is-javascript-test-saving-state-in-the-regex + */ + it('should be consistent with log message across retries', (done) => { + let assertionMessage + + cy.on('command:retry', () => { + if (assertionMessage) { + expect(assertionMessage).to.equal(cy.state('current').get('logs')[1].get('message')) + done() + } + + assertionMessage = cy.state('current').get('logs')[1].get('message') + }) + + cy.get('#with-trailing-space').should('have.text', 'I\'ve got a lovely bunch of coconuts') + }) + describe('function argument', () => { it('waits until function is true', () => { const button = cy.$$('button:first') diff --git a/packages/driver/cypress/e2e/cypress/error_utils.cy.ts b/packages/driver/cypress/e2e/cypress/error_utils.cy.ts index 6d8e8d0e75bb..f2cad64afd05 100644 --- a/packages/driver/cypress/e2e/cypress/error_utils.cy.ts +++ b/packages/driver/cypress/e2e/cypress/error_utils.cy.ts @@ -392,23 +392,16 @@ describe('driver/src/cypress/error_utils', () => { beforeEach(() => { $stackUtils.replacedStack = cy.stub().returns('replaced stack') - $stackUtils.stackWithUserInvocationStackSpliced = cy.stub().returns({ stack: 'spliced stack' }) $stackUtils.getSourceStack = cy.stub().returns(sourceStack) $stackUtils.getCodeFrame = cy.stub().returns(codeFrame) err = { stack: 'Error: original stack message\n at originalStack (foo.js:1:1)' } }) - it('replaces stack with user invocation stack', () => { + it('replaces stack with source map stack', () => { const result = $errUtils.enhanceStack({ err, userInvocationStack }) - expect(result.stack).to.equal('replaced stack') - }) - - it('attaches source mapped stack', () => { - const result = $errUtils.enhanceStack({ err, userInvocationStack }) - - expect(result.sourceMappedStack).to.equal(sourceStack.sourceMapped) + expect(result.stack).to.equal(sourceStack.sourceMapped) }) it('attaches parsed stack', () => { @@ -426,17 +419,16 @@ describe('driver/src/cypress/error_utils', () => { it('appends user invocation stack when it is a cypress error', () => { err.name = 'CypressError' - const result = $errUtils.enhanceStack({ err, userInvocationStack }) - - expect(result.stack).to.equal('spliced stack') + cy.spy($stackUtils, 'stackWithUserInvocationStackSpliced') + $errUtils.enhanceStack({ err, userInvocationStack }) + expect($stackUtils.stackWithUserInvocationStackSpliced).to.be.called }) it('appends user invocation stack when it is a chai validation error', () => { err.message = 'Invalid Chai property' - - const result = $errUtils.enhanceStack({ err, userInvocationStack }) - - expect(result.stack).to.equal('spliced stack') + cy.spy($stackUtils, 'stackWithUserInvocationStackSpliced') + $errUtils.enhanceStack({ err, userInvocationStack }) + expect($stackUtils.stackWithUserInvocationStackSpliced).to.be.called }) it('does not replaced or append stack when there is no invocation stack', () => { diff --git a/packages/driver/cypress/e2e/cypress/proxy-logging.cy.ts b/packages/driver/cypress/e2e/cypress/proxy-logging.cy.ts index 0d99501c4d57..7347de2433a8 100644 --- a/packages/driver/cypress/e2e/cypress/proxy-logging.cy.ts +++ b/packages/driver/cypress/e2e/cypress/proxy-logging.cy.ts @@ -321,7 +321,8 @@ describe('Proxy Logging', () => { }) }) - it('works with forceNetworkError', () => { + // TODO(webkit): fix forceNetworkError and unskip + it('works with forceNetworkError', { browser: '!webkit' }, () => { const logs: any[] = [] cy.on('log:added', (log) => { diff --git a/packages/driver/cypress/e2e/cypress/script_utils.cy.js b/packages/driver/cypress/e2e/cypress/script_utils.cy.js index acb7d89311d5..e473c6cb7c7a 100644 --- a/packages/driver/cypress/e2e/cypress/script_utils.cy.js +++ b/packages/driver/cypress/e2e/cypress/script_utils.cy.js @@ -35,8 +35,8 @@ describe('src/cypress/script_utils', () => { return $scriptUtils.runScripts(scriptWindow, scripts) .then(() => { expect($sourceMapUtils.extractSourceMap).to.be.calledTwice - expect($sourceMapUtils.extractSourceMap).to.be.calledWith(scripts[0], 'the script contents') - expect($sourceMapUtils.extractSourceMap).to.be.calledWith(scripts[1], 'the script contents') + expect($sourceMapUtils.extractSourceMap).to.be.calledWith('the script contents') + expect($sourceMapUtils.extractSourceMap).to.be.calledWith('the script contents') }) }) diff --git a/packages/driver/cypress/e2e/cypress/source_map_utils.cy.js b/packages/driver/cypress/e2e/cypress/source_map_utils.cy.js index 801c613b14f0..90f6522bc67e 100644 --- a/packages/driver/cypress/e2e/cypress/source_map_utils.cy.js +++ b/packages/driver/cypress/e2e/cypress/source_map_utils.cy.js @@ -46,19 +46,19 @@ const file2 = createFile('file2') describe('driver/src/cypress/source_map_utils', () => { context('.extractSourceMap', () => { it('returns null if there is no source map embedded', () => { - const sourceMap = $sourceMapUtils.extractSourceMap(file2, testContent) + const sourceMap = $sourceMapUtils.extractSourceMap(testContent) expect(sourceMap).to.be.null }) it('returns null if it is not an inline map', () => { - const sourceMap = $sourceMapUtils.extractSourceMap(file2, `${testContent}\n\/\/# sourceMappingURL=foo.map`) + const sourceMap = $sourceMapUtils.extractSourceMap(`${testContent}\n\/\/# sourceMappingURL=foo.map`) expect(sourceMap).to.be.null }) it('returns source map when content has an inline map', () => { - const sourceMap = $sourceMapUtils.extractSourceMap(file1, fileContents) + const sourceMap = $sourceMapUtils.extractSourceMap(fileContents) expect(sourceMap).to.be.eql(sourceMap) }) @@ -71,7 +71,7 @@ describe('driver/src/cypress/source_map_utils', () => { const timeLimit = 10 const startTime = Date.now() - $sourceMapUtils.extractSourceMap(file1, fileContents) + $sourceMapUtils.extractSourceMap(fileContents) const endTime = Date.now() @@ -81,7 +81,7 @@ describe('driver/src/cypress/source_map_utils', () => { // https://github.com/cypress-io/cypress/issues/7481 it('does not garble utf-8 characters', () => { - const sourceMap = $sourceMapUtils.extractSourceMap(file1, fileContents) + const sourceMap = $sourceMapUtils.extractSourceMap(fileContents) expect(sourceMap.sourcesContent[1]).to.include('サイプリスは一番') }) diff --git a/packages/driver/cypress/e2e/cypress/stack_utils.cy.js b/packages/driver/cypress/e2e/cypress/stack_utils.cy.js index e5416a5ca742..4b6e069c53bd 100644 --- a/packages/driver/cypress/e2e/cypress/stack_utils.cy.js +++ b/packages/driver/cypress/e2e/cypress/stack_utils.cy.js @@ -1,6 +1,5 @@ const $stackUtils = require('@packages/driver/src/cypress/stack_utils').default const $sourceMapUtils = require('@packages/driver/src/cypress/source_map_utils').default -const { stripIndent } = require('common-tags') describe('driver/src/cypress/stack_utils', () => { context('.replacedStack', () => { @@ -80,13 +79,13 @@ describe('driver/src/cypress/stack_utils', () => { expect(codeFrame).to.be.an('object') expect(codeFrame.frame).to.contain(` 1 | it('is a failing test', () => {`) expect(codeFrame.frame).to.contain(`> 2 | cy.get('.not-there'`) - expect(codeFrame.frame).to.contain(` | ^`) + expect(codeFrame.frame).to.contain(` | ^`) expect(codeFrame.frame).to.contain(` 3 | }`) expect(codeFrame.absoluteFile).to.equal('/dev/app/cypress/integration/features/source_map_spec.js') expect(codeFrame.relativeFile).to.equal('cypress/integration/features/source_map_spec.js') expect(codeFrame.language).to.equal('js') expect(codeFrame.line).to.equal(2) - expect(codeFrame.column).to.eq(5) + expect(codeFrame.column).to.eq(6) }) it('does not add code frame if stack does not yield one', () => { @@ -111,7 +110,7 @@ describe('driver/src/cypress/stack_utils', () => { .then((errorLocation) => { expect(errorLocation, 'does not have disk information').to.deep.equal({ absoluteFile: undefined, - column: 4, + column: 3, fileUrl: 'http://localhost:8888/js/utils.js', function: '', line: 9, @@ -150,8 +149,8 @@ describe('driver/src/cypress/stack_utils', () => { const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot) expect(sourceStack.sourceMapped).to.equal(`Error: spec iframe stack - at foo.bar (some_other_file.ts:2:2) - at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\ + at foo.bar (some_other_file.ts:2:1) + at Context. (cypress/integration/features/source_map_spec.coffee:4:3)\ `) expect(sourceStack.parsed).to.eql([ @@ -166,7 +165,7 @@ describe('driver/src/cypress/stack_utils', () => { relativeFile: 'some_other_file.ts', absoluteFile: '/dev/app/some_other_file.ts', line: 2, - column: 2, + column: 1, whitespace: ' ', }, { @@ -176,7 +175,7 @@ describe('driver/src/cypress/stack_utils', () => { relativeFile: 'cypress/integration/features/source_map_spec.coffee', absoluteFile: '/dev/app/cypress/integration/features/source_map_spec.coffee', line: 4, - column: 4, + column: 3, whitespace: ' ', }, ]) @@ -186,8 +185,8 @@ describe('driver/src/cypress/stack_utils', () => { const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot) expect(sourceStack.sourceMapped).to.equal(`Error: spec iframe stack - at foo.bar (some_other_file.ts:2:2) - at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\ + at foo.bar (some_other_file.ts:2:1) + at Context. (cypress/integration/features/source_map_spec.coffee:4:3)\ `) }) @@ -195,8 +194,8 @@ describe('driver/src/cypress/stack_utils', () => { generatedStack = generatedStack.split('\n').slice(1).join('\n') const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot) - expect(sourceStack.sourceMapped).to.equal(` at foo.bar (some_other_file.ts:2:2) - at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\ + expect(sourceStack.sourceMapped).to.equal(` at foo.bar (some_other_file.ts:2:1) + at Context. (cypress/integration/features/source_map_spec.coffee:4:3)\ `) }) @@ -209,8 +208,8 @@ more lines Error: spec iframe stack - at foo.bar (some_other_file.ts:2:2) - at Context. (cypress/integration/features/source_map_spec.coffee:4:4)\ + at foo.bar (some_other_file.ts:2:1) + at Context. (cypress/integration/features/source_map_spec.coffee:4:3)\ `) }) @@ -230,8 +229,8 @@ Error: spec iframe stack const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot) expect(sourceStack.sourceMapped).to.equal(`Error: spec iframe stack - at foo.bar (cypress:///some_other_file.ts:2:2) - at Context. (webpack:///cypress/integration/features/source_map_spec.coffee:4:4)\ + at foo.bar (cypress:///some_other_file.ts:2:1) + at Context. (webpack:///cypress/integration/features/source_map_spec.coffee:4:3)\ `) expect(sourceStack.parsed).to.eql([ @@ -246,7 +245,7 @@ Error: spec iframe stack relativeFile: 'some_other_file.ts', absoluteFile: '/dev/app/some_other_file.ts', line: 2, - column: 2, + column: 1, whitespace: ' ', }, { @@ -256,7 +255,7 @@ Error: spec iframe stack relativeFile: 'cypress/integration/features/source_map_spec.coffee', absoluteFile: '/dev/app/cypress/integration/features/source_map_spec.coffee', line: 4, - column: 4, + column: 3, whitespace: ' ', }, ]) @@ -278,8 +277,8 @@ Error: spec iframe stack const sourceStack = $stackUtils.getSourceStack(generatedStack, projectRoot) expect(sourceStack.sourceMapped).to.equal(`Error: spec iframe stack - at foo.bar (cypress:////root/absolute/path/some_other_file.ts:2:2) - at Context. (webpack:////root/absolute/path/cypress/integration/features/source_map_spec.coffee:4:4)\ + at foo.bar (cypress:////root/absolute/path/some_other_file.ts:2:1) + at Context. (webpack:////root/absolute/path/cypress/integration/features/source_map_spec.coffee:4:3)\ `) expect(sourceStack.parsed).to.eql([ @@ -294,7 +293,7 @@ Error: spec iframe stack relativeFile: '/root/absolute/path/some_other_file.ts', absoluteFile: '/root/absolute/path/some_other_file.ts', line: 2, - column: 2, + column: 1, whitespace: ' ', }, { @@ -304,7 +303,7 @@ Error: spec iframe stack relativeFile: '/root/absolute/path/cypress/integration/features/source_map_spec.coffee', absoluteFile: '/root/absolute/path/cypress/integration/features/source_map_spec.coffee', line: 4, - column: 4, + column: 3, whitespace: ' ', }, ]) @@ -317,7 +316,7 @@ Error: spec iframe stack context('.getSourceDetailsForFirstLine', () => { it('parses good stack trace', () => { - const stack = stripIndent` + const stack = ` Error at Suite.eval (http://localhost:8888/__cypress/tests?p=cypress/integration/spec.js:101:3) at Object../cypress/integration/spec.js (http://localhost:8888/__cypress/tests?p=cypress/integration/spec.js:100:1) @@ -336,7 +335,7 @@ Error: spec iframe stack }) it('parses anonymous eval line', () => { - const stack = stripIndent` + const stack = ` SyntaxError: The following error originated from your application code, not from Cypress. > Identifier 'app' has already been declared @@ -381,13 +380,13 @@ Error: spec iframe stack expect(details, 'minimal details').to.deep.equal({ absoluteFile: undefined, - column: 2, + column: 1, fileUrl: undefined, function: '', line: 1, originalFile: undefined, relativeFile: undefined, - whitespace: ' ', + whitespace: ' ', }) }) @@ -400,7 +399,7 @@ Error: spec iframe stack }) // stack is fairly irrelevant in this test - testing transforming getSourcePosition response - const stack = stripIndent` + const stack = ` Error at Object../cypress/integration/spec%with space &^$ emoji👍_你好.js (http://localhost:50129/__cypress/tests?p=cypress/integration/spec%25with%20space%20%26^$%20emoji👍_你好.js:99:1) ` @@ -421,7 +420,7 @@ Error: spec iframe stack }) // stack is fairly irrelevant in this test - testing transforming getSourcePosition response - const stack = stripIndent` + const stack = ` Error at Object../cypress/integration/spec.js (http://localhost:50129/__cypress/tests?p=/root/path/cypress/integration/spec.js:99:1) ` diff --git a/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts b/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts new file mode 100644 index 000000000000..4726fb353f17 --- /dev/null +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts @@ -0,0 +1,1435 @@ +describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { + const makeRequest = ( + win: Cypress.AUTWindow, + url: string, + client: 'fetch' | 'xmlHttpRequest' = 'xmlHttpRequest', + credentials: 'same-origin' | 'include' | 'omit' | boolean = false, + ) => { + if (client === 'fetch') { + // if a boolean is specified, make sure the default is applied + credentials = Cypress._.isBoolean(credentials) ? 'same-origin' : credentials + + return win.fetch(url, { credentials }) + } + + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest() + + xhr.open('GET', url) + xhr.withCredentials = Cypress._.isBoolean(credentials) ? credentials : false + xhr.onload = function () { + resolve(xhr.response) + } + + xhr.onerror = function () { + reject(xhr.response) + } + + xhr.send() + }) + } + + const serverConfig = { + http: { + sameOriginPort: 3500, + crossOriginPort: 3501, + }, + https: { + sameOriginPort: 3502, + crossOriginPort: 3503, + }, + } + + beforeEach(() => { + // FIXME: clearing cookies in the browser currently does not clear cookies in the server-side cookie jar + cy.clearCookies() + }) + + Object.keys(serverConfig).forEach((scheme) => { + const sameOriginPort = serverConfig[scheme].sameOriginPort + const crossOriginPort = serverConfig[scheme].crossOriginPort + + describe(`Scheme: ${scheme}://`, () => { + // with cy.origin means the AUT has navigated away and the top origin does NOT match the AUT origin + // the server side cookie jar now needs to simulate the AUT url as top + describe('w/ cy.origin', () => { + let originUrl: string + + before(() => { + originUrl = `${scheme}://www.foobar.com:${sameOriginPort}` + + // add httpClient here globally until Cypress.require PR is merged + cy.origin(`${scheme}://www.foobar.com:${sameOriginPort}`, () => { + const makeRequest = ( + win: Cypress.AUTWindow, + url: string, + client: 'fetch' | 'xmlHttpRequest' = 'xmlHttpRequest', + credentials: 'same-origin' | 'include' | 'omit' | boolean = false, + ) => { + if (client === 'fetch') { + // if a boolean is specified, make sure the default is applied + credentials = Cypress._.isBoolean(credentials) ? 'same-origin' : credentials + + return win.fetch(url, { credentials }) + } + + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest() + + xhr.open('GET', url) + xhr.withCredentials = Cypress._.isBoolean(credentials) ? credentials : false + xhr.onload = function () { + resolve(xhr.response) + } + + xhr.onerror = function () { + reject(xhr.response) + } + + xhr.send() + }) + } + + // @ts-ignore + window.makeRequest = makeRequest + }) + }) + + describe('same site / same origin', () => { + describe('XMLHttpRequest', () => { + // withCredentials option should have no effect on same-site requests + // XHR requests seem like they need to be absolute within a cy.origin block, but fetch requests can be relative? + it('sets and attaches same-site cookies to request', () => { + cy.intercept(`${originUrl}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: originUrl, + }, (originUrl) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${originUrl}/set-cookie?cookie=foo1=bar1; Domain=foobar.com`, 'xmlHttpRequest')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${originUrl}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + + describe('fetch', () => { + it('sets and attaches same-site cookies to request by default (same-origin)', () => { + cy.intercept(`${originUrl}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, () => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/test-request', 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + + // this test should behave exactly the same as the (same-origin) test, but adding here incase our implementation is not consistent + it('sets and attaches same-site cookies to request if "include" credentials option is specified', () => { + cy.intercept(`${originUrl}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, () => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/test-request', 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does NOT attach same-site cookies to request if "omit" credentials option is specified', () => { + cy.intercept(`${originUrl}/test-request`, (req) => { + // current expected assertion with server side cookie jar is set from previous test + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion, regardless of server side cookie jar + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, () => { + cy.window().then((win) => { + // set the cookie in the browser + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + // but omit the cookies in the request + return cy.wrap(makeRequest(win, '/test-request', 'fetch', 'omit')) + }) + + cy.wait('@cookieCheck') + }) + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does NOT set same-site cookies from request if "omit" credentials option is specified', () => { + cy.intercept(`${originUrl}/test-request`, (req) => { + // current expected assertion with server side cookie jar is set from previous test + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion, regardless of server side cookie jar + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, () => { + cy.window().then((win) => { + // do NOT set the cookie in the browser + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch', 'omit')) + }) + + cy.window().then((win) => { + // but send the cookies in the request + return cy.wrap(makeRequest(win, '/test-request', 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + }) + + describe('same site / cross origin', () => { + describe('XMLHttpRequest', () => { + // withCredentials option should have no effect on same-site requests, even though the request is cross-origin + it('sets and attaches same-site cookies to request, even though request is cross-origin', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + // do NOT set the cookie in the browser + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie?cookie=foo1=bar1; Domain=foobar.com`, 'xmlHttpRequest')) + }) + + cy.window().then((win) => { + // but send the cookies in the request + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + + describe('fetch', () => { + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does not set same-site cookies from request nor send same-site cookies by default (same-origin)', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + + it('sets same-site cookies from request and sends same-site cookies if "include" credentials option is specified', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch', 'include')) + }) + + // assert cookie value is actually set in the browser + // current expected assertion. NOTE: This SHOULD be consistent + if (Cypress.isBrowser('firefox')) { + // firefox actually sets the cookie correctly + cy.getCookie('foo1').its('value').should('equal', 'bar1') + } else { + cy.getCookie('foo1').its('value').should('equal', null) + } + + // FIXME: ideally, browser should have access to this cookie + // future expected assertion + // cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch', 'include')) + }) + }) + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('sets same-site cookies if "include" credentials option is specified from request, but does not attach same-site cookies to request by default (same-origin)', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch', 'include')) + }) + + // assert cookie value is actually set in the browser + // current expected assertion. NOTE: This SHOULD be consistent + if (Cypress.isBrowser('firefox')) { + // firefox actually sets the cookie correctly + cy.getCookie('foo1').its('value').should('equal', 'bar1') + } else { + cy.getCookie('foo1').its('value').should('equal', null) + } + + // FIXME: ideally, browser should have access to this cookie + // future expected assertion + // cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + // this should have the same effect as same-origin option for same-site/cross-origin requests, but adding here incase our implementation is not consistent + it('does not set or send same-site cookies if "omit" credentials option is specified', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch', 'omit')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch', 'omit')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + }) + + describe('cross site / cross origin', () => { + describe('XMLHttpRequest', () => { + it('does NOT set or send cookies with request by default', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + }, + }, ({ scheme, sameOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie?cookie=bar1=baz1; Domain=barbaz.com`, 'xmlHttpRequest')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + }) + + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + if (scheme === 'https') { + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does set cookie if withCredentials is true, but does not send cookie if withCredentials is false', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('bar1=baz1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + }, + }, ({ scheme, sameOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'xmlHttpRequest', true)) + }) + + // assert cookie value is actually set in the browser + if (scheme === 'https') { + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + } else { + cy.getCookie('bar1').its('value').should('equal', null) + } + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + }) + + it('does set cookie if withCredentials is true, and sends cookie if withCredentials is true', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('bar1=baz1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + }, + }, ({ scheme, sameOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'xmlHttpRequest', true)) + }) + + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, 'xmlHttpRequest', true)) + }) + + cy.wait('@cookieCheck') + }) + }) + } + }) + + describe('fetch', () => { + ['same-origin', 'omit'].forEach((credentialOption) => { + it(`does NOT set or send cookies with request by credentials is ${credentialOption}`, () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + credentialOption, + }, + }, ({ scheme, sameOriginPort, credentialOption }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie?cookie=bar1=baz1; Domain=barbaz.com`, 'fetch', credentialOption as 'same-origin' | 'omit')) + }) + + cy.getCookie('bar1').its('value').should('equal', null) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'fetch', credentialOption as 'same-origin' | 'omit')) + }) + + cy.wait('@cookieCheck') + }) + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it(`does set cookie if credentials is "include", but does not send cookie if credentials is ${credentialOption}`, () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + // current expected assertion + if (scheme === 'https') { + expect(req['headers']['cookie']).to.equal('bar1=baz1') + } else { + expect(req['headers']['cookie']).to.equal('') + } + + // future expected assertion for both http / https + // expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + credentialOption, + }, + }, ({ scheme, sameOriginPort, credentialOption }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'fetch', 'include')) + }) + + // assert cookie value is actually set in the browser + if (scheme === 'https') { + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + } else { + cy.getCookie('bar1').its('value').should('equal', null) + } + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'fetch', credentialOption as 'same-origin' | 'omit')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + if (scheme === 'https') { + it('does set cookie if credentials is "include", and sends cookie if credentials is "include"', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('bar1=baz1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + }, + }, ({ scheme, sameOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'fetch', 'include')) + }) + + // assert cookie value is actually set in the browser + + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + }) + } + }) + }) + + describe('misc', () => { + describe('domains', () => { + it('attaches subdomain and TLD cookies when making subdomain requests', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('bar=baz; baz=quux') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo=bar; Domain=www.foobar.com', 'fetch', 'include')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=bar=baz; Domain=.foobar.com`, 'fetch', 'include')) + }) + + // Cookie should not be sent with app.foobar.com:3500/test as it does NOT fit the domain + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=baz=quux; Domain=app.foobar.com`, 'fetch', 'include')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + }) + + it('attaches TLD cookies ONLY when making top level requests', () => { + cy.intercept(`${scheme}://app.foobar.com:${sameOriginPort}/test-request-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('bar=baz') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + }, + }, ({ scheme, sameOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo=bar; Domain=www.foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=bar=baz; Domain=.foobar.com`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${sameOriginPort}/test-request-credentials`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + + describe('paths', () => { + it('gives specific path precedent over generic path, regardless of matching domain', () => { + cy.intercept(`/test-request`, (req) => { + // bar=baz should come BEFORE foo=bar + expect(req['headers']['cookie']).to.equal('bar=baz; foo=bar') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, () => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo=bar; Domain=www.foobar.com; Path=/', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=bar=baz; Domain=.foobar.com; Path=/test-request`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/test-request`, 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + + describe('creation time', () => { + it('places cookies created earlier BEFORE newly created cookies', () => { + cy.intercept(`${scheme}://www.foobar.com:${sameOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('foo=bar; bar=baz') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + sameOriginPort, + }, + }, ({ scheme, sameOriginPort }) => { + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=foo=bar; Domain=www.foobar.com`, 'fetch')) + }) + + cy.wait(200) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=bar=baz; Domain=.foobar.com`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.foobar.com:${sameOriginPort}/test-request`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + }) + }) + + // without cy.origin means the AUT has the same origin as top + // TODO: In the future, this test should be run with the experimentalSessionAndOrigin=true and experimentalSessionAndOrigin=false + describe('w/o cy.origin', () => { + describe('same site / same origin', () => { + describe('XMLHttpRequest', () => { + // withCredentials option should have no effect on same-site requests + it('sets and attaches same-site cookies to request', () => { + cy.intercept('/test-request', (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'xmlHttpRequest')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/test-request', 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + }) + + describe('fetch', () => { + it('sets and attaches same-site cookies to request by default (same-origin)', () => { + cy.intercept('/test-request', (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/test-request', 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + + // this test should behave exactly the same as the (same-origin) test, but adding here incase our implementation is not consistent + it('sets and attaches same-site cookies to request if "include" credentials option is specified', () => { + cy.intercept('/test-request', (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/test-request', 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does NOT attach same-site cookies to request if "omit" credentials option is specified', () => { + cy.intercept('/test-request', (req) => { + // current expected assertion with server side cookie jar is set from previous test + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion, regardless of server side cookie jar + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + // set the cookie in the browser + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + // but omit the cookies in the request + return cy.wrap(makeRequest(win, '/test-request', 'fetch', 'omit')) + }) + + cy.wait('@cookieCheck') + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does NOT set same-site cookies from request if "omit" credentials option is specified', () => { + cy.intercept('/test-request', (req) => { + // current expected assertion with server side cookie jar is set from previous test + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion, regardless of server side cookie jar + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + // do NOT set the cookie in the browser + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo1=bar1; Domain=foobar.com', 'fetch', 'omit')) + }) + + cy.window().then((win) => { + // but send the cookies in the request + return cy.wrap(makeRequest(win, '/test-request', 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + + describe('same site / cross origin', () => { + describe('XMLHttpRequest', () => { + // withCredentials option should have no effect on same-site requests, even though the request is cross-origin + it('sets and attaches same-site cookies to request, even though request is cross-origin', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie?cookie=foo1=bar1; Domain=foobar.com`, 'xmlHttpRequest')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + }) + + describe('fetch', () => { + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does not set same-site cookies from request nor send same-site cookies by default (same-origin)', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + + it('sets same-site cookies from request and sends same-site cookies if "include" credentials option is specified', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch', 'include')) + }) + + cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('sets same-site cookies if "include" credentials option is specified from request, but does not attach same-site cookies to request by default (same-origin)', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch', 'include')) + }) + + cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + // this should have the same effect as same-origin option for same-site/cross-origin requests, but adding here incase our implementation is not consistent + it('does not set or send same-site cookies if "omit" credentials option is specified', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'fetch', 'omit')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'fetch', 'omit')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + + describe('cross site / cross origin', () => { + describe('XMLHttpRequest', () => { + it('does NOT set or send cookies with request by default', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie?cookie=bar1=baz1; Domain=barbaz.com`, 'xmlHttpRequest')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + if (scheme === 'https') { + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it('does set cookie if withCredentials is true, but does not send cookie if withCredentials is false', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + // current expected assertion + expect(req['headers']['cookie']).to.equal('bar1=baz1') + + // future expected assertion + // expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'xmlHttpRequest', true)) + }) + + // assert cookie value is actually set in the browser + if (scheme === 'https') { + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + } else { + cy.getCookie('bar1').its('value').should('equal', null) + } + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + + it('does set cookie if withCredentials is true, and sends cookie if withCredentials is true', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('bar1=baz1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'xmlHttpRequest', true)) + }) + + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, 'xmlHttpRequest', true)) + }) + + cy.wait('@cookieCheck') + }) + } + }) + + describe('fetch', () => { + ['same-origin', 'omit'].forEach((credentialOption) => { + it(`does NOT set or send cookies with request by credentials is ${credentialOption}`, () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie?cookie=bar1=baz1; Domain=barbaz.com`, 'fetch', credentialOption as 'same-origin' | 'omit')) + }) + + cy.getCookie('bar1').its('value').should('equal', null) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'fetch', credentialOption as 'same-origin' | 'omit')) + }) + + cy.wait('@cookieCheck') + }) + + // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 + it(`does set cookie if credentials is "include", but does not send cookie if credentials is ${credentialOption}`, () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { + // current expected assertion + if (scheme === 'https') { + expect(req['headers']['cookie']).to.equal('bar1=baz1') + } else { + expect(req['headers']['cookie']).to.equal('') + } + + // future expected assertion for both http / https + // expect(req['headers']['cookie']).to.equal('') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'fetch', 'include')) + }) + + // assert cookie value is actually set in the browser + if (scheme === 'https') { + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + } else { + cy.getCookie('bar1').its('value').should('equal', null) + } + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'fetch', credentialOption as 'same-origin' | 'omit')) + }) + + cy.wait('@cookieCheck') + }) + }) + + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + if (scheme === 'https') { + it('does set cookie if credentials is "include", and sends cookie if credentials is "include"', () => { + cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('bar1=baz1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'fetch', 'include')) + }) + + // assert cookie value is actually set in the browser + + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + cy.getCookie('bar1').its('value').should('equal', null) + // can only set third-party SameSite=None with Secure attribute, which is only possibly over https + + //expected future assertion + // cy.getCookie('bar1').its('value').should('equal', 'baz1') + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-credentials`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + } + }) + }) + + describe('misc', () => { + describe('domains', () => { + it('attaches subdomain and TLD cookies when making subdomain requests', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal('foo=bar; bar=baz') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://app.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo=bar; Domain=app.foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=bar=baz; Domain=.foobar.com`, 'fetch', 'include')) + }) + + // Cookie should not be sent with app.foobar.com:3500/test as it does NOT fit the domain + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=baz=quux; Domain=www.foobar.com`, 'fetch', 'include')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + + it('attaches TLD cookies ONLY when making top level requests', () => { + cy.intercept(`${scheme}://www.foobar.com:${sameOriginPort}/test-request-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('bar=baz') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://app.foobar.com:${crossOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo=bar; Domain=app.foobar.com', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=bar=baz; Domain=.foobar.com`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.foobar.com:${sameOriginPort}/test-request-credentials`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + }) + + describe('paths', () => { + it('gives specific path precedent over generic path, regardless of matching domain', () => { + cy.intercept(`/test-request`, (req) => { + // bar=baz should come BEFORE foo=bar + expect(req['headers']['cookie']).to.equal('bar=baz; foo=bar') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://app.foobar.com:${crossOriginPort}`) + cy.window().then((win) => { + return cy.wrap(makeRequest(win, '/set-cookie?cookie=foo=bar; Domain=app.foobar.com; Path=/', 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=bar=baz; Domain=.foobar.com; Path=/test-request`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/test-request`, 'fetch')) + }) + + cy.wait('@cookieCheck') + }) + }) + + describe('creation time', () => { + it('places cookies created earlier BEFORE newly created cookies', () => { + cy.intercept(`${scheme}://www.foobar.com:${sameOriginPort}/test-request`, (req) => { + 7 + expect(req['headers']['cookie']).to.equal('foo=bar; bar=baz') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://app.foobar.com:${crossOriginPort}`) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=foo=bar; Domain=.foobar.com`, 'fetch')) + }) + + cy.wait(200) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `/set-cookie?cookie=bar=baz; Domain=.foobar.com`, 'fetch')) + }) + + cy.window().then((win) => { + return cy.wrap(makeRequest(win, `${scheme}://www.foobar.com:${sameOriginPort}/test-request`, 'fetch', 'include')) + }) + + cy.wait('@cookieCheck') + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts b/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts index f5c251013c42..3a4cd80ea0a7 100644 --- a/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts @@ -191,7 +191,8 @@ describe('cy.origin - cookie login', () => { verifyIdpNotLoggedIn({ expectNullCookie: false }) }) - it('SameSite=None -> not logged in', () => { + // FIXME: Currently in Firefox, the default cookie setting in the extension is no_restriction, which can be set with Secure=false. + it('SameSite=None -> not logged in', { browser: '!firefox' }, () => { cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { cy.get('[data-cy="username"]').type(username) cy.get('[data-cy="cookieProps"]').type('SameSite=None') diff --git a/packages/driver/cypress/e2e/e2e/uncaught_errors.cy.js b/packages/driver/cypress/e2e/e2e/uncaught_errors.cy.js index 95bc5eb8fdd4..e588d0930597 100644 --- a/packages/driver/cypress/e2e/e2e/uncaught_errors.cy.js +++ b/packages/driver/cypress/e2e/e2e/uncaught_errors.cy.js @@ -97,8 +97,7 @@ describe('uncaught errors', () => { cy.get('.error-two').invoke('text').should('equal', 'async error') }) - // TODO(webkit): fix+unskip. the browser emits the correct event, but not at the time expected - it('unhandled rejection triggers uncaught:exception and has promise as third argument', { browser: '!webkit' }, (done) => { + it('unhandled rejection triggers uncaught:exception and has promise as third argument', (done) => { cy.once('uncaught:exception', (err, runnable, promise) => { expect(err.stack).to.include('promise rejection') expect(err.stack).to.include('one') @@ -117,7 +116,7 @@ describe('uncaught errors', () => { // if we mutate the error, the app's listeners for 'error' or // 'unhandledrejection' will have our wrapped error instead of the original - it('original error is not mutated for "error"', { browser: '!webkit' }, () => { + it('original error is not mutated for "error"', () => { cy.once('uncaught:exception', () => false) cy.visit('/fixtures/errors.html') @@ -126,8 +125,7 @@ describe('uncaught errors', () => { cy.get('.error-two').invoke('text').should('equal', 'sync error') }) - // TODO(webkit): fix+unskip. the browser emits the correct event, but not at the time expected - it('original error is not mutated for "unhandledrejection"', { browser: '!webkit' }, () => { + it('original error is not mutated for "unhandledrejection"', () => { cy.once('uncaught:exception', () => false) cy.visit('/fixtures/errors.html') diff --git a/packages/driver/cypress/e2e/util/config.cy.js b/packages/driver/cypress/e2e/util/config.cy.js index c74840a9dd5c..47d4af4e1e9d 100644 --- a/packages/driver/cypress/e2e/util/config.cy.js +++ b/packages/driver/cypress/e2e/util/config.cy.js @@ -24,6 +24,18 @@ describe('driver/src/cypress/validate_config', () => { expect(overrideLevel).to.be.undefined }) + it('returns override level of restoring', () => { + const state = $SetterGetter.create({ + duringUserTestExecution: false, + test: { + _testConfig: { applied: 'restoring' }, + }, + }) + const overrideLevel = getMochaOverrideLevel(state) + + expect(overrideLevel).to.eq('restoring') + }) + it('returns override level of suite', () => { const state = $SetterGetter.create({ duringUserTestExecution: false, @@ -170,6 +182,20 @@ describe('driver/src/cypress/validate_config', () => { }).not.to.throw() }) + it('skips checking override level when restoring global configuration before next test', () => { + const state = $SetterGetter.create({ + duringUserTestExecution: false, + test: { + _testConfig: { applied: 'restoring' }, + }, + specWindow: { Error }, + }) + + expect(() => { + validateConfig(state, { testIsolation: 'legacy' }) + }).not.to.throw() + }) + it('throws when invalid configuration value', () => { const state = $SetterGetter.create({ duringUserTestExecution: true, diff --git a/packages/driver/cypress/e2e/webkit.cy.ts b/packages/driver/cypress/e2e/webkit.cy.ts new file mode 100644 index 000000000000..36da5ab9c4d5 --- /dev/null +++ b/packages/driver/cypress/e2e/webkit.cy.ts @@ -0,0 +1,31 @@ +describe('WebKit-specific behavior', { browser: 'webkit' }, () => { + it('cy.origin() is disabled', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.equal('`cy.origin()` is not currently supported in experimental WebKit.') + expect(err.docsUrl).to.equal('https://on.cypress.io/webkit-experiment') + done() + }) + + cy.origin('foo', () => {}) + }) + + it('cy.session() is disabled', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.equal('`cy.session()` is not currently supported in experimental WebKit.') + expect(err.docsUrl).to.equal('https://on.cypress.io/webkit-experiment') + done() + }) + + cy.session('foo', () => {}) + }) + + it('cy.session() is disabled', (done) => { + cy.on('fail', (err) => { + expect(err.message).to.include('`forceNetworkError` was passed, but it is not currently supported in experimental WebKit.') + expect(err.docsUrl).to.equal('https://on.cypress.io/intercept') + done() + }) + + cy.intercept('http://foo.com', { forceNetworkError: true }) + }) +}) diff --git a/packages/driver/cypress/fixtures/jquery.html b/packages/driver/cypress/fixtures/jquery.html index 47244043e64d..4c843bffbc3e 100644 --- a/packages/driver/cypress/fixtures/jquery.html +++ b/packages/driver/cypress/fixtures/jquery.html @@ -22,6 +22,7 @@ span with attr=number span with prop=number + span with trailing space

jQuery

diff --git a/packages/driver/cypress/fixtures/primary-origin.html b/packages/driver/cypress/fixtures/primary-origin.html index 916d112b18ba..1199d487f4fc 100644 --- a/packages/driver/cypress/fixtures/primary-origin.html +++ b/packages/driver/cypress/fixtures/primary-origin.html @@ -21,6 +21,8 @@
  • Login with Social (aliased localhost)
  • Login with Social (cookie override)
  • Login with Social (lands on idp)
  • +
  • Visit foobar.com over http
  • +
  • Visit foobar.com over https