diff --git a/.vscode/launch.json b/.vscode/launch.json index b55f65350daa..4e76bbcdfd22 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,8 @@ "type": "node", "request": "attach", "name": "Attach by Process ID", - "processId": "${command:PickProcess}" + "processId": "${command:PickProcess}", + "continueOnAttach": true }, { "type": "node", diff --git a/circle.yml b/circle.yml index 263ffb9f551a..49a982791e36 100644 --- a/circle.yml +++ b/circle.yml @@ -29,6 +29,7 @@ mainBuildFilters: &mainBuildFilters only: - develop - 10.0-release + - feature-multidomain - unify-1449-beta-slug-length # usually we don't build Mac app - it takes a long time @@ -38,6 +39,7 @@ macWorkflowFilters: &mac-workflow-filters when: or: - equal: [ develop, << pipeline.git.branch >> ] + - equal: [ feature-multidomain, << pipeline.git.branch >> ] - equal: [ unify-1449-beta-slug-length, << pipeline.git.branch >> ] - matches: pattern: "-release$" @@ -48,6 +50,7 @@ windowsWorkflowFilters: &windows-workflow-filters or: - equal: [ master, << pipeline.git.branch >> ] - equal: [ develop, << pipeline.git.branch >> ] + - equal: [ feature-multidomain, << pipeline.git.branch >> ] - equal: [ unify-1449-beta-slug-length, << pipeline.git.branch >> ] - matches: pattern: "-release$" @@ -403,6 +406,10 @@ commands: description: chrome channel to install type: string default: '' + experimentalSessionAndOrigin: + description: experimental flag to apply + type: boolean + default: false steps: - restore_cached_workspace - when: @@ -420,8 +427,13 @@ commands: if [[ -v MAIN_RECORD_KEY ]]; then # internal PR - CYPRESS_RECORD_KEY=$MAIN_RECORD_KEY \ - yarn cypress:run --record --parallel --group 5x-driver-<> --browser <> + if <>; then + CYPRESS_RECORD_KEY=$MAIN_RECORD_KEY \ + yarn cypress:run-experimentalSessionAndOrigin --record --parallel --group 5x-driver-<>-experimentalSessionAndOrigin --browser <> + else + CYPRESS_RECORD_KEY=$MAIN_RECORD_KEY \ + yarn cypress:run --record --parallel --group 5x-driver-<> --browser <> + fi else # external PR TESTFILES=$(circleci tests glob "cypress/integration/**/*spec.*" | circleci tests split --total=$CIRCLE_NODE_TOTAL) @@ -430,7 +442,11 @@ commands: if [[ -z "$TESTFILES" ]]; then echo "Empty list of test files" fi - yarn cypress:run --browser <> --spec $TESTFILES + if <>; then + yarn cypress:run-experimentalSessionAndOrigin --browser <> --spec $TESTFILES + else + yarn cypress:run --browser <> --spec $TESTFILES + fi fi working_directory: packages/driver - verify-mocha-results @@ -1291,6 +1307,44 @@ jobs: - run-driver-integration-tests: browser: electron + driver-integration-tests-chrome-experimentalSessionAndOrigin: + <<: *defaults + resource_class: medium + parallelism: 5 + steps: + - run-driver-integration-tests: + browser: chrome + install-chrome-channel: stable + experimentalSessionAndOrigin: true + + driver-integration-tests-chrome-beta-experimentalSessionAndOrigin: + <<: *defaults + resource_class: medium + parallelism: 5 + steps: + - run-driver-integration-tests: + browser: chrome:beta + install-chrome-channel: beta + experimentalSessionAndOrigin: true + + driver-integration-tests-firefox-experimentalSessionAndOrigin: + <<: *defaults + resource_class: medium + parallelism: 5 + steps: + - run-driver-integration-tests: + browser: firefox + experimentalSessionAndOrigin: true + + driver-integration-tests-electron-experimentalSessionAndOrigin: + <<: *defaults + resource_class: medium + parallelism: 5 + steps: + - run-driver-integration-tests: + browser: electron + experimentalSessionAndOrigin: true + desktop-gui-integration-tests-7x: <<: *defaults parallelism: 7 @@ -1604,7 +1658,7 @@ jobs: - run: name: Check current branch to persist artifacts command: | - if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "unify-1449-beta-slug-length" ]]; then + if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "unify-1449-beta-slug-length" && "$CIRCLE_BRANCH" != "feature-multidomain" ]]; then echo "Not uploading artifacts or posting install comment for this branch." circleci-agent step halt fi @@ -2081,6 +2135,22 @@ linux-workflow: &linux-workflow context: test-runner:cypress-record-key requires: - build + - driver-integration-tests-chrome-experimentalSessionAndOrigin: + context: test-runner:cypress-record-key + requires: + - build + - driver-integration-tests-chrome-beta-experimentalSessionAndOrigin: + context: test-runner:cypress-record-key + requires: + - build + - driver-integration-tests-firefox-experimentalSessionAndOrigin: + context: test-runner:cypress-record-key + requires: + - build + - driver-integration-tests-electron-experimentalSessionAndOrigin: + context: test-runner:cypress-record-key + requires: + - build - runner-integration-tests-chrome: context: [test-runner:cypress-record-key, test-runner:percy] requires: @@ -2177,6 +2247,10 @@ linux-workflow: &linux-workflow - driver-integration-tests-chrome - driver-integration-tests-chrome-beta - driver-integration-tests-electron + - driver-integration-tests-firefox-experimentalSessionAndOrigin + - driver-integration-tests-chrome-experimentalSessionAndOrigin + - driver-integration-tests-chrome-beta-experimentalSessionAndOrigin + - driver-integration-tests-electron-experimentalSessionAndOrigin - system-tests-non-root - system-tests-firefox - system-tests-electron diff --git a/cli/schema/cypress.schema.json b/cli/schema/cypress.schema.json index 752dba905af3..5b7251565274 100644 --- a/cli/schema/cypress.schema.json +++ b/cli/schema/cypress.schema.json @@ -253,15 +253,15 @@ "default": false, "description": "Allows listening to the `before:run`, `after:run`, `before:spec`, and `after:spec` events in the plugins file during interactive mode." }, - "experimentalSourceRewriting": { + "experimentalSessionAndOrigin": { "type": "boolean", "default": false, - "description": "Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm." + "description": "Enables cross-origin and improved session support, including the `cy.origin` and `cy.session` commands. See https://on.cypress.io/origin and https://on.cypress.io/session." }, - "experimentalSessionSupport": { + "experimentalSourceRewriting": { "type": "boolean", "default": false, - "description": "Enable experimental session support. See https://on.cypress.io/session" + "description": "Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm." }, "experimentalFetchPolyfill": { "type": "boolean", diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index b76af718c3c5..30e09a61bf1c 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -56,19 +56,6 @@ declare namespace Cypress { password: string } - interface RemoteState { - auth?: { - username: string - password: string - } - domainName: string - strategy: 'file' | 'http' - origin: string - fileServer: string - props: Record - visiting: string - } - interface Backend { /** * Firefox only: Force Cypress to run garbage collection routines. @@ -639,7 +626,7 @@ declare namespace Cypress { * Trigger action * @private */ - action: (action: string, ...args: any[]) => void + action: (action: string, ...args: any[]) => any[] | void /** * Load files @@ -1064,7 +1051,7 @@ declare namespace Cypress { /** * Save/Restore browser Cookies, LocalStorage, and SessionStorage data resulting from the supplied `setup` function. * - * Only available if the `experimentalSessionSupport` config option is enabled. + * Only available if the `experimentalSessionAndOrigin` config option is enabled. * * @see https://on.cypress.io/session */ @@ -1426,6 +1413,29 @@ declare namespace Cypress { */ off: Actions + /** + * Enables running Cypress commands in a secondary origin. + * @see https://on.cypress.io/origin + * @example + * cy.origin('example.com', () => { + * cy.get('h1').should('equal', 'Example Domain') + * }) + */ + origin(urlOrDomain: string, fn: () => void): Chainable + + /** + * Enables running Cypress commands in a secondary origin. + * @see https://on.cypress.io/origin + * @example + * cy.origin('example.com', { args: { key: 'value', foo: 'foo' } }, ({ key, foo }) => { + * expect(key).to.equal('value') + * expect(foo).to.equal('foo') + * }) + */ + origin(urlOrDomain: string, options: { + args: T + }, fn: (args: T) => void): Chainable + /** * Get the parent DOM element of a set of DOM elements. * @@ -2822,15 +2832,15 @@ declare namespace Cypress { */ scrollBehavior: scrollBehaviorOptions /** - * Enable experimental session support. See https://on.cypress.io/session + * Allows listening to the `before:run`, `after:run`, `before:spec`, and `after:spec` events in the plugins file during interactive mode. * @default false */ - experimentalSessionSupport: boolean + experimentalInteractiveRunEvents: boolean /** - * Allows listening to the `before:run`, `after:run`, `before:spec`, and `after:spec` events in the plugins file during interactive mode. + * Enables cross-origin and improved session support, including the `cy.origin` and `cy.session` commands. See https://on.cypress.io/origin and https://on.cypress.io/session. * @default false */ - experimentalInteractiveRunEvents: boolean + experimentalSessionAndOrigin: boolean /** * Generate and save commands directly to your test suite by interacting with your app as an end user would. * @default false @@ -2962,7 +2972,6 @@ declare namespace Cypress { projectName: string projectRoot: string proxyUrl: string - remote: RemoteState report: boolean reporterRoute: string reporterUrl: string @@ -5452,6 +5461,21 @@ declare namespace Cypress { (action: 'task', tasks: Tasks): void } + interface CodeFrame { + frame: string + language: string + line: number + column: number + absoluteFile: string + originalFile: string + relativeFile: string + } + + interface CypressError extends Error { + docsUrl?: string + codeFrame?: CodeFrame + } + // for just a few events like "window:alert" it makes sense to allow passing cy.stub() or // a user callback function. Others probably only need a callback function. @@ -5557,7 +5581,7 @@ declare namespace Cypress { * This event exists because it's extremely useful for debugging purposes. * @see https://on.cypress.io/catalog-of-events#App-Events */ - (action: 'fail', fn: (error: Error, mocha: Mocha.Runnable) => void): Cypress + (action: 'fail', fn: (error: CypressError, mocha: Mocha.Runnable) => void): Cypress /** * Fires whenever the viewport changes via a `cy.viewport()` or naturally when * Cypress resets the viewport to the default between tests. Useful for debugging purposes. @@ -5591,6 +5615,12 @@ declare namespace Cypress { * @see https://on.cypress.io/catalog-of-events#App-Events */ (action: 'command:end', fn: (command: CommandQueue) => void): Cypress + /** + * Fires when a command is skipped, namely the `should` command. + * Useful for debugging and understanding how commands are handled. + * @see https://on.cypress.io/catalog-of-events#App-Events + */ + (action: 'skipped:command:end', fn: (command: CommandQueue) => void): Cypress /** * Fires whenever a command begins its retrying routines. * This is called on the trailing edge after Cypress has internally @@ -5681,10 +5711,13 @@ declare namespace Cypress { } interface EnqueuedCommand { + id: string name: string args: any[] type: string chainerId: string + injected: boolean + userInvocationStack?: string fn(...args: any[]): any } @@ -5723,6 +5756,8 @@ declare namespace Cypress { } interface LogConfig extends Timeoutable { + /** Unique id for the log, in the form of '-' */ + id: string /** The JQuery element for the command. This will highlight the command in the main window when debugging */ $el: JQuery /** The scope of the log entry. If child, will appear nested below parents, prefixed with '-' */ @@ -5735,6 +5770,8 @@ declare namespace Cypress { message: any /** Set to false if you want to control the finishing of the command in the log yourself */ autoEnd: boolean + /** Set to true to immediately finish the log */ + end: boolean /** Return an object that will be printed in the dev tools console */ consoleProps(): ObjectLike } diff --git a/cli/types/tests/actions.ts b/cli/types/tests/actions.ts index e31b294e254b..014c43dad9da 100644 --- a/cli/types/tests/actions.ts +++ b/cli/types/tests/actions.ts @@ -33,7 +33,7 @@ Cypress.on('url:changed', (url) => { }) Cypress.on('fail', (error, mocha) => { - error // $ExpectType Error + error // $ExpectType CypressError mocha // $ExpectType Runnable }) diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index d1992f829f6e..9b5f9c0d382d 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -904,7 +904,6 @@ namespace CypressTaskTests { } namespace CypressSessionsTests { - Cypress.config('experimentalSessionSupport') // $ExpectType boolean cy.session('user') cy.session('user', () => {}) cy.session({ name: 'bob' }, () => {}) @@ -939,3 +938,21 @@ namespace CypressKeyboardTests { delay: 500 // $ExpectError }) } + +namespace CypressOriginTests { + cy.origin('example.com', () => {}) + cy.origin('example.com', { args: {}}, (value: object) => {}) + cy.origin('example.com', { args: { one: 1, key: 'value', bool: true } }, (value: { one: number, key: string, bool: boolean}) => {}) + cy.origin('example.com', { args: [1, 'value', true ] }, (value: Array<(number | string | boolean)>) => {}) + cy.origin('example.com', { args : 'value'}, (value: string) => {}) + cy.origin('example.com', { args: 1 }, (value: number) => {}) + cy.origin('example.com', { args: true }, (value: boolean) => {}) + + cy.origin() // $ExpectError + cy.origin('example.com') // $ExpectError + cy.origin(true) // $ExpectError + cy.origin('example.com', {}) // $ExpectError + cy.origin('example.com', {}, {}) // $ExpectError + cy.origin('example.com', { args: ['value'] }, (value: boolean[]) => {}) // $ExpectError + cy.origin('example.com', {}, (value: undefined) => {}) // $ExpectError +} diff --git a/packages/config/__snapshots__/index_spec.js b/packages/config/__snapshots__/index_spec.js index 2e39e551f5bd..6b51b01c7083 100644 --- a/packages/config/__snapshots__/index_spec.js +++ b/packages/config/__snapshots__/index_spec.js @@ -1,16 +1,17 @@ -exports['src/index .getBreakingKeys returns list of breaking config keys 1'] = [ +exports['config/lib/index .getBreakingKeys returns list of breaking config keys 1'] = [ "blacklistHosts", "experimentalComponentTesting", "experimentalGetCookiesSameSite", "experimentalNetworkStubbing", "experimentalRunEvents", + "experimentalSessionSupport", "experimentalShadowDomSupport", "firefoxGcInterval", "nodeVersion", "nodeVersion" ] -exports['src/index .getDefaultValues returns list of public config keys 1'] = { +exports['config/lib/index .getDefaultValues returns list of public config keys 1'] = { "animationDistanceThreshold": 5, "baseUrl": null, "blockHosts": null, @@ -25,7 +26,7 @@ exports['src/index .getDefaultValues returns list of public config keys 1'] = { "execTimeout": 60000, "experimentalFetchPolyfill": false, "experimentalInteractiveRunEvents": false, - "experimentalSessionSupport": false, + "experimentalSessionAndOrigin": false, "experimentalSourceRewriting": false, "experimentalStudio": false, "fileServerFolder": "", @@ -86,7 +87,7 @@ exports['src/index .getDefaultValues returns list of public config keys 1'] = { "xhrRoute": "/xhrs/" } -exports['src/index .getPublicConfigKeys returns list of public config keys 1'] = [ +exports['config/lib/index .getPublicConfigKeys returns list of public config keys 1'] = [ "animationDistanceThreshold", "baseUrl", "blockHosts", @@ -101,7 +102,7 @@ exports['src/index .getPublicConfigKeys returns list of public config keys 1'] = "execTimeout", "experimentalFetchPolyfill", "experimentalInteractiveRunEvents", - "experimentalSessionSupport", + "experimentalSessionAndOrigin", "experimentalSourceRewriting", "experimentalStudio", "fileServerFolder", diff --git a/packages/config/__snapshots__/validation_spec.js b/packages/config/__snapshots__/validation_spec.js index 0f0171ed3ca9..b7d4a84b7b09 100644 --- a/packages/config/__snapshots__/validation_spec.js +++ b/packages/config/__snapshots__/validation_spec.js @@ -13,7 +13,108 @@ exports['browsers list with a string'] = { "list": "browsers" } -exports['src/validation .isValidBrowser passes valid browsers and forms error messages for invalid ones isValidBrowser 1'] = { +exports['not one of the strings error message'] = { + "key": "test", + "value": "nope", + "type": "one of these values: \"foo\", \"bar\"" +} + +exports['number instead of string'] = { + "key": "test", + "value": 42, + "type": "one of these values: \"foo\", \"bar\"" +} + +exports['null instead of string'] = { + "key": "test", + "value": null, + "type": "one of these values: \"foo\", \"bar\"" +} + +exports['not one of the numbers error message'] = { + "key": "test", + "value": 4, + "type": "one of these values: 1, 2, 3" +} + +exports['string instead of a number'] = { + "key": "test", + "value": "foo", + "type": "one of these values: 1, 2, 3" +} + +exports['null instead of a number'] = { + "key": "test", + "value": null, + "type": "one of these values: 1, 2, 3" +} + +exports['not string or array'] = { + "key": "mockConfigKey", + "value": null, + "type": "a string or an array of strings" +} + +exports['array of non-strings'] = { + "key": "mockConfigKey", + "value": [ + 1, + 2, + 3 + ], + "type": "a string or an array of strings" +} + +exports['invalid retry value'] = { + "key": "mockConfigKey", + "value": "1", + "type": "a positive number or null or an object with keys \"openMode\" and \"runMode\" with values of numbers or nulls" +} + +exports['invalid retry object'] = { + "key": "mockConfigKey", + "value": { + "fakeMode": 1 + }, + "type": "a positive number or null or an object with keys \"openMode\" and \"runMode\" with values of numbers or nulls" +} + +exports['missing https protocol'] = { + "key": "clientCertificates[0].url", + "value": "http://url.com", + "type": "an https protocol" +} + +exports['invalid url'] = { + "key": "clientCertificates[0].url", + "value": "not *", + "type": "a valid URL" +} + +exports['not qualified url'] = { + "key": "mockConfigKey", + "value": "url.com", + "type": "a fully qualified URL (starting with `http://` or `https://`)" +} + +exports['empty string'] = { + "key": "mockConfigKey", + "value": "", + "type": "a fully qualified URL (starting with `http://` or `https://`)" +} + +exports['config/lib/validation .isValidClientCertificatesSet returns error message for certs not passed as an array array 1'] = { + "key": "mockConfigKey", + "value": "1", + "type": "a positive number or null or an object with keys \"openMode\" and \"runMode\" with values of numbers or nulls" +} + +exports['config/lib/validation .isValidClientCertificatesSet returns error message for certs object without url 1'] = { + "key": "clientCertificates[0].url", + "type": "a URL matcher" +} + +exports['config/lib/validation .isValidBrowser passes valid browsers and forms error messages for invalid ones isValidBrowser 1'] = { "name": "isValidBrowser", "behavior": [ { @@ -82,145 +183,44 @@ exports['src/validation .isValidBrowser passes valid browsers and forms error me ] } -exports['not one of the strings error message'] = { - "key": "test", - "value": "nope", - "type": "one of these values: \"foo\", \"bar\"" -} - -exports['number instead of string'] = { - "key": "test", - "value": 42, - "type": "one of these values: \"foo\", \"bar\"" -} - -exports['null instead of string'] = { - "key": "test", - "value": null, - "type": "one of these values: \"foo\", \"bar\"" -} - -exports['not one of the numbers error message'] = { - "key": "test", - "value": 4, - "type": "one of these values: 1, 2, 3" -} - -exports['string instead of a number'] = { - "key": "test", - "value": "foo", - "type": "one of these values: 1, 2, 3" +exports['config/lib/validation .isPlainObject returns error message when value is a not an object 1'] = { + "key": "mockConfigKey", + "value": 1, + "type": "a plain object" } -exports['null instead of a number'] = { - "key": "test", - "value": null, - "type": "one of these values: 1, 2, 3" +exports['config/lib/validation .isNumber returns error message when value is a not a number 1'] = { + "key": "mockConfigKey", + "value": "string", + "type": "a number" } -exports['src/validation .isStringOrFalse returns error message when value is neither string nor false 1'] = { +exports['config/lib/validation .isNumberOrFalse returns error message when value is a not number or false 1'] = { "key": "mockConfigKey", "value": null, - "type": "a string or false" + "type": "a number or false" } -exports['src/validation .isBoolean returns error message when value is a not a string 1'] = { +exports['config/lib/validation .isBoolean returns error message when value is a not a string 1'] = { "key": "mockConfigKey", "value": 1, "type": "a string" } -exports['src/validation .isString returns error message when value is a not a string 1'] = { +exports['config/lib/validation .isString returns error message when value is a not a string 1'] = { "key": "mockConfigKey", "value": 1, "type": "a string" } -exports['src/validation .isArray returns error message when value is a non-array 1'] = { +exports['config/lib/validation .isArray returns error message when value is a non-array 1'] = { "key": "mockConfigKey", "value": 1, "type": "an array" } -exports['not string or array'] = { +exports['config/lib/validation .isStringOrFalse returns error message when value is neither string nor false 1'] = { "key": "mockConfigKey", "value": null, - "type": "a string or an array of strings" -} - -exports['array of non-strings'] = { - "key": "mockConfigKey", - "value": [ - 1, - 2, - 3 - ], - "type": "a string or an array of strings" -} - -exports['src/validation .isNumberOrFalse returns error message when value is a not number or false 1'] = { - "key": "mockConfigKey", - "value": null, - "type": "a number or false" -} - -exports['src/validation .isPlainObject returns error message when value is a not an object 1'] = { - "key": "mockConfigKey", - "value": 1, - "type": "a plain object" -} - -exports['src/validation .isNumber returns error message when value is a not a number 1'] = { - "key": "mockConfigKey", - "value": "string", - "type": "a number" -} - -exports['invalid retry value'] = { - "key": "mockConfigKey", - "value": "1", - "type": "a positive number or null or an object with keys \"openMode\" and \"runMode\" with values of numbers or nulls" -} - -exports['invalid retry object'] = { - "key": "mockConfigKey", - "value": { - "fakeMode": 1 - }, - "type": "a positive number or null or an object with keys \"openMode\" and \"runMode\" with values of numbers or nulls" -} - -exports['src/validation .isValidClientCertificatesSet returns error message for certs not passed as an array array 1'] = { - "key": "mockConfigKey", - "value": "1", - "type": "a positive number or null or an object with keys \"openMode\" and \"runMode\" with values of numbers or nulls" -} - -exports['src/validation .isValidClientCertificatesSet returns error message for certs object without url 1'] = { - "key": "clientCertificates[0].url", - "type": "a URL matcher" -} - -exports['missing https protocol'] = { - "key": "clientCertificates[0].url", - "value": "http://url.com", - "type": "an https protocol" -} - -exports['invalid url'] = { - "key": "clientCertificates[0].url", - "value": "not *", - "type": "a valid URL" -} - -exports['not qualified url'] = { - "key": "mockConfigKey", - "value": "url.com", - "type": "a fully qualified URL (starting with `http://` or `https://`)" -} - -exports['empty string'] = { - "key": "mockConfigKey", - "value": "", - "type": "a fully qualified URL (starting with `http://` or `https://`)" + "type": "a string or false" } diff --git a/packages/config/lib/options.ts b/packages/config/lib/options.ts index 4aeecc6ab2af..1884460fe3f3 100644 --- a/packages/config/lib/options.ts +++ b/packages/config/lib/options.ts @@ -153,11 +153,11 @@ const resolvedOptions: Array = [ isExperimental: true, canUpdateDuringTestTime: false, }, { - name: 'experimentalSessionSupport', + name: 'experimentalSessionAndOrigin', defaultValue: false, validation: validate.isBoolean, isExperimental: true, - canUpdateDuringTestTime: true, + canUpdateDuringTestTime: false, }, { name: 'experimentalSourceRewriting', defaultValue: false, @@ -502,6 +502,10 @@ export const breakingOptions: Array = [ name: 'experimentalRunEvents', errorKey: 'EXPERIMENTAL_RUN_EVENTS_REMOVED', isWarning: true, + }, { + name: 'experimentalSessionSupport', + errorKey: 'EXPERIMENTAL_SESSION_SUPPORT_REMOVED', + isWarning: true, }, { name: 'experimentalShadowDomSupport', errorKey: 'EXPERIMENTAL_SHADOW_DOM_REMOVED', diff --git a/packages/config/test/unit/index_spec.js b/packages/config/test/unit/index_spec.js index b255109a777c..a2ebdaa68b7c 100644 --- a/packages/config/test/unit/index_spec.js +++ b/packages/config/test/unit/index_spec.js @@ -8,7 +8,7 @@ const configUtil = require('../../lib/index') chai.use(sinonChai) const { expect } = chai -describe('src/index', () => { +describe('config/lib/index', () => { describe('.allowed', () => { it('returns filter config only containing allowed keys', () => { const keys = configUtil.allowed({ diff --git a/packages/config/test/unit/validation_spec.js b/packages/config/test/unit/validation_spec.js index b3ecbbeb8b51..f0cd89496492 100644 --- a/packages/config/test/unit/validation_spec.js +++ b/packages/config/test/unit/validation_spec.js @@ -3,7 +3,7 @@ const { expect } = require('chai') const validation = require('../../lib/validation') -describe('src/validation', () => { +describe('config/lib/validation', () => { const mockKey = 'mockConfigKey' describe('.isValidClientCertificatesSet', () => { diff --git a/packages/driver/cypress.json b/packages/driver/cypress.json index 013a851c5590..f6d381383ca1 100644 --- a/packages/driver/cypress.json +++ b/packages/driver/cypress.json @@ -3,7 +3,8 @@ "baseUrl": "http://localhost:3500", "testFiles": "**/*", "hosts": { - "*.foobar.com": "127.0.0.1" + "*.foobar.com": "127.0.0.1", + "*.idp.com": "127.0.0.1" }, "reporter": "cypress-multi-reporters", "reporterOptions": { diff --git a/packages/driver/cypress/fixtures/auth/approval.html b/packages/driver/cypress/fixtures/auth/approval.html new file mode 100644 index 000000000000..744924c73521 --- /dev/null +++ b/packages/driver/cypress/fixtures/auth/approval.html @@ -0,0 +1,59 @@ + + + + + + + + + + + diff --git a/packages/driver/cypress/fixtures/auth/cookie-login.html b/packages/driver/cypress/fixtures/auth/cookie-login.html new file mode 100644 index 000000000000..4a439d400b1e --- /dev/null +++ b/packages/driver/cypress/fixtures/auth/cookie-login.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/packages/driver/cypress/fixtures/auth/delayedNavigate.html b/packages/driver/cypress/fixtures/auth/delayedNavigate.html new file mode 100644 index 000000000000..0367ba084248 --- /dev/null +++ b/packages/driver/cypress/fixtures/auth/delayedNavigate.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/driver/cypress/fixtures/auth/idp.html b/packages/driver/cypress/fixtures/auth/idp.html new file mode 100644 index 000000000000..8f52f4f28648 --- /dev/null +++ b/packages/driver/cypress/fixtures/auth/idp.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + diff --git a/packages/driver/cypress/fixtures/auth/index.html b/packages/driver/cypress/fixtures/auth/index.html new file mode 100644 index 000000000000..bc7f2327ab10 --- /dev/null +++ b/packages/driver/cypress/fixtures/auth/index.html @@ -0,0 +1,94 @@ + + + + + + + + diff --git a/packages/driver/cypress/fixtures/dom.html b/packages/driver/cypress/fixtures/dom.html index 762e7fa1a18b..6347924ceccf 100644 --- a/packages/driver/cypress/fixtures/dom.html +++ b/packages/driver/cypress/fixtures/dom.html @@ -437,8 +437,8 @@
- diff --git a/packages/driver/cypress/fixtures/multi-domain-secondary.html b/packages/driver/cypress/fixtures/multi-domain-secondary.html new file mode 100644 index 000000000000..2e5514a089c6 --- /dev/null +++ b/packages/driver/cypress/fixtures/multi-domain-secondary.html @@ -0,0 +1,41 @@ + + + + + +

From a secondary origin

+

+

+
+ +
+ + + + hashChange + /fixtures/multi-domain.html + + + + diff --git a/packages/driver/cypress/fixtures/multi-domain.html b/packages/driver/cypress/fixtures/multi-domain.html new file mode 100644 index 000000000000..d33be872623e --- /dev/null +++ b/packages/driver/cypress/fixtures/multi-domain.html @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/packages/driver/cypress/integration/commands/actions/check_spec.js b/packages/driver/cypress/integration/commands/actions/check_spec.js index 554e8656b361..323e6773cff0 100644 --- a/packages/driver/cypress/integration/commands/actions/check_spec.js +++ b/packages/driver/cypress/integration/commands/actions/check_spec.js @@ -2,18 +2,8 @@ const { assertLogLength } = require('../../../support/utils') const { _, Promise, $ } = Cypress describe('src/cy/commands/actions/check', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + cy.visit('/fixtures/dom.html') }) context('#check', () => { diff --git a/packages/driver/cypress/integration/commands/actions/focus_spec.js b/packages/driver/cypress/integration/commands/actions/focus_spec.js index 57b31a7deb93..1a86f1048c45 100644 --- a/packages/driver/cypress/integration/commands/actions/focus_spec.js +++ b/packages/driver/cypress/integration/commands/actions/focus_spec.js @@ -6,18 +6,8 @@ const getActiveElement = () => { } describe('src/cy/commands/actions/focus', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + beforeEach(() => { + cy.visit('/fixtures/dom.html') }) context('#focus', () => { diff --git a/packages/driver/cypress/integration/commands/actions/hover_spec.js b/packages/driver/cypress/integration/commands/actions/hover_spec.js index 811e6799ff3c..f3b10a193e73 100644 --- a/packages/driver/cypress/integration/commands/actions/hover_spec.js +++ b/packages/driver/cypress/integration/commands/actions/hover_spec.js @@ -1,18 +1,6 @@ -const { $ } = Cypress - describe('src/cy/commands/actions/hover', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + beforeEach(() => { + cy.visit('/fixtures/dom.html') }) context('#hover', () => { diff --git a/packages/driver/cypress/integration/commands/actions/scroll_spec.js b/packages/driver/cypress/integration/commands/actions/scroll_spec.js index 4726421baad5..cd6e456be732 100644 --- a/packages/driver/cypress/integration/commands/actions/scroll_spec.js +++ b/packages/driver/cypress/integration/commands/actions/scroll_spec.js @@ -1,19 +1,8 @@ -const { $ } = window.Cypress.$Cypress -const { _ } = window.Cypress +const { _, $ } = Cypress describe('src/cy/commands/actions/scroll', () => { - before(() => { - cy - .visit('/fixtures/scrolling.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + beforeEach(() => { + cy.visit('/fixtures/scrolling.html') cy.viewport(600, 200) }) diff --git a/packages/driver/cypress/integration/commands/actions/select_spec.js b/packages/driver/cypress/integration/commands/actions/select_spec.js index bc26bec2e98c..991db09398af 100644 --- a/packages/driver/cypress/integration/commands/actions/select_spec.js +++ b/packages/driver/cypress/integration/commands/actions/select_spec.js @@ -2,18 +2,8 @@ const { assertLogLength } = require('../../../support/utils') const { _, $ } = Cypress describe('src/cy/commands/actions/select', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + beforeEach(() => { + cy.visit('/fixtures/dom.html') }) context('#select', () => { @@ -563,7 +553,7 @@ describe('src/cy/commands/actions/select', () => { done() }) - cy.get('select[name=fielset-disabled]').select('foo') + cy.get('select[name=fieldset-disabled]').select('foo') }) it('throws when optgroup is disabled', (done) => { diff --git a/packages/driver/cypress/integration/commands/actions/submit_spec.js b/packages/driver/cypress/integration/commands/actions/submit_spec.js index 61f210a6a954..ce3ecda8f4d8 100644 --- a/packages/driver/cypress/integration/commands/actions/submit_spec.js +++ b/packages/driver/cypress/integration/commands/actions/submit_spec.js @@ -2,18 +2,8 @@ const { assertLogLength } = require('../../../support/utils') const { _, $, Promise } = Cypress describe('src/cy/commands/actions/submit', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + cy.visit('/fixtures/dom.html') }) context('#submit', () => { diff --git a/packages/driver/cypress/integration/commands/actions/trigger_spec.js b/packages/driver/cypress/integration/commands/actions/trigger_spec.js index 0bde9d654f4f..b1423d7545d9 100644 --- a/packages/driver/cypress/integration/commands/actions/trigger_spec.js +++ b/packages/driver/cypress/integration/commands/actions/trigger_spec.js @@ -2,18 +2,8 @@ const { assertLogLength } = require('../../../support/utils') const { _, $ } = Cypress describe('src/cy/commands/actions/trigger', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + cy.visit('/fixtures/dom.html') }) context('#trigger', () => { diff --git a/packages/driver/cypress/integration/commands/aliasing_spec.js b/packages/driver/cypress/integration/commands/aliasing_spec.js index e51684afcceb..8fa05133ae92 100644 --- a/packages/driver/cypress/integration/commands/aliasing_spec.js +++ b/packages/driver/cypress/integration/commands/aliasing_spec.js @@ -2,18 +2,8 @@ const { assertLogLength } = require('../../support/utils') const { _, $ } = Cypress describe('src/cy/commands/aliasing', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + beforeEach(() => { + cy.visit('/fixtures/dom.html') }) context('#as', () => { diff --git a/packages/driver/cypress/integration/commands/angular_spec.js b/packages/driver/cypress/integration/commands/angular_spec.js index 9e620c1d8856..089c35903016 100644 --- a/packages/driver/cypress/integration/commands/angular_spec.js +++ b/packages/driver/cypress/integration/commands/angular_spec.js @@ -2,7 +2,7 @@ const { assertLogLength } = require('../../support/utils') const { _, $ } = Cypress describe('src/cy/commands/angular', () => { - before(() => { + beforeEach(() => { cy.visit('/fixtures/angular.html') }) diff --git a/packages/driver/cypress/integration/commands/assertions_spec.js b/packages/driver/cypress/integration/commands/assertions_spec.js index 86429d1dad34..a17f3ce8c7a9 100644 --- a/packages/driver/cypress/integration/commands/assertions_spec.js +++ b/packages/driver/cypress/integration/commands/assertions_spec.js @@ -35,20 +35,10 @@ const captureCommands = () => { } describe('src/cy/commands/assertions', () => { - before(() => { - cy - .visit('/fixtures/jquery.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - let testCommands beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + cy.visit('/fixtures/jquery.html') testCommands = captureCommands() }) @@ -70,6 +60,7 @@ describe('src/cy/commands/assertions', () => { .noop({ foo: 'bar' }).should('deep.eq', { foo: 'bar' }) .then((obj) => { expect(testCommands()).to.eql([ + { name: 'visit', snapshots: 1, retries: 0 }, { name: 'noop', snapshots: 0, retries: 0 }, { name: 'should', snapshots: 1, retries: 0 }, { name: 'then', snapshots: 0, retries: 0 }, @@ -204,6 +195,7 @@ describe('src/cy/commands/assertions', () => { }) .then(() => { expect(testCommands()).to.eql([ + { name: 'visit', snapshots: 1, retries: 0 }, // cy.get() has 2 snapshots, 1 for itself, and 1 // for the .should(...) assertion. @@ -2997,11 +2989,10 @@ describe('src/cy/commands/assertions', () => { // should be taken. it('only snapshots once when failing to find DOM elements and not retrying', (done) => { cy.on('fail', (err) => { - expect(testCommands()).to.eql([{ - name: 'get', - snapshots: 1, - retries: 0, - }]) + expect(testCommands()).to.eql([ + { name: 'visit', snapshots: 1, retries: 0 }, + { name: 'get', snapshots: 1, retries: 0 }, + ]) done() }) diff --git a/packages/driver/cypress/integration/commands/clock_spec.js b/packages/driver/cypress/integration/commands/clock_spec.js index 01ade377b6b6..4db1fcbf0c82 100644 --- a/packages/driver/cypress/integration/commands/clock_spec.js +++ b/packages/driver/cypress/integration/commands/clock_spec.js @@ -115,6 +115,8 @@ describe('src/cy/commands/clock', () => { // this test was written to catch a bug in lolex (dep, now @sinonjs/fake-timers) 3 and was fixed by lolex 4 upgrade, it('doesn\'t override window.performance members', () => { + cy.visit('/fixtures/empty.html') + cy.clock() .then((clock) => { cy.window().then((win) => { diff --git a/packages/driver/cypress/integration/commands/commands_spec.js b/packages/driver/cypress/integration/commands/commands_spec.js index fa8a278cbf93..10d1112d36a4 100644 --- a/packages/driver/cypress/integration/commands/commands_spec.js +++ b/packages/driver/cypress/integration/commands/commands_spec.js @@ -1,18 +1,8 @@ -const { _, $ } = Cypress +const { _ } = Cypress describe('src/cy/commands/commands', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + beforeEach(() => { + cy.visit('/fixtures/dom.html') }) it('can invoke commands by name', () => { @@ -43,7 +33,7 @@ describe('src/cy/commands/commands', () => { cy.command('get', 'body').then(() => { const names = cy.queue.names() - expect(names).to.deep.eq(['get', 'then']) + expect(names).to.deep.eq(['visit', 'get', 'then']) }) }) diff --git a/packages/driver/cypress/integration/commands/connectors_spec.js b/packages/driver/cypress/integration/commands/connectors_spec.js index 5968da419f53..fd4d23e3c5e0 100644 --- a/packages/driver/cypress/integration/commands/connectors_spec.js +++ b/packages/driver/cypress/integration/commands/connectors_spec.js @@ -3,18 +3,8 @@ const { _, Promise, $ } = Cypress describe('src/cy/commands/connectors', () => { describe('with jquery', () => { - before(() => { - cy - .visit('/fixtures/jquery.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + cy.visit('/fixtures/jquery.html') }) context('#spread', () => { @@ -90,7 +80,8 @@ describe('src/cy/commands/connectors', () => { it('does not insert a mocha callback', () => { cy.noop().then(() => { - expect(cy.queue.length).to.eq(2) + // queue: visit -> noop -> then + expect(cy.queue.length).to.eq(3) }) }) @@ -1803,18 +1794,8 @@ describe('src/cy/commands/connectors', () => { }) describe('without jquery', () => { - before(() => { - cy - .visit('/fixtures/dom.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + beforeEach(() => { + cy.visit('/fixtures/dom.html') }) context('#each', () => { diff --git a/packages/driver/cypress/integration/commands/files_spec.js b/packages/driver/cypress/integration/commands/files_spec.js index f70c67e700a6..d2a9f27a0819 100644 --- a/packages/driver/cypress/integration/commands/files_spec.js +++ b/packages/driver/cypress/integration/commands/files_spec.js @@ -161,6 +161,8 @@ describe('src/cy/commands/files', () => { defaultCommandTimeout: 50, }, () => { beforeEach(function () { + cy.visit('/fixtures/empty.html') + this.logs = [] cy.on('log:added', (attrs, log) => { diff --git a/packages/driver/cypress/integration/commands/misc_spec.js b/packages/driver/cypress/integration/commands/misc_spec.js index eaf41954a429..34f81050f1cc 100644 --- a/packages/driver/cypress/integration/commands/misc_spec.js +++ b/packages/driver/cypress/integration/commands/misc_spec.js @@ -2,18 +2,8 @@ const { assertLogLength } = require('../../support/utils') const { _, $, dom } = Cypress describe('src/cy/commands/misc', () => { - before(() => { - cy - .visit('/fixtures/jquery.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) + cy.visit('/fixtures/jquery.html') }) context('#end', () => { diff --git a/packages/driver/cypress/integration/commands/navigation_spec.js b/packages/driver/cypress/integration/commands/navigation_spec.js index 30d9b9179651..1b9a09669743 100644 --- a/packages/driver/cypress/integration/commands/navigation_spec.js +++ b/packages/driver/cypress/integration/commands/navigation_spec.js @@ -6,24 +6,8 @@ const { _, Promise, $ } = Cypress describe('src/cy/commands/navigation', () => { context('#reload', () => { - before(() => { - cy - .visit('/fixtures/generic.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - beforeEach(function () { - const doc = cy.state('document') - - this.win = cy.state('window') - - $(doc.body).empty().html(this.body) - }) - - afterEach(function () { - cy.state('window', this.win) + cy.visit('/fixtures/generic.html') }) it('calls into window.location.reload', () => { @@ -288,24 +272,11 @@ describe('src/cy/commands/navigation', () => { }) context('#go', () => { - before(() => { - cy - .visit('/fixtures/generic.html') - .then(function (win) { - this.body = win.document.body.outerHTML - }) - }) - - beforeEach(function () { - const doc = cy.state('document') - - $(doc.body).empty().html(this.body) - }) - // TODO: fix this it('sets timeout to Cypress.config(pageLoadTimeout)', { pageLoadTimeout: 4567, }, () => { + cy.visit('/fixtures/generic.html') const timeout = cy.spy(Promise.prototype, 'timeout') cy @@ -508,7 +479,7 @@ describe('src/cy/commands/navigation', () => { }) it('only logs once on error', function (done) { - cy.on('fail', (err) => { + cy.once('fail', (err) => { assertLogLength(this.logs, 1) expect(this.logs[0].get('error')).to.eq(err) @@ -825,7 +796,7 @@ describe('src/cy/commands/navigation', () => { onLoad, }) .then((win) => { - expect(win.bar).to.not.exist + expect(win.foo).to.equal('bar') expect(onLoad).not.to.have.been.called }) }) @@ -971,7 +942,7 @@ describe('src/cy/commands/navigation', () => { }) describe('location getter overrides', () => { - before(() => { + beforeEach(function () { cy .visit('/fixtures/jquery.html?foo=bar#dashboard?baz=quux') .window().as('win').then((win) => { @@ -980,9 +951,7 @@ describe('src/cy/commands/navigation', () => { // overriding the location getters expect(win.location.href).to.include('/fixtures/jquery.html?foo=bar#dashboard?baz=quux') }) - }) - beforeEach(function () { this.win = cy.state('window') this.eq = (attr, str) => { @@ -1440,10 +1409,25 @@ describe('src/cy/commands/navigation', () => { it('throws when attempting to visit a 2nd domain on different port', function (done) { cy.on('fail', (err) => { const { lastLog } = this + const experimentalMessage = Cypress.config('experimentalSessionAndOrigin') ? `You likely forgot to use \`cy.origin()\`:\n` : `In order to visit a different origin, you can enable the \`experimentalSessionAndOrigin\` flag and use \`cy.origin()\`:\n` + + expect(err.message).to.equal(stripIndent`\ + \`cy.visit()\` failed because you are attempting to visit a URL that is of a different origin.\n + ${experimentalMessage} + \`cy.visit('http://localhost:3500/fixtures/generic.html')\` + \`\`\n + \`cy.origin('http://localhost:3501', () => {\` + \` cy.visit('http://localhost:3501/fixtures/generic.html')\` + \` \` + \`})\`\n + The new URL is considered a different origin because the following parts of the URL are different:\n + > port\n + You may only \`cy.visit()\` same-origin URLs within a single test.\n + The previous URL you visited was:\n + > 'http://localhost:3500'\n + You're attempting to visit this URL:\n + > 'http://localhost:3501'`) - expect(err.message).to.include('`cy.visit()` failed because you are attempting to visit a URL that is of a different origin.') - expect(err.message).to.include('The new URL is considered a different origin because the following parts of the URL are different:') - expect(err.message).to.include('> port') expect(err.docsUrl).to.eq('https://on.cypress.io/cannot-visit-different-origin-domain') assertLogLength(this.logs, 2) expect(lastLog.get('error')).to.eq(err) @@ -1452,17 +1436,31 @@ describe('src/cy/commands/navigation', () => { }) cy.visit('http://localhost:3500/fixtures/generic.html') - cy.visit('http://localhost:3501/fixtures/generic.html') }) it('throws when attempting to visit a 2nd domain on different protocol', function (done) { cy.on('fail', (err) => { const { lastLog } = this + const experimentalMessage = Cypress.config('experimentalSessionAndOrigin') ? `You likely forgot to use \`cy.origin()\`:\n` : `In order to visit a different origin, you can enable the \`experimentalSessionAndOrigin\` flag and use \`cy.origin()\`:\n` + + expect(err.message).to.equal(stripIndent`\ + \`cy.visit()\` failed because you are attempting to visit a URL that is of a different origin.\n + ${experimentalMessage} + \`cy.visit('http://localhost:3500/fixtures/generic.html')\` + \`\`\n + \`cy.origin('https://localhost:3502', () => {\` + \` cy.visit('https://localhost:3502/fixtures/generic.html')\` + \` \` + \`})\`\n + The new URL is considered a different origin because the following parts of the URL are different:\n + > protocol, port\n + You may only \`cy.visit()\` same-origin URLs within a single test.\n + The previous URL you visited was:\n + > 'http://localhost:3500'\n + You're attempting to visit this URL:\n + > 'https://localhost:3502'`) - expect(err.message).to.include('`cy.visit()` failed because you are attempting to visit a URL that is of a different origin.') - expect(err.message).to.include('The new URL is considered a different origin because the following parts of the URL are different:') - expect(err.message).to.include('> protocol') expect(err.docsUrl).to.eq('https://on.cypress.io/cannot-visit-different-origin-domain') assertLogLength(this.logs, 2) expect(lastLog.get('error')).to.eq(err) @@ -1471,16 +1469,31 @@ describe('src/cy/commands/navigation', () => { }) cy.visit('http://localhost:3500/fixtures/generic.html') - cy.visit('https://localhost:3500/fixtures/generic.html') + cy.visit('https://localhost:3502/fixtures/generic.html') }) it('throws when attempting to visit a 2nd domain on different superdomain', function (done) { cy.on('fail', (err) => { const { lastLog } = this + const experimentalMessage = Cypress.config('experimentalSessionAndOrigin') ? `You likely forgot to use \`cy.origin()\`:\n` : `In order to visit a different origin, you can enable the \`experimentalSessionAndOrigin\` flag and use \`cy.origin()\`:\n` + + expect(err.message).to.equal(stripIndent`\ + \`cy.visit()\` failed because you are attempting to visit a URL that is of a different origin.\n + ${experimentalMessage} + \`cy.visit('http://localhost:3500/fixtures/generic.html')\` + \`\`\n + \`cy.origin('http://foobar.com:3500', () => {\` + \` cy.visit('http://www.foobar.com:3500/fixtures/generic.html')\` + \` \` + \`})\`\n + The new URL is considered a different origin because the following parts of the URL are different:\n + > superdomain\n + You may only \`cy.visit()\` same-origin URLs within a single test.\n + The previous URL you visited was:\n + > 'http://localhost:3500'\n + You're attempting to visit this URL:\n + > 'http://www.foobar.com:3500'`) - expect(err.message).to.include('`cy.visit()` failed because you are attempting to visit a URL that is of a different origin.') - expect(err.message).to.include('The new URL is considered a different origin because the following parts of the URL are different:') - expect(err.message).to.include('> superdomain') expect(err.docsUrl).to.eq('https://on.cypress.io/cannot-visit-different-origin-domain') assertLogLength(this.logs, 2) expect(lastLog.get('error')).to.eq(err) @@ -1489,14 +1502,31 @@ describe('src/cy/commands/navigation', () => { }) cy.visit('http://localhost:3500/fixtures/generic.html') - cy.visit('http://google.com:3500/fixtures/generic.html') + cy.visit('http://www.foobar.com:3500/fixtures/generic.html') }) it('throws attempting to visit 2 unique ip addresses', function (done) { cy.on('fail', (err) => { const { lastLog } = this + const experimentalMessage = Cypress.config('experimentalSessionAndOrigin') ? `You likely forgot to use \`cy.origin()\`:\n` : `In order to visit a different origin, you can enable the \`experimentalSessionAndOrigin\` flag and use \`cy.origin()\`:\n` + + expect(err.message).to.equal(stripIndent`\ + \`cy.visit()\` failed because you are attempting to visit a URL that is of a different origin.\n + ${experimentalMessage} + \`cy.visit('http://127.0.0.1:3500/fixtures/generic.html')\` + \`\`\n + \`cy.origin('http://0.0.0.0:3500', () => {\` + \` cy.visit('http://0.0.0.0:3500/fixtures/generic.html')\` + \` \` + \`})\`\n + The new URL is considered a different origin because the following parts of the URL are different:\n + > superdomain\n + You may only \`cy.visit()\` same-origin URLs within a single test.\n + The previous URL you visited was:\n + > 'http://127.0.0.1:3500'\n + You're attempting to visit this URL:\n + > 'http://0.0.0.0:3500'`) - expect(err.message).to.include('`cy.visit()` failed because you are attempting to visit a URL that is of a different origin.') expect(err.docsUrl).to.eq('https://on.cypress.io/cannot-visit-different-origin-domain') assertLogLength(this.logs, 2) expect(lastLog.get('error')).to.eq(err) @@ -1506,22 +1536,7 @@ describe('src/cy/commands/navigation', () => { cy .visit('http://127.0.0.1:3500/fixtures/generic.html') - .visit('http://126.0.0.1:3500/fixtures/generic.html') - }) - - it('does not call resolve:url when throws attempting to visit a 2nd domain', (done) => { - const backend = cy.spy(Cypress, 'backend') - - cy.on('fail', (err) => { - expect(backend).to.be.calledWithMatch('resolve:url', 'http://localhost:3500/fixtures/generic.html') - expect(backend).not.to.be.calledWithMatch('resolve:url', 'http://google.com:3500/fixtures/generic.html') - - done() - }) - - cy - .visit('http://localhost:3500/fixtures/generic.html') - .visit('http://google.com:3500/fixtures/generic.html') + .visit('http://0.0.0.0:3500/fixtures/generic.html') }) it('displays loading_network_failed when _resolveUrl throws', function (done) { @@ -2134,31 +2149,51 @@ describe('src/cy/commands/navigation', () => { .get('#does-not-exist', { timeout: 200 }).should('have.class', 'foo') }) - it('captures cross origin failures', function (done) { - cy.once('fail', (err) => { + it('displays cross origin failures when navigating to a cross origin', { pageLoadTimeout: 3000 }, function (done) { + cy.on('fail', (err) => { const { lastLog } = this - assertLogLength(this.logs, 2) - expect(err.message).to.include('Cypress detected a cross origin error happened on page load') - expect(err.docsUrl).to.eq('https://on.cypress.io/cross-origin-violation') - expect(lastLog.get('name')).to.eq('page load') - expect(lastLog.get('state')).to.eq('failed') + if (Cypress.config('experimentalSessionAndOrigin')) { + // When the experimentalSessionAndOrigin feature is enabled, we will timeout and display this message. + expect(err.message).to.equal(stripIndent`\ + Timed out after waiting \`3000ms\` for your remote page to load on origin(s):\n + - \`http://localhost:3500\`\n + A cross-origin request for \`http://www.foobar.com:3500/fixtures/multi-domain-secondary.html\` was detected.\n + A command that triggers cross-origin navigation must be immediately followed by a \`cy.origin()\` command:\n + \`cy.origin(\'http://foobar.com:3500\', () => {\` + \` \` + \`})\`\n + If the cross-origin request was an intermediary state, you can try increasing the \`pageLoadTimeout\` value in \`cypress.json\` to wait longer.\n + Browsers will not fire the \`load\` event until all stylesheets and scripts are done downloading.\n + When this \`load\` event occurs, Cypress will continue running commands.`) + + expect(err.docsUrl).to.eq('https://on.cypress.io/origin') + } else { + const error = Cypress.isBrowser('firefox') ? 'Permission denied to access property "document" on cross-origin object' : 'Blocked a frame with origin "http://localhost:3500" from accessing a cross-origin frame.' + + // When the experimentalSessionAndOrigin feature is disabled, we will immediately and display this message. + expect(err.message).to.equal(stripIndent`\ + Cypress detected a cross origin error happened on page load:\n + > ${error}\n + Before the page load, you were bound to the origin policy:\n + > http://localhost:3500\n + A cross origin error happens when your application navigates to a new URL which does not match the origin policy above.\n + A new URL does not match the origin policy if the 'protocol', 'port' (if specified), and/or 'host' (unless of the same superdomain) are different.\n + Cypress does not allow you to navigate to a different origin URL within a single test.\n + You may need to restructure some of your test code to avoid this problem.\n + Alternatively you can also disable Chrome Web Security in Chromium-based browsers which will turn off this restriction by setting { chromeWebSecurity: false } in \`cypress.json\`.`) + + expect(err.docsUrl).to.eq('https://on.cypress.io/cross-origin-violation') + } + + assertLogLength(this.logs, 6) expect(lastLog.get('error')).to.eq(err) - expect(cy.state('onPageLoadErr')).to.be.null done() }) - cy - .visit('/fixtures/jquery.html') - .window({ log: false }).then((win) => { - const url = 'http://localhost:3501/fixtures/generic.html' - - const $a = win.$(`jquery`) - .appendTo(win.document.body) - - causeSynchronousBeforeUnload($a) - }) + cy.visit('/fixtures/multi-domain.html') + cy.get('a[data-cy="cross-origin-secondary-link"]').click() }) return null @@ -2285,20 +2320,33 @@ describe('src/cy/commands/navigation', () => { }) }) - it('waits for stability at the end of the command queue when not stable', (done) => { + it('tests waiting on stability at the end of the command queue', (done) => { cy .visit('/fixtures/generic.html') .then((win) => { - cy.on('window:load', () => { + // We do not wait if the experimentalSessionAndOrigin feature is enabled + if (Cypress.config('experimentalSessionAndOrigin')) { + const onLoad = cy.spy() + + cy.on('window:load', onLoad) + cy.on('command:queue:end', () => { + expect(onLoad).not.have.been.called done() }) - }) + } else { + // We do wait if the experimentalSessionAndOrigin feature is not enabled + cy.on('window:load', () => { + cy.on('command:queue:end', () => { + done() + }) + }) + } cy.on('command:queue:before:end', () => { - // force us to become unstable immediately - // else the beforeunload event fires at the end - // of the tick which is too late + // force us to become unstable immediately + // else the beforeunload event fires at the end + // of the tick which is too late cy.isStable(false, 'testing') win.location.href = '/timeout?ms=100' diff --git a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts index 60245c2bb6ee..dd5dae3b2617 100644 --- a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts +++ b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts @@ -1011,7 +1011,7 @@ describe('network stubbing', function () { // using "hosts" setting in the "cypress.json" file const corsUrl = 'http://diff.foobar.com:3501/no-cors' - before(() => { + beforeEach(() => { cy.visit('http://127.0.0.1:3500/fixtures/dom.html') }) @@ -1070,7 +1070,7 @@ describe('network stubbing', function () { // a different domain from the page own domain const corsUrl = 'http://diff.foobar.com:3501/cors' - before(() => { + beforeEach(() => { cy.visit('http://127.0.0.1:3500/fixtures/dom.html') }) @@ -1278,6 +1278,8 @@ describe('network stubbing', function () { // @see https://github.com/cypress-io/cypress/issues/15841 it('prevents requests from reaching destination server', function () { + cy.visit('/fixtures/empty.html') + const v = String(Date.now()) // this test creates server-side state via /set-var to test if requests are being sent or not @@ -1991,6 +1993,7 @@ describe('network stubbing', function () { .intercept(`${url}*`, (req) => { // @ts-ignore req.on(eventName, (res) => { + res.headers['content-type'] = 'application/json' res.send({ statusCode: 200, fixture: 'valid.json', @@ -2616,6 +2619,8 @@ describe('network stubbing', function () { it('intercepts cached responses as expected', { browser: '!firefox', // TODO: why does firefox behave differently? transparently returns cached response }, function () { + cy.visit('/fixtures/empty.html') + // use a queryparam to bust cache from previous runs of this test const url = `/fixtures/generic.html?t=${Date.now()}` let hits = 0 @@ -2688,6 +2693,7 @@ describe('network stubbing', function () { return Promise.delay(delay) .then(() => { + res.headers['content-type'] = 'text/plain' res.send('Promise.delay worked') }) }) @@ -2731,6 +2737,7 @@ describe('network stubbing', function () { req.reply((res) => { this.start = Date.now() + res.headers['content-type'] = 'text/plain' res.setThrottle(kbps).send(payload) }) }).then(() => { @@ -2755,6 +2762,7 @@ describe('network stubbing', function () { req.reply((res) => { this.start = Date.now() + res.headers['content-type'] = 'text/plain' res.setThrottle(kbps).setDelay(delay).send({ statusCode: 200, body: payload, @@ -2929,6 +2937,7 @@ describe('network stubbing', function () { it('res.send(body)', function () { cy.intercept('/custom-headers*', function (req) { req.reply((res) => { + res.headers['content-type'] = 'text/plain' res.send('baz') }) }) @@ -2985,6 +2994,7 @@ describe('network stubbing', function () { it('res.send(status, body)', function (done) { cy.intercept('/custom-headers*', function (req) { req.reply((res) => { + res.headers['content-type'] = 'text/plain' res.send(777, 'bar') }) }) @@ -3047,6 +3057,7 @@ describe('network stubbing', function () { cy.intercept(`${url}*`, function (req) { req.reply((res) => { + res.headers['content-type'] = 'application/json' res.send({ statusCode: 200, fixture: 'valid.json', @@ -3090,6 +3101,7 @@ describe('network stubbing', function () { req.reply((res) => { this.start = Date.now() + res.headers['content-type'] = 'text/plain' // ensure .throttle and .delay are overridden res.setThrottle(1e6).setDelay(1).send({ statusCode: 200, diff --git a/packages/driver/cypress/integration/commands/waiting_spec.js b/packages/driver/cypress/integration/commands/waiting_spec.js index 73ab6976121b..3134a9f38691 100644 --- a/packages/driver/cypress/integration/commands/waiting_spec.js +++ b/packages/driver/cypress/integration/commands/waiting_spec.js @@ -53,7 +53,7 @@ describe('src/cy/commands/waiting', () => { }) describe('alias argument', () => { - before(() => { + beforeEach(() => { cy.visit('/fixtures/jquery.html') }) @@ -750,7 +750,7 @@ describe('src/cy/commands/waiting', () => { }) describe('multiple alias arguments', () => { - before(() => { + beforeEach(() => { cy.visit('/fixtures/jquery.html') }) @@ -776,7 +776,7 @@ describe('src/cy/commands/waiting', () => { }) describe('multiple separate alias waits', () => { - before(() => { + beforeEach(() => { cy.visit('/fixtures/jquery.html') }) @@ -1022,6 +1022,8 @@ describe('src/cy/commands/waiting', () => { done() }) + cy.visit('/fixtures/empty.html') + cy .server() .route(/foo/, {}).as('getFoo') @@ -1042,6 +1044,10 @@ describe('src/cy/commands/waiting', () => { }) describe('alias argument', () => { + beforeEach(() => { + cy.visit('/fixtures/empty.html') + }) + it('is a parent command', () => { cy .server() @@ -1133,6 +1139,10 @@ describe('src/cy/commands/waiting', () => { }) describe('timeouts', function () { + beforeEach(() => { + cy.visit('/fixtures/empty.html') + }) + it('sets default requestTimeout', { requestTimeout: 199, }, function (done) { diff --git a/packages/driver/cypress/integration/commands/window_spec.js b/packages/driver/cypress/integration/commands/window_spec.js index 27665aa2344e..a56a241b99b5 100644 --- a/packages/driver/cypress/integration/commands/window_spec.js +++ b/packages/driver/cypress/integration/commands/window_spec.js @@ -364,26 +364,16 @@ describe('src/cy/commands/window', () => { }) context('#title', () => { - before(() => { + beforeEach(() => { cy .visit('/fixtures/generic.html') .then(function (win) { const h = $(win.document.head) h.find('script').remove() - - this.head = h.prop('outerHTML') - this.body = win.document.body.outerHTML }) }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.head).empty().html(this.head) - $(doc.body).empty().html(this.body) - }) - it('returns the pages title as a string', () => { const title = cy.$$('title').text() diff --git a/packages/driver/cypress/integration/commands/xhr_spec.js b/packages/driver/cypress/integration/commands/xhr_spec.js index 6aa0b8155c29..16718906f7f0 100644 --- a/packages/driver/cypress/integration/commands/xhr_spec.js +++ b/packages/driver/cypress/integration/commands/xhr_spec.js @@ -3,26 +3,16 @@ const { assertLogLength } = require('../../support/utils') const { _, $, Promise } = Cypress describe('src/cy/commands/xhr', () => { - before(() => { + beforeEach(function () { cy .visit('/fixtures/jquery.html') .then(function (win) { const h = $(win.document.head) h.find('script').remove() - - this.head = h.prop('outerHTML') - this.body = win.document.body.outerHTML }) }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.head).empty().html(this.head) - $(doc.body).empty().html(this.body) - }) - context('#startXhrServer', () => { it('continues to be a defined properties', () => { cy diff --git a/packages/driver/cypress/integration/cy/snapshot_spec.js b/packages/driver/cypress/integration/cy/snapshot_spec.js index d793b10040a5..429e1337e88c 100644 --- a/packages/driver/cypress/integration/cy/snapshot_spec.js +++ b/packages/driver/cypress/integration/cy/snapshot_spec.js @@ -32,28 +32,16 @@ describe('driver/src/cy/snapshots', () => { }) context('snapshot el', () => { - before(() => { + beforeEach(() => { cy .visit('/fixtures/generic.html') .then(function (win) { const h = $(win.document.head) h.find('script').remove() - - this.head = h.prop('outerHTML') - this.body = win.document.body.outerHTML }) }) - beforeEach(function () { - const doc = cy.state('document') - - $(doc.head).empty().html(this.head) - $(doc.body).empty().html(this.body) - - this.$el = $('snapshot').appendTo(cy.$$('body')) - }) - it('does not clone scripts', function () { $(' + + + `) + }) + + app.get('/login', (req, res) => { + const { username } = req.query + + if (!username) { + return res.send('

Must specify username to log in

') + } + + // can't use res.cookie() because it won't allow setting an invalid + // SameSite value, which we want to test + res + .header('Set-Cookie', `user=${username}${getCookieAdditions(req.query)}`) + .redirect(302, '/welcome') + }) + + app.get('/welcome', (req, res) => { + if (!req.cookies.user) { + return res.send('

No user found

') + } + + res.send(`

Welcome, ${req.cookies.user}!

`) + }) + let _var = '' app.get('/set-var', (req, res) => { diff --git a/packages/driver/cypress/support/utils.js b/packages/driver/cypress/support/utils.js index c358c3d657c8..8ae506576458 100644 --- a/packages/driver/cypress/support/utils.js +++ b/packages/driver/cypress/support/utils.js @@ -72,6 +72,20 @@ export const assertLogLength = (logs, expectedLength) => { expect(logs.length).to.eq(expectedLength, `received ${logs.length} logs when we expected ${expectedLength}: [${receivedLogs}]`) } +export const findCrossOriginLogs = (consolePropCommand, logMap, matchingOrigin) => { + const matchedLogs = Array.from(logMap.values()).filter((log) => { + const props = log.get() + + let consoleProps = _.isFunction(props?.consoleProps) ? props.consoleProps() : props?.consoleProps + + return consoleProps.Command === consolePropCommand && props.id.includes(matchingOrigin) + }) + + const logAttrs = matchedLogs.map((log) => log.get()) + + return logAttrs.length === 1 ? logAttrs[0] : logAttrs +} + export const attachListeners = (listenerArr) => { return (els) => { _.each(els, (el, elName) => { @@ -94,6 +108,10 @@ const getAllFn = (...aliases) => { ) } +const shouldWithTimeout = (cb, timeout = 250) => { + cy.wrap({}, { timeout }).should(cb) +} + export const keyEvents = [ 'keydown', 'keyup', @@ -120,6 +138,8 @@ export const expectCaret = (start, end) => { Cypress.Commands.add('getAll', getAllFn) +Cypress.Commands.add('shouldWithTimeout', shouldWithTimeout) + const chaiSubset = require('chai-subset') chai.use(chaiSubset) diff --git a/packages/driver/package.json b/packages/driver/package.json index 14ac9cf26243..f9d1b8bba748 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -5,7 +5,9 @@ "scripts": { "clean-deps": "rm -rf node_modules", "cypress:open": "node ../../scripts/cypress open", - "cypress:run": "node ../../scripts/cypress run", + "cypress:run": "node ../../scripts/cypress run --spec \"cypress/integration/*/*\",\"cypress/integration/*/!(multi-domain)/**/*\"", + "cypress:open-experimentalSessionAndOrigin": "node ../../scripts/cypress open --config experimentalSessionAndOrigin=true", + "cypress:run-experimentalSessionAndOrigin": "node ../../scripts/cypress run --config experimentalSessionAndOrigin=true", "postinstall": "patch-package", "start": "node -e 'console.log(require(`chalk`).red(`\nError:\n\tRunning \\`yarn start\\` is no longer needed for driver/cypress tests.\n\tWe now automatically spawn the server in the pluginsFile.\n\tChanges to the server will be watched and reloaded automatically.`))'" }, @@ -19,7 +21,9 @@ "@packages/config": "0.0.0-development", "@packages/network": "0.0.0-development", "@packages/runner": "0.0.0-development", + "@packages/runner-shared": "0.0.0-development", "@packages/server": "0.0.0-development", + "@packages/socket": "0.0.0-development", "@packages/ts": "0.0.0-development", "@sinonjs/fake-timers": "8.1.0", "@types/chalk": "^2.2.0", @@ -38,6 +42,8 @@ "chai-subset": "1.6.0", "clone": "2.1.2", "compression": "1.7.4", + "cookie-parser": "1.4.5", + "core-js-pure": "3.21.0", "cors": "2.8.5", "cypress-multi-reporters": "1.4.0", "dayjs": "^1.10.3", diff --git a/packages/driver/src/cy/commands/cookies.ts b/packages/driver/src/cy/commands/cookies.ts index 69075312e083..176a8e359c45 100644 --- a/packages/driver/src/cy/commands/cookies.ts +++ b/packages/driver/src/cy/commands/cookies.ts @@ -183,7 +183,7 @@ export default function (Commands, Cypress, cy, state, config) { // stuff, or handling this in the runner itself? // Cypress sessions will clear cookies on its own before each test Cypress.on('test:before:run:async', () => { - if (!Cypress.config('experimentalSessionSupport')) { + if (!Cypress.config('experimentalSessionAndOrigin')) { return getAndClear() } }) diff --git a/packages/driver/src/cy/commands/navigation.ts b/packages/driver/src/cy/commands/navigation.ts index da2a73e2b2d7..1d560bca334e 100644 --- a/packages/driver/src/cy/commands/navigation.ts +++ b/packages/driver/src/cy/commands/navigation.ts @@ -7,13 +7,13 @@ import $utils from '../../cypress/utils' import $errUtils from '../../cypress/error_utils' import { LogUtils, Log } from '../../cypress/log' import { bothUrlsMatchAndOneHasHash } from '../navigation' -import { $Location } from '../../cypress/location' +import { $Location, LocationObject } from '../../cypress/location' import debugFn from 'debug' const debug = debugFn('cypress:driver:navigation') let id = null -let previousDomainVisited: boolean = false +let previouslyVisitedLocation: LocationObject | undefined let hasVisitedAboutBlank: boolean = false let currentlyVisitingAboutBlank: boolean = false let knownCommandCausedInstability: boolean = false @@ -30,7 +30,7 @@ const reset = (test: any = {}) => { // continuously reset this // before each test run! - previousDomainVisited = false + previouslyVisitedLocation = undefined // make sure we reset that we haven't // visited about blank again @@ -50,27 +50,58 @@ const isValidVisitMethod = (method) => { const timedOutWaitingForPageLoad = (ms, log) => { debug('timedOutWaitingForPageLoad') - $errUtils.throwErrByPath('navigation.timed_out', { + const anticipatedCrossOriginHref = cy.state('anticipatingCrossOriginResponse')?.href + + // Were we anticipating a cross origin page when we timed out? + if (!anticipatedCrossOriginHref) { + $errUtils.throwErrByPath('navigation.timed_out', { + args: { + configFile: Cypress.config('configFile'), + ms, + }, + onFail: log, + }) + } + + // We remain in an anticipating state until either a load even happens or a timeout. + cy.isAnticipatingCrossOriginResponseFor(undefined) + + // By default origins is just this location. + let originPolicies = [$Location.create(location.href).originPolicy] + + const currentCommand = cy.queue.state('current') + + if (currentCommand?.get('name') === 'origin') { + // If the current command is a cy.origin command, we should have gotten a request on the origin it expects. + originPolicies = [cy.state('latestActiveOriginPolicy')] + } else if (Cypress.isCrossOriginSpecBridge && cy.queue.isOnLastCommand()) { + // If this is a cross origin spec bridge and we're on the last command, we should have gotten a request on the origin of one of the parents. + originPolicies = cy.state('parentOriginPolicies') + } + + $errUtils.throwErrByPath('navigation.cross_origin_load_timed_out', { args: { configFile: Cypress.config('configFile'), ms, + crossOriginUrl: $Location.create(anticipatedCrossOriginHref), + originPolicies, }, onFail: log, }) } -const cannotVisitDifferentOrigin = (origin, previousUrlVisited, remoteUrl, existingUrl, log) => { +const cannotVisitDifferentOrigin = ({ remote, existing, originalUrl, previouslyVisitedLocation, log, isCrossOriginSpecBridge = false }) => { const differences: string[] = [] - if (remoteUrl.protocol !== existingUrl.protocol) { + if (remote.protocol !== existing.protocol) { differences.push('protocol') } - if (remoteUrl.port !== existingUrl.port) { + if (remote.port !== existing.port) { differences.push('port') } - if (remoteUrl.superDomain !== existingUrl.superDomain) { + if (remote.superDomain !== existing.superDomain) { differences.push('superdomain') } @@ -78,14 +109,36 @@ const cannotVisitDifferentOrigin = (origin, previousUrlVisited, remoteUrl, exist onFail: log, args: { differences: differences.join(', '), - previousUrl: previousUrlVisited, - attemptedUrl: origin, + previousUrl: previouslyVisitedLocation, + attemptedUrl: remote, + originalUrl, + isCrossOriginSpecBridge, + experimentalSessionAndOrigin: Cypress.config('experimentalSessionAndOrigin'), + }, + errProps: { + isCrossOrigin: true, }, } $errUtils.throwErrByPath('visit.cannot_visit_different_origin', errOpts) } +const cannotVisitPreviousOrigin = ({ remote, originalUrl, previouslyVisitedLocation, log }) => { + const errOpts = { + onFail: log, + args: { + attemptedUrl: remote, + previousUrl: previouslyVisitedLocation, + originalUrl, + }, + errProps: { + isCrossOrigin: true, + }, + } + + $errUtils.throwErrByPath('origin.cannot_visit_previous_origin', errOpts) +} + const specifyFileByRelativePath = (url, log) => { $errUtils.throwErrByPath('visit.specify_file_by_relative_path', { onFail: log, @@ -342,23 +395,75 @@ const stabilityChanged = (Cypress, state, config, stable) => { debug('waiting for window:load') - return new Promise((resolve) => { - return cy.once('window:load', (e) => { + const promise = new Promise((resolve) => { + const onWindowLoad = (win) => { // this prevents a log occurring when we navigate to about:blank inbetween tests if (!state('duringUserTestExecution')) return cy.state('onPageLoadErr', null) - if (e.window.location.href === 'about:blank') { + if (win.location.href === 'about:blank') { // we treat this as a system log since navigating to about:blank must have been caused by Cypress options._log.set({ message: '', name: 'Clear Page', type: 'system' }).snapshot().end() } else { options._log.set('message', '--page loaded--').snapshot().end() } - return resolve() + resolve() + } + + const onCrossOriginWindowLoad = ({ url }) => { + options._log.set('message', '--page loaded--').snapshot().end() + + // Updating the URL state, This is done to display the new url event when we return to the primary origin + let urls = state('urls') || [] + let urlPosition = state('urlPosition') + + if (urlPosition === undefined) { + urlPosition = -1 + } + + urls.push(url) + urlPosition = urlPosition + 1 + + state('urls', urls) + state('url', url) + state('urlPosition', urlPosition) + + resolve() + } + + const onCrossOriginFailure = (err) => { + options._log.set('message', '--page loaded--').snapshot().error(err) + + resolve() + } + + const onInternalWindowLoad = (details) => { + switch (details.type) { + case 'same:origin': + return onWindowLoad(details.window) + case 'cross:origin': + return onCrossOriginWindowLoad(details) + case 'cross:origin:failure': + return onCrossOriginFailure(details.error) + default: + throw new Error(`Unexpected internal:window:load type: ${details?.type}`) + } + } + + cy.once('internal:window:load', onInternalWindowLoad) + + // If this request is still pending after the test run, resolve it, no commands were waiting on its result. + cy.once('test:after:run', () => { + if (promise.isPending()) { + options._log.set('message', '').end() + resolve() + } }) }) + + return promise } const reject = (err) => { @@ -387,18 +492,25 @@ const stabilityChanged = (Cypress, state, config, stable) => { } } +// filter the options to only the REQUEST_URL_OPTS options, normalize the timeout +// value to the responseTimeout, and add the isCrossOriginSpecBridge value. +// // there are really two timeout values - pageLoadTimeout // and the underlying responseTimeout. for the purposes -// of resolving resolving the url, we only care about +// of resolving the url, we only care about // responseTimeout - since pageLoadTimeout is a driver // and browser concern. therefore we normalize the options // object and send 'responseTimeout' as options.timeout // for the backend. -const normalizeTimeoutOptions = (options) => { +const normalizeOptions = (options) => { return _ .chain(options) .pick(REQUEST_URL_OPTS) - .extend({ timeout: options.responseTimeout }) + .extend({ + timeout: options.responseTimeout, + isCrossOrigin: Cypress.isCrossOriginSpecBridge, + hasAlreadyVisitedUrl: options.hasAlreadyVisitedUrl, + }) .value() } @@ -412,6 +524,7 @@ type InvalidContentTypeError = Error & { interface InternalVisitOptions extends Partial { _log?: Log + hasAlreadyVisitedUrl: boolean } export default (Commands, Cypress, cy, state, config) => { @@ -453,7 +566,7 @@ export default (Commands, Cypress, cy, state, config) => { return Cypress.backend( 'resolve:url', url, - normalizeTimeoutOptions(options), + normalizeOptions(options), ) .then((resp: any = {}) => { if (!resp.isOkStatusCode) { @@ -500,6 +613,10 @@ export default (Commands, Cypress, cy, state, config) => { } catch (e) {} // eslint-disable-line no-empty }) + Cypress.primaryOriginCommunicator.on('visit:url', ({ url }) => { + $utils.iframeSrc(Cypress.$autIframe, url) + }) + Commands.addAll({ reload (...args) { let forceReload @@ -734,6 +851,8 @@ export default (Commands, Cypress, cy, state, config) => { onLoad () {}, }) + options.hasAlreadyVisitedUrl = !!previouslyVisitedLocation + if (!_.isUndefined(options.qs) && !_.isObject(options.qs)) { $errUtils.throwErrByPath('visit.invalid_qs', { args: { qs: String(options.qs) } }) } @@ -774,10 +893,14 @@ export default (Commands, Cypress, cy, state, config) => { url = $Location.normalize(url) - const baseUrl = config('baseUrl') + if (Cypress.isCrossOriginSpecBridge) { + url = $Location.qualifyWithBaseUrl(Cypress.state('originCommandBaseUrl'), url) + } else { + const baseUrl = config('baseUrl') - if (baseUrl) { - url = $Location.qualifyWithBaseUrl(baseUrl, url) + if (baseUrl) { + url = $Location.qualifyWithBaseUrl(baseUrl, url) + } } const qs = options.qs @@ -851,6 +974,12 @@ export default (Commands, Cypress, cy, state, config) => { knownCommandCausedInstability = true + // if this is a cross origin spec bridge, we need to tell the primary to change + // the AUT iframe since we don't have access to it + if (Cypress.isCrossOriginSpecBridge) { + return Cypress.specBridgeCommunicator.toPrimary('visit:url', { url }) + } + return $utils.iframeSrc($autIframe, url) }) } @@ -917,33 +1046,36 @@ export default (Commands, Cypress, cy, state, config) => { const existingHash = remote.hash || '' const existingAuth = remote.auth || '' - if (previousDomainVisited && (remote.originPolicy !== existing.originPolicy)) { - // if we've already visited a new superDomain - // then die else we'd be in a terrible endless loop - // we also need to disable retries to prevent the endless loop - $utils.getTestFromRunnable(state('runnable'))._retries = 0 - - return cannotVisitDifferentOrigin(remote.origin, previousDomainVisited, remote, existing, options._log) - } - - const current = $Location.create(win.location.href) - - // if all that is changing is the hash then we know - // the browser won't actually make a new http request - // for this, and so we need to resolve onLoad immediately - // and bypass the actual visit resolution stuff - if (bothUrlsMatchAndOneHasHash(current, remote)) { - // https://github.com/cypress-io/cypress/issues/1311 - if (current.hash === remote.hash) { - consoleProps['Note'] = 'Because this visit was to the same hash, the page did not reload and the onBeforeLoad and onLoad callbacks did not fire.' + // in a cross origin spec bridge, the window may not have been set yet if nothing has been loaded in the secondary origin, + // it's also possible for a new test to start and for a cross-origin failure to occur if the win is set but + // the AUT hasn't yet navigated to the secondary origin + if (win) { + try { + const current = $Location.create(win.location.href) + + // if all that is changing is the hash then we know + // the browser won't actually make a new http request + // for this, and so we need to resolve onLoad immediately + // and bypass the actual visit resolution stuff + if (bothUrlsMatchAndOneHasHash(current, remote)) { + // https://github.com/cypress-io/cypress/issues/1311 + if (current.hash === remote.hash) { + consoleProps['Note'] = 'Because this visit was to the same hash, the page did not reload and the onBeforeLoad and onLoad callbacks did not fire.' + + return onLoad({ runOnLoadCallback: false }) + } - return onLoad({ runOnLoadCallback: false }) + return changeIframeSrc(remote.href, 'hashchange') + .then(() => { + return onLoad({}) + }) + } + } catch (e) { + // if this is a cross-origin error, skip it + if (e.name !== 'SecurityError') { + throw e + } } - - return changeIframeSrc(remote.href, 'hashchange') - .then(() => { - return onLoad({}) - }) } if (existingHash) { @@ -959,7 +1091,7 @@ export default (Commands, Cypress, cy, state, config) => { return requestUrl(url, options) .then((resp: any = {}) => { - let { url, originalUrl, cookies, redirects, filePath } = resp + let { url, originalUrl, cookies, redirects, filePath, isPrimaryOrigin } = resp // reapply the existing hash url += existingHash @@ -991,10 +1123,8 @@ export default (Commands, Cypress, cy, state, config) => { // if the origin currently matches // then go ahead and change the iframe's src - // and we're good to go - // if origin is existing.origin if (remote.originPolicy === existing.originPolicy) { - previousDomainVisited = remote.origin + previouslyVisitedLocation = remote url = $Location.fullyQualifyUrl(url) @@ -1004,13 +1134,28 @@ export default (Commands, Cypress, cy, state, config) => { }) } - // if we've already visited a new origin - // then die else we'd be in a terrible endless loop - if (previousDomainVisited) { - return cannotVisitDifferentOrigin(remote.origin, previousDomainVisited, remote, existing, options._log) + // if we've already cy.visit'ed in the test and we are visiting a new origin, + // throw an error, else we'd be in a endless loop, + // we also need to disable retries to prevent the endless loop + if (previouslyVisitedLocation) { + $utils.getTestFromRunnable(state('runnable'))._retries = 0 + + const params = { remote, existing, originalUrl, previouslyVisitedLocation, log: options._log } + + return cannotVisitDifferentOrigin(params) + } + + // if we are in a cross origin spec bridge and the origin policies weren't the same, + // we need to throw an error since the user tried to visit a new + // origin which isn't allowed within a cy.origin block + if (Cypress.isCrossOriginSpecBridge) { + const existingAutOrigin = win ? $Location.create(win.location.href) : $Location.create(Cypress.state('currentActiveOriginPolicy')) + const params = { remote, existing, originalUrl, previouslyVisitedLocation: existingAutOrigin, log: options._log, isCrossOriginSpecBridge: true, isPrimaryOrigin } + + return isPrimaryOrigin ? cannotVisitPreviousOrigin(params) : cannotVisitDifferentOrigin(params) } - // tell our backend we're changing domains + // tell our backend we're changing origins // TODO: add in other things we want to preserve // state for like scrollTop let s: Record = { @@ -1088,10 +1233,11 @@ export default (Commands, Cypress, cy, state, config) => { return } - // if it came from the user's onBeforeLoad or onLoad callback, it's + // if the err came from the user's onBeforeLoad/onLoad callback or is cross-origin, it's // not a network failure, and we should throw the original error - if (err.isCallbackError) { + if (err.isCallbackError || err.isCrossOrigin) { delete err.isCallbackError + delete err.isCrossOrigin throw err } @@ -1119,7 +1265,10 @@ export default (Commands, Cypress, cy, state, config) => { // so that we nuke the previous state. subsequent // visits will not navigate to about:blank so that // our history entries are intact - if (!hasVisitedAboutBlank) { + // skip for cross origin spec bridges since they require + // session support which already visits + // about:blank between tests + if (!hasVisitedAboutBlank && !Cypress.isCrossOriginSpecBridge) { hasVisitedAboutBlank = true currentlyVisitingAboutBlank = true diff --git a/packages/driver/src/cy/commands/request.ts b/packages/driver/src/cy/commands/request.ts index 74eb8caca0d4..6fe8adfb65f5 100644 --- a/packages/driver/src/cy/commands/request.ts +++ b/packages/driver/src/cy/commands/request.ts @@ -144,7 +144,12 @@ export default (Commands, Cypress, cy, state, config) => { // origin may return an empty string if we haven't visited anything yet options.url = $Location.normalize(options.url) - const originOrBase = config('baseUrl') || cy.getRemoteLocation('origin') + // If passed a relative url, determine the fully qualified URL to use. + // In the multi-origin version of the driver, we use originCommandBaseUrl, + // which is set to the origin that is associated with it. + // In the primary driver (where originCommandBaseUrl is undefined), we + // use the baseUrl or remote origin. + const originOrBase = Cypress.state('originCommandBaseUrl') || config('baseUrl') || cy.getRemoteLocation('origin') if (originOrBase) { options.url = $Location.qualifyWithBaseUrl(originOrBase, options.url) diff --git a/packages/driver/src/cy/commands/screenshot.ts b/packages/driver/src/cy/commands/screenshot.ts index 585cd08639e8..a5920a9a35c8 100644 --- a/packages/driver/src/cy/commands/screenshot.ts +++ b/packages/driver/src/cy/commands/screenshot.ts @@ -331,7 +331,7 @@ const takeScreenshot = (Cypress, state, screenshotConfig, options: TakeScreensho } } - const before = () => { + const before = ($el) => { return Promise.try(() => { if (disableTimersAndAnimations) { return cy.pauseTimers(true) @@ -340,11 +340,41 @@ const takeScreenshot = (Cypress, state, screenshotConfig, options: TakeScreensho return null }) .then(() => { + // could fail if iframe is cross-origin, so fail gracefully + try { + if (disableTimersAndAnimations) { + $dom.addCssAnimationDisabler($el) + } + + _.each(getBlackout(screenshotConfig), (selector) => { + $dom.addBlackouts($el, selector) + }) + } catch (err) { + /* eslint-disable no-console */ + console.error('Failed to modify app dom:') + console.error(err) + /* eslint-enable no-console */ + } + return sendAsync('before:screenshot', getOptions(true)) }) } - const after = () => { + const after = ($el) => { + // could fail if iframe is cross-origin, so fail gracefully + try { + if (disableTimersAndAnimations) { + $dom.removeCssAnimationDisabler($el) + } + + $dom.removeBlackouts($el) + } catch (err) { + /* eslint-disable no-console */ + console.error('Failed to modify app dom:') + console.error(err) + /* eslint-enable no-console */ + } + send('after:screenshot', getOptions(false)) return Promise.try(() => { @@ -381,7 +411,7 @@ const takeScreenshot = (Cypress, state, screenshotConfig, options: TakeScreensho ? subject : $dom.wrap(state('document').documentElement) - return before() + return before($el) .then(() => { if (onBeforeScreenshot) { onBeforeScreenshot.call(state('ctx'), $el) @@ -408,7 +438,7 @@ const takeScreenshot = (Cypress, state, screenshotConfig, options: TakeScreensho return props }) - .finally(after) + .finally(() => after($el)) } interface InternalScreenshotOptions extends Partial { diff --git a/packages/driver/src/cy/commands/sessions/index.ts b/packages/driver/src/cy/commands/sessions/index.ts index 4aaedbc4cd17..e901955df5c6 100644 --- a/packages/driver/src/cy/commands/sessions/index.ts +++ b/packages/driver/src/cy/commands/sessions/index.ts @@ -108,8 +108,12 @@ export default function (Commands, Cypress, cy) { } function throwIfNoSessionSupport () { - if (!Cypress.config('experimentalSessionSupport')) { - $errUtils.throwErrByPath('sessions.experimentNotEnabled') + if (!Cypress.config('experimentalSessionAndOrigin')) { + $errUtils.throwErrByPath('sessions.experimentNotEnabled', { + args: { + experimentalSessionSupport: Cypress.config('experimentalSessionSupport'), + }, + }) } } @@ -311,7 +315,7 @@ export default function (Commands, Cypress, cy) { registerSessionHooks () { Cypress.on('test:before:run:async', () => { - if (Cypress.config('experimentalSessionSupport')) { + if (Cypress.config('experimentalSessionAndOrigin')) { currentTestRegisteredSessions.clear() return navigateAboutBlank(false) diff --git a/packages/driver/src/cy/commands/window.ts b/packages/driver/src/cy/commands/window.ts index 908e81189a25..a187a4c3ec7f 100644 --- a/packages/driver/src/cy/commands/window.ts +++ b/packages/driver/src/cy/commands/window.ts @@ -60,6 +60,12 @@ export default (Commands, Cypress, cy, state) => { // currentViewport could already be set due to previous runs currentViewport = currentViewport || defaultViewport + // sync the global viewport state when the viewport has changed in the primary or secondary + Cypress.primaryOriginCommunicator.on('sync:viewport', (viewport) => { + currentViewport = viewport + state(viewport) + }) + Cypress.on('test:before:run:async', () => { // if we have viewportDefaults it means // something has changed the default and we diff --git a/packages/driver/src/cy/jquery.ts b/packages/driver/src/cy/jquery.ts index 436fb9d104ac..6585056a9b00 100644 --- a/packages/driver/src/cy/jquery.ts +++ b/packages/driver/src/cy/jquery.ts @@ -9,6 +9,14 @@ const remoteJQueryisNotSameAsGlobal = (remoteJQuery) => { // eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces export const create = (state) => ({ + $$ (selector, context) { + if (context == null) { + context = state('document') + } + + return $dom.query(selector, context) + }, + getRemotejQueryInstance (subject) { // we make assumptions that you cannot have // an array of mixed types, so we only look at diff --git a/packages/driver/src/cy/listeners.ts b/packages/driver/src/cy/listeners.ts index deba8927e160..1d5f5cb9cab3 100644 --- a/packages/driver/src/cy/listeners.ts +++ b/packages/driver/src/cy/listeners.ts @@ -59,6 +59,7 @@ type BoundCallbacks = { onError: (handlerType) => (event) => undefined onHistoryNav: (delta) => void onSubmit: (e) => any + onLoad: (e) => any onBeforeUnload: (e) => undefined onUnload: (e) => any onNavigation: (...args) => any @@ -78,6 +79,10 @@ export const bindToListeners = (contentWindow, callbacks: BoundCallbacks) => { addListener(contentWindow, 'error', callbacks.onError('error')) addListener(contentWindow, 'unhandledrejection', callbacks.onError('unhandledrejection')) + addListener(contentWindow, 'load', (e) => { + callbacks.onLoad(e) + }) + addListener(contentWindow, 'beforeunload', (e) => { // bail if we've canceled this event (from another source) // or we've set a returnValue on the original event diff --git a/packages/driver/src/cy/location.ts b/packages/driver/src/cy/location.ts index 64c46c52cdfb..34b9d51dd158 100644 --- a/packages/driver/src/cy/location.ts +++ b/packages/driver/src/cy/location.ts @@ -3,7 +3,7 @@ import $utils from '../cypress/utils' // eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces export const create = (state) => ({ - getRemoteLocation (key, win) { + getRemoteLocation (key?: string | undefined, win?: Window) { try { const remoteUrl = $utils.locToString(win ?? state('window')) const location = $Location.create(remoteUrl) @@ -15,7 +15,7 @@ export const create = (state) => ({ return location } catch (e) { // it is possible we do not have access to the location - // for example, if the app has redirected to a 2nd domain + // for example, if the app has redirected to a different origin return '' } }, diff --git a/packages/driver/src/cy/logGroup.ts b/packages/driver/src/cy/logGroup.ts index 5444e352c4c1..908ea08dad12 100644 --- a/packages/driver/src/cy/logGroup.ts +++ b/packages/driver/src/cy/logGroup.ts @@ -1,3 +1,4 @@ +import _ from 'lodash' import { $Command } from '../cypress/command' import $errUtils from '../cypress/error_utils' diff --git a/packages/driver/src/cy/multi-domain/index.ts b/packages/driver/src/cy/multi-domain/index.ts new file mode 100644 index 000000000000..70ce6fd5df35 --- /dev/null +++ b/packages/driver/src/cy/multi-domain/index.ts @@ -0,0 +1,279 @@ +import Bluebird from 'bluebird' +import $errUtils from '../../cypress/error_utils' +import $stackUtils from '../../cypress/stack_utils' +import { Validator } from './validator' +import { createUnserializableSubjectProxy } from './unserializable_subject_proxy' +import { serializeRunnable } from './util' +import { preprocessConfig, preprocessEnv, syncConfigToCurrentOrigin, syncEnvToCurrentOrigin } from '../../util/config' +import { $Location } from '../../cypress/location' +import { LogUtils } from '../../cypress/log' +import logGroup from '../logGroup' + +const reHttp = /^https?:\/\// + +const normalizeOrigin = (urlOrDomain) => { + let origin = urlOrDomain + + // If just a domain, convert it to an origin by adding the protocol + if (!reHttp.test(urlOrDomain)) { + origin = `https://${urlOrDomain}` + } + + return $Location.normalize(origin) +} + +export function addCommands (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: Cypress.State, config: Cypress.InternalConfig) { + let timeoutId + + const communicator = Cypress.primaryOriginCommunicator + + communicator.on('delaying:html', (request) => { + // when a cross origin request is detected by the proxy, it holds it up + // to provide time for the spec bridge to be set up. normally, the queue + // will not continue until the page is stable, but this signals it to go + // ahead because we're anticipating a cross origin request + cy.isAnticipatingCrossOriginResponseFor(request) + const location = $Location.create(request.href) + + // If this event has occurred while a cy.origin command is running with + // the same origin policy, do not set the time out and allow cy.origin + // to handle the ready for origin event + if (cy.state('currentActiveOriginPolicy') === location.originPolicy) { + return + } + + // If we haven't seen a cy.origin and cleared the timeout within 300ms, + // go ahead and inform the server to release the response. + // This typically happens during a redirect where the user does + // not have a cy.origin for the intermediary origin. + timeoutId = setTimeout(() => { + Cypress.backend('cross:origin:release:html') + }, 300) + }) + + Commands.addAll({ + origin (urlOrDomain: string, optionsOrFn: { args: T } | (() => {}), fn?: (args?: T) => {}) { + const userInvocationStack = state('current').get('userInvocationStack') + + // store the invocation stack in the case that `cy.origin` errors + communicator.userInvocationStack = userInvocationStack + + clearTimeout(timeoutId) + // this command runs for as long as the commands in the secondary + // origin run, so it can't have its own timeout + cy.clearTimeout() + + if (!config('experimentalSessionAndOrigin')) { + $errUtils.throwErrByPath('origin.experiment_not_enabled') + } + + let options + let callbackFn + + if (fn) { + callbackFn = fn + options = optionsOrFn + } else { + callbackFn = optionsOrFn + options = { + args: undefined, + } + } + + let log + + logGroup(Cypress, { + name: 'origin', + type: 'parent', + message: urlOrDomain, + // @ts-ignore TODO: revisit once log-grouping has more implementations + }, (_log) => { + log = _log + }) + + const validator = new Validator({ + log, + onFailure: () => { + Cypress.backend('cross:origin:release:html') + }, + }) + + validator.validate({ + callbackFn, + options, + urlOrDomain, + }) + + // use URL to ensure unicode characters are correctly handled + const url = new URL(normalizeOrigin(urlOrDomain)).toString() + const location = $Location.create(url) + + validator.validateLocation(location, urlOrDomain) + + const originPolicy = location.originPolicy + + // This is intentionally not reset after leaving the cy.origin command. + cy.state('latestActiveOriginPolicy', originPolicy) + // This is set while IN the cy.origin command. + cy.state('currentActiveOriginPolicy', originPolicy) + + return new Bluebird((resolve, reject, onCancel) => { + const cleanup = ({ readyForOriginFailed }: {readyForOriginFailed?: boolean} = {}): void => { + cy.state('currentActiveOriginPolicy', undefined) + if (!readyForOriginFailed) { + Cypress.backend('cross:origin:finished', location.originPolicy) + } + + communicator.off('queue:finished', onQueueFinished) + communicator.off('sync:globals', onSyncGlobals) + } + + onCancel && onCancel(() => { + cleanup() + }) + + const _resolve = ({ subject, unserializableSubjectType }) => { + cleanup() + resolve(unserializableSubjectType ? createUnserializableSubjectProxy(unserializableSubjectType) : subject) + } + + const _reject = (err, cleanupOptions: {readyForOriginFailed?: boolean} = {}) => { + cleanup(cleanupOptions) + log?.error(err) + reject(err) + } + + const onQueueFinished = ({ err, subject, unserializableSubjectType }) => { + if (err) { + return _reject(err) + } + + _resolve({ subject, unserializableSubjectType }) + } + + const onSyncGlobals = ({ config, env }) => { + syncConfigToCurrentOrigin(config) + syncEnvToCurrentOrigin(env) + } + + communicator.once('sync:globals', onSyncGlobals) + + communicator.once('ran:origin:fn', (details) => { + const { subject, unserializableSubjectType, err, finished } = details + + // lets the proxy know to allow the response for the secondary + // origin html through, so the page will finish loading + Cypress.backend('cross:origin:release:html') + + if (err) { + if (err?.name === 'ReferenceError') { + const wrappedErr = $errUtils.errByPath('origin.ran_origin_fn_reference_error', { + error: err.message, + }) + + wrappedErr.name = err.name + wrappedErr.stack = $stackUtils.replacedStack(wrappedErr, err.stack) + + // Prevent cypress from trying to add the function to the error log + wrappedErr.onFail = () => {} + + return _reject(wrappedErr) + } + + return _reject(err) + } + + // if there are not commands and a synchronous return from the callback, + // this resolves immediately + if (finished || subject || unserializableSubjectType) { + _resolve({ subject, unserializableSubjectType }) + } + }) + + communicator.once('queue:finished', onQueueFinished) + + // We don't unbind this even after queue:finished, because an async + // error could be thrown after the queue is done, but make sure not + // to stack up listeners on it after it's originally bound + if (!communicator.listeners('uncaught:error').length) { + communicator.once('uncaught:error', ({ err }) => { + cy.fail(err, { async: true }) + }) + } + + // fired once the spec bridge is set up and ready to receive messages + communicator.once('bridge:ready', async (_data, specBridgeOriginPolicy) => { + if (specBridgeOriginPolicy === originPolicy) { + // now that the spec bridge is ready, instantiate Cypress with the current app config and environment variables for initial sync when creating the instance + communicator.toSpecBridge(originPolicy, 'initialize:cypress', { + config: preprocessConfig(Cypress.config()), + env: preprocessEnv(Cypress.env()), + }) + + await Cypress.backend('cross:origin:bridge:ready', { originPolicy }) + + // once the secondary origin page loads, send along the + // user-specified callback to run in that origin + try { + communicator.toSpecBridge(originPolicy, 'run:origin:fn', { + args: options?.args || undefined, + fn: callbackFn.toString(), + // let the spec bridge version of Cypress know if config read-only values can be overwritten since window.top cannot be accessed in cross-origin iframes + // this should only be used for internal testing. Cast to boolean to guarantee serialization + // @ts-ignore + skipConfigValidation: !!window.top.__cySkipValidateConfig, + state: { + viewportWidth: Cypress.state('viewportWidth'), + viewportHeight: Cypress.state('viewportHeight'), + runnable: serializeRunnable(Cypress.state('runnable')), + duringUserTestExecution: Cypress.state('duringUserTestExecution'), + hookId: Cypress.state('hookId'), + originCommandBaseUrl: location.href, + parentOriginPolicies: [cy.getRemoteLocation('originPolicy')], + isStable: Cypress.state('isStable'), + autOrigin: Cypress.state('autOrigin'), + }, + config: preprocessConfig(Cypress.config()), + env: preprocessEnv(Cypress.env()), + logCounter: LogUtils.getCounter(), + }) + } catch (err: any) { + // Release the request if 'run:origin:fn' fails + Cypress.backend('cross:origin:release:html') + + const wrappedErr = $errUtils.errByPath('origin.run_origin_fn_errored', { + error: err.message, + }) + + wrappedErr.name = err.name + + const stack = $stackUtils.replacedStack(wrappedErr, userInvocationStack) + + // add the actual stack, since it might be useful for debugging + // the failure + wrappedErr.stack = $stackUtils.stackWithContentAppended({ + appendToStack: { + title: 'From Cypress Internals', + content: $stackUtils.stackWithoutMessage(err.stack), + }, + }, stack) + + // @ts-ignore - This keeps Bluebird from messing with the stack. + // It tries to add a bunch of stuff that's not useful and ends up + // messing up the stack that we want on the error + wrappedErr.__stackCleaned__ = true + + // Prevent cypress from trying to add the function to the error log + wrappedErr.onFail = () => {} + + _reject(wrappedErr, { readyForOriginFailed: true }) + } + } + }) + + // this signals to the runner to create the spec bridge for the specified origin policy + communicator.emit('expect:origin', location) + }) + }, + }) +} diff --git a/packages/driver/src/cy/multi-domain/unserializable_subject_proxy.ts b/packages/driver/src/cy/multi-domain/unserializable_subject_proxy.ts new file mode 100644 index 000000000000..54afed13111b --- /dev/null +++ b/packages/driver/src/cy/multi-domain/unserializable_subject_proxy.ts @@ -0,0 +1,64 @@ +import $errUtils from '../../cypress/error_utils' + +// These properties are required to avoid failing prior to attempting to use the subject. +// If Symbol.toStringTag is passed through to the target we will not properly fail the 'cy.invoke' command. +const passThroughProps = [ + 'then', + Symbol.isConcatSpreadable, + 'jquery', + 'nodeType', + 'window', + 'document', + 'inspect', + 'isSinonProxy', + '_spreadArray', + 'selector', +] + +/** + * Create a proxy object to fail when accessed or called. + * @param type The type of operand that failed to serialize + * @returns A proxy object that will fail when accessed. + */ +export const createUnserializableSubjectProxy = (type: string) => { + let target = {} + + // If the failed subject is a function, use a function as the target. + if (type === 'function') { + target = () => {} + } + + // Symbol note: The target can't be a symbol, but we can use an object until the symbol is accessed, then provide a different error. + + return new Proxy(target, { + /** + * Throw an error if the proxy is called like a function. + * @param target the proxy target + * @param thisArg this + * @param argumentsList args passed. + */ + apply () { + $errUtils.throwErrByPath('origin.failed_to_serialize_function') + }, + + /** + * Throw an error if any properties besides the listed ones are accessed. + * @param target The proxy target + * @param prop The property being accessed + * @param receiver Either the proxy or an object that inherits from the proxy. + * @returns either an error or the result of the allowed get on the target. + */ + get (target, prop) { + if (passThroughProps.includes(prop)) { + return target[prop] + } + + // Provide a slightly different message if the object was meant to be a symbol. + if (type === 'symbol') { + $errUtils.throwErrByPath('origin.failed_to_serialize_symbol') + } else { + $errUtils.throwErrByPath('origin.failed_to_serialize_object') + } + }, + }) +} diff --git a/packages/driver/src/cy/multi-domain/util.ts b/packages/driver/src/cy/multi-domain/util.ts new file mode 100644 index 000000000000..bbb042adb649 --- /dev/null +++ b/packages/driver/src/cy/multi-domain/util.ts @@ -0,0 +1,19 @@ +import _ from 'lodash' + +export const serializeRunnable = (runnable) => { + if (!runnable) return undefined + + const fields = _.pick(runnable, ['id', 'type', 'title', 'parent', 'ctx', 'titlePath', '_timeout']) + + fields.ctx = _.pick(runnable.ctx, ['currentTest.id', 'currentTest._currentRetry', 'currentTest.type', 'currentTest.title']) + + // recursively call serializeRunnable for the parent field + if (fields.parent) { + fields.titlePath = fields.titlePath() + fields.parent = serializeRunnable(fields.parent) + } else { + fields.titlePath = undefined + } + + return fields +} diff --git a/packages/driver/src/cy/multi-domain/validator.ts b/packages/driver/src/cy/multi-domain/validator.ts new file mode 100644 index 000000000000..f1e3968f09a1 --- /dev/null +++ b/packages/driver/src/cy/multi-domain/validator.ts @@ -0,0 +1,72 @@ +import $utils from '../../cypress/utils' +import $errUtils from '../../cypress/error_utils' +import { difference, isPlainObject, isString } from 'lodash' + +const validOptionKeys = Object.freeze(['args']) + +export class Validator { + log: Cypress.Log + onFailure: () => {} + + constructor ({ log, onFailure }) { + this.log = log + this.onFailure = onFailure + } + + validate ({ callbackFn, options, urlOrDomain }) { + if (!isString(urlOrDomain)) { + this.onFailure() + + $errUtils.throwErrByPath('origin.invalid_url_argument', { + onFail: this.log, + args: { arg: $utils.stringify(urlOrDomain) }, + }) + } + + if (options) { + if (!isPlainObject(options)) { + this.onFailure() + + $errUtils.throwErrByPath('origin.invalid_options_argument', { + onFail: this.log, + args: { arg: $utils.stringify(options) }, + }) + } + + const extraneousKeys = difference(Object.keys(options), validOptionKeys) + + if (extraneousKeys.length) { + this.onFailure() + + $errUtils.throwErrByPath('origin.extraneous_options_argument', { + onFail: this.log, + args: { + extraneousKeys: extraneousKeys.join(', '), + validOptionKeys: validOptionKeys.join(', '), + }, + }) + } + } + + if (typeof callbackFn !== 'function') { + this.onFailure() + + $errUtils.throwErrByPath('origin.invalid_fn_argument', { + onFail: this.log, + args: { arg: $utils.stringify(callbackFn) }, + }) + } + } + + validateLocation (location, urlOrDomain) { + // we don't support query params + if (location.search.length > 0) { + this.onFailure() + + $errUtils.throwErrByPath('origin.invalid_url_argument', { + onFail: this.log, + args: { arg: $utils.stringify(urlOrDomain) }, + }) + } + } +} diff --git a/packages/driver/src/cy/overrides.ts b/packages/driver/src/cy/overrides.ts new file mode 100644 index 000000000000..20e1c4a56742 --- /dev/null +++ b/packages/driver/src/cy/overrides.ts @@ -0,0 +1,70 @@ +import _ from 'lodash' +// @ts-ignore +import { registerFetch } from 'unfetch' +import $selection from '../dom/selection' + +export const create = (state, config, focused, snapshots) => { + const wrapNativeMethods = function (contentWindow) { + try { + // return null to trick contentWindow into thinking + // its not been iframed if modifyObstructiveCode is true + if (config('modifyObstructiveCode')) { + Object.defineProperty(contentWindow, 'frameElement', { + get () { + return null + }, + }) + } + + contentWindow.HTMLElement.prototype.focus = function (focusOption) { + return focused.interceptFocus(this, contentWindow, focusOption) + } + + contentWindow.HTMLElement.prototype.blur = function () { + return focused.interceptBlur(this) + } + + contentWindow.SVGElement.prototype.focus = function (focusOption) { + return focused.interceptFocus(this, contentWindow, focusOption) + } + + contentWindow.SVGElement.prototype.blur = function () { + return focused.interceptBlur(this) + } + + contentWindow.HTMLInputElement.prototype.select = function () { + return $selection.interceptSelect.call(this) + } + + contentWindow.document.hasFocus = function () { + return focused.documentHasFocus.call(this) + } + + const cssModificationSpy = function (original, ...args) { + snapshots.onCssModified(this.href) + + return original.apply(this, args) + } + + const { insertRule } = contentWindow.CSSStyleSheet.prototype + const { deleteRule } = contentWindow.CSSStyleSheet.prototype + + contentWindow.CSSStyleSheet.prototype.insertRule = _.wrap(insertRule, cssModificationSpy) + contentWindow.CSSStyleSheet.prototype.deleteRule = _.wrap(deleteRule, cssModificationSpy) + + if (config('experimentalFetchPolyfill')) { + // drop "fetch" polyfill that replaces it with XMLHttpRequest + // from the app iframe that we wrap for network stubbing + contentWindow.fetch = registerFetch(contentWindow) + // flag the polyfill to test this experimental feature easier + state('fetchPolyfilled', true) + } + } catch (error) {} // eslint-disable-line no-empty + } + + return { + wrapNativeMethods, + } +} + +export interface IOverrides extends ReturnType {} diff --git a/packages/driver/src/cy/retries.ts b/packages/driver/src/cy/retries.ts index 0bb0e431bb6d..c1f23d9a2afc 100644 --- a/packages/driver/src/cy/retries.ts +++ b/packages/driver/src/cy/retries.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import Promise from 'bluebird' import $errUtils from '../cypress/error_utils' +import * as cors from '@packages/network/lib/cors' const { errByPath, modifyErrMsg, throwErr, mergeErrProps } = $errUtils @@ -73,6 +74,18 @@ export const create = (Cypress, state, timeout, clearTimeout, whenStable, finish }).message const retryErrProps = modifyErrMsg(error, prependMsg, (msg1, msg2) => { + const autOrigin = Cypress.state('autOrigin') + const commandOrigin = window.location.origin + + if (autOrigin && !cors.urlOriginsMatch(commandOrigin, autOrigin)) { + const appendMsg = errByPath('miscellaneous.cross_origin_command', { + commandOrigin, + autOrigin, + }).message + + return `${msg2}${msg1}\n\n${appendMsg}` + } + return `${msg2}${msg1}` }) diff --git a/packages/driver/src/cy/snapshots.ts b/packages/driver/src/cy/snapshots.ts index c0cc082da39e..9d0a05328101 100644 --- a/packages/driver/src/cy/snapshots.ts +++ b/packages/driver/src/cy/snapshots.ts @@ -5,6 +5,8 @@ import { create as createSnapshotsCSS } from './snapshots_css' export const HIGHLIGHT_ATTR = 'data-cypress-el' +export const FINAL_SNAPSHOT_NAME = 'final state' + export const create = ($$, state) => { const snapshotsCss = createSnapshotsCSS($$, state) const snapshotsMap = new WeakMap() @@ -99,15 +101,20 @@ export const create = ($$, state) => { } const getStyles = (snapshot) => { - const styleIds = snapshotsMap.get(snapshot) + const { ids, styles } = snapshotsMap.get(snapshot) || {} - if (!styleIds) { + if (!ids && !styles) { return {} } + // If a cross origin processed snapshot, styles are directly added into the CSS map. Simply return them. + if (styles?.headStyles || styles?.bodyStyles) { + return styles + } + return { - headStyles: snapshotsCss.getStylesByIds(styleIds.headStyleIds), - bodyStyles: snapshotsCss.getStylesByIds(styleIds.bodyStyleIds), + headStyles: snapshotsCss.getStylesByIds(ids?.headStyleIds), + bodyStyles: snapshotsCss.getStylesByIds(ids?.bodyStyleIds), } } @@ -119,20 +126,24 @@ export const create = ($$, state) => { $body.find('script,link[rel="stylesheet"],style').remove() const snapshot = { - name: 'final state', + name: FINAL_SNAPSHOT_NAME, htmlAttrs, body: { get: () => $body.detach(), }, } - snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds }) + snapshotsMap.set(snapshot, { + ids: { + headStyleIds, + bodyStyleIds, + }, + }) return snapshot } - const createSnapshot = (name, $elToHighlight) => { - Cypress.action('cy:snapshot', name) + const createSnapshotBody = ($elToHighlight) => { // create a unique selector for this el // but only IF the subject is truly an element. For example // we might be wrapping a primitive like "$([1, 2]).first()" @@ -141,64 +152,89 @@ export const create = ($$, state) => { // jQuery v3 runs in strict mode and throws an error if you attempt to set a property // TODO: in firefox sometimes this throws a cross-origin access error - try { - const isJqueryElement = $dom.isElement($elToHighlight) && $dom.isJquery($elToHighlight) + const isJqueryElement = $dom.isElement($elToHighlight) && $dom.isJquery($elToHighlight) - if (isJqueryElement) { - ($elToHighlight as JQuery).attr(HIGHLIGHT_ATTR, 'true') - } + if (isJqueryElement) { + ($elToHighlight as JQuery).attr(HIGHLIGHT_ATTR, 'true') + } - // TODO: throw error here if cy is undefined! - - // cloneNode can actually trigger functions attached to custom elements - // so we have to use importNode to clone the element - // https://github.com/cypress-io/cypress/issues/7187 - // https://github.com/cypress-io/cypress/issues/1068 - // we import it to a transient document (snapshotDocument) so that there - // are no side effects from cloning it. see below for how we re-attach - // it to the AUT document - // https://github.com/cypress-io/cypress/issues/8679 - // this can fail if snapshotting before the page has fully loaded, - // so we catch this below and return null for the snapshot - // https://github.com/cypress-io/cypress/issues/15816 - const $body = $$(snapshotDocument.importNode($$('body')[0], true)) - - // for the head and body, get an array of all CSS, - // whether it's links or style tags - // if it's same-origin, it will get the actual styles as a string - // it it's cross-domain, it will get a reference to the link's href - const { headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds() - - // replaces iframes with placeholders - replaceIframes($body) - - // remove tags we don't want in body - $body.find('script,link[rel=\'stylesheet\'],style').remove() - - // here we need to figure out if we're in a remote manual environment - // if so we need to stringify the DOM: - // 1. grab all inputs / textareas / options and set their value on the element - // 2. convert DOM to string: body.prop("outerHTML") - // 3. send this string via websocket to our server - // 4. server rebroadcasts this to our client and its stored as a property - - // its also possible for us to store the DOM string completely on the server - // without ever sending it back to the browser (until its requests). - // we could just store it in memory and wipe it out intelligently. - // this would also prevent having to store the DOM structure on the client, - // which would reduce memory, and some CPU operations - - // now remove it after we clone - if (isJqueryElement) { - ($elToHighlight as JQuery).removeAttr(HIGHLIGHT_ATTR) - } + // TODO: throw error here if cy is undefined! + + // cloneNode can actually trigger functions attached to custom elements + // so we have to use importNode to clone the element + // https://github.com/cypress-io/cypress/issues/7187 + // https://github.com/cypress-io/cypress/issues/1068 + // we import it to a transient document (snapshotDocument) so that there + // are no side effects from cloning it. see below for how we re-attach + // it to the AUT document + // https://github.com/cypress-io/cypress/issues/8679 + // this can fail if snapshotting before the page has fully loaded, + // so we catch this below and return null for the snapshot + // https://github.com/cypress-io/cypress/issues/15816 + const $body = $$(snapshotDocument.importNode($$('body')[0], true)) + // for the head and body, get an array of all CSS, + // whether it's links or style tags + // if it's same-origin, it will get the actual styles as a string + // if it's cross-origin, it will get a reference to the link's href + const { headStyleIds, bodyStyleIds } = snapshotsCss.getStyleIds() + + // replaces iframes with placeholders + replaceIframes($body) + + // remove tags we don't want in body + $body.find('script,link[rel=\'stylesheet\'],style').remove() + + // here we need to figure out if we're in a remote manual environment + // if so we need to stringify the DOM: + // 1. grab all inputs / textareas / options and set their value on the element + // 2. convert DOM to string: body.prop("outerHTML") + // 3. send this string via websocket to our server + // 4. server rebroadcasts this to our client and its stored as a property + + // its also possible for us to store the DOM string completely on the server + // without ever sending it back to the browser (until its requests). + // we could just store it in memory and wipe it out intelligently. + // this would also prevent having to store the DOM structure on the client, + // which would reduce memory, and some CPU operations + + // now remove it after we clone + if (isJqueryElement) { + ($elToHighlight as JQuery).removeAttr(HIGHLIGHT_ATTR) + } + + const $htmlAttrs = getHtmlAttrs($$('html')[0]) + + return { + $body, + $htmlAttrs, + headStyleIds, + bodyStyleIds, + } + } + + const reifySnapshotBody = (preprocessedSnapshot) => { + const $body = preprocessedSnapshot.body.get() + const $htmlAttrs = preprocessedSnapshot.htmlAttrs + const { headStyles, bodyStyles } = preprocessedSnapshot.styles + + return { + $body, + $htmlAttrs, + headStyles, + bodyStyles, + } + } + + const createSnapshot = (name, $elToHighlight, preprocessedSnapshot) => { + Cypress.action('cy:snapshot', name) + + try { + const { + $body, + $htmlAttrs, + ...styleAttrs + } = preprocessedSnapshot ? reifySnapshotBody(preprocessedSnapshot) : createSnapshotBody($elToHighlight) - // preserve attributes on the tag - const htmlAttrs = getHtmlAttrs($$('html')[0]) - // the body we clone via importNode above is attached to a transient document - // so that there are no side effects from cloning it. we only attach it back - // to the AUT document at the last moment (when restoring the snapshot) - // https://github.com/cypress-io/cypress/issues/8679 let attachedBody const body = { get: () => { @@ -212,11 +248,38 @@ export const create = ($$, state) => { const snapshot = { name, - htmlAttrs, + htmlAttrs: $htmlAttrs, body, } - snapshotsMap.set(snapshot, { headStyleIds, bodyStyleIds }) + const { + headStyleIds, + bodyStyleIds, + headStyles, + bodyStyles, + }: { + headStyleIds?: string[] + bodyStyleIds?: string[] + headStyles?: string[] + bodyStyles?: string[] + } = styleAttrs + + if (headStyleIds && bodyStyleIds) { + snapshotsMap.set(snapshot, { + ids: { + headStyleIds, + bodyStyleIds, + }, + }) + } else if (headStyles && bodyStyles) { + // The Snapshot is being reified from cross origin. Get inline styles of reified snapshot. + snapshotsMap.set(snapshot, { + styles: { + headStyles, + bodyStyles, + }, + }) + } return snapshot } catch (e) { diff --git a/packages/driver/src/cy/snapshots_css.ts b/packages/driver/src/cy/snapshots_css.ts index 66312f7ff56c..0115c668cdde 100644 --- a/packages/driver/src/cy/snapshots_css.ts +++ b/packages/driver/src/cy/snapshots_css.ts @@ -59,7 +59,7 @@ const makePathsAbsoluteToStylesheet = $utils.memoize((styles, href) => { }, makePathsAbsoluteToStylesheetCache) const getExternalCssContents = (href, stylesheet) => { - //// some browsers may throw a SecurityError if the stylesheet is cross-domain + //// some browsers may throw a SecurityError if the stylesheet is cross-origin //// https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet#Notes //// for others, it will just be null try { @@ -164,7 +164,7 @@ export const create = ($$, state) => { const href = stylesheet.href //// if there's an href, it's a link tag - //// return the CSS rules as a string, or, if cross-domain, + //// return the CSS rules as a string, or, if cross-origin, //// a reference to the stylesheet's href if (href) { return getStyleId(href, stylesheets[href]) || { href } diff --git a/packages/driver/src/cy/stability.ts b/packages/driver/src/cy/stability.ts index 7e03bd7422a2..217803dacb4e 100644 --- a/packages/driver/src/cy/stability.ts +++ b/packages/driver/src/cy/stability.ts @@ -9,10 +9,7 @@ export const create = (Cypress, state) => ({ const whenStable = state('whenStable') - // if we are going back to stable and we have - // a whenStable callback if (stable && whenStable) { - // invoke it whenStable() } @@ -20,28 +17,83 @@ export const create = (Cypress, state) => ({ // we notify the outside world because this is what the runner uses to // show the 'loading spinner' during an app page loading transition event - return Cypress.action('cy:stability:changed', stable, event) + Cypress.action('cy:stability:changed', stable, event) }, whenStable: (fn: () => any) => { - // if we are not stable - if (state('isStable') === false) { - return new Promise((resolve, reject) => { - // then when we become stable - return state('whenStable', () => { - // reset this callback function - state('whenStable', null) - - // and invoke the original function - return Promise.try(fn) - .then(resolve) - .catch(reject) - }) + if (state('isStable') !== false) { + return Promise.try(fn) + } + + return new Promise((resolve, reject) => { + // then when we become stable + state('whenStable', () => { + // reset this callback function + state('whenStable', null) + + // and invoke the original function + Promise.try(fn) + .then(resolve) + .catch(reject) }) + }) + }, + + isAnticipatingCrossOriginResponseFor (request: {href: string}): void { + if (state('anticipatingCrossOriginResponse') === request) { + return + } + + const whenAnticipatingCrossOriginResponse = state('whenAnticipatingCrossOriginResponse') + + if (!!request?.href && whenAnticipatingCrossOriginResponse) { + whenAnticipatingCrossOriginResponse() + } + + state('anticipatingCrossOriginResponse', request) + }, + + whenStableOrAnticipatingCrossOriginResponse (fn, command) { + const commandIsOrigin = command?.get('name') === 'origin' + const commandIsEndLogGroup = command?.get('name') === 'end-logGroup' + + if ( + // cy.origin() needs to run when unstable (if we're anticipating + // a cross-origin response) in order to allow it to set up a spec bridge + // before the page loads and stability is restored + (!!state('anticipatingCrossOriginResponse') && commandIsOrigin) + // the end-logGroup command is inserted internally to mark the end of + // a command group and needs to be allowed or stability will hang things + // up if chaining cy.origin commands + || commandIsEndLogGroup + || state('isStable') !== false + ) { + return Promise.try(fn) } - // else invoke it right now - return Promise.try(fn) + return new Promise((resolve, reject) => { + let fulfilled = false + + const onSignal = () => { + if (fulfilled) return + + fulfilled = true + + state('whenStable', null) + state('whenAnticipatingCrossOriginResponse', null) + + Promise.try(fn) + .then(resolve) + .catch(reject) + } + + state('whenStable', onSignal) + + // We only care to listen for anticipating cross origin request when the command we're waiting for is origin + if (commandIsOrigin) { + state('whenAnticipatingCrossOriginResponse', onSignal) + } + }) }, }) diff --git a/packages/driver/src/cy/testConfigOverrides.ts b/packages/driver/src/cy/testConfigOverrides.ts index 8b9c23353a83..4b93bb7caf71 100644 --- a/packages/driver/src/cy/testConfigOverrides.ts +++ b/packages/driver/src/cy/testConfigOverrides.ts @@ -143,7 +143,9 @@ export class TestConfigOverride { restoreAndSetTestConfigOverrides (test, config, env) { if (this.restoreTestConfigFn) this.restoreTestConfigFn() - const resolvedTestConfig = test._testConfig || {} + const resolvedTestConfig = test._testConfig || { + unverifiedTestConfig: [], + } if (Object.keys(resolvedTestConfig.unverifiedTestConfig).length > 0) { this.restoreTestConfigFn = mutateConfiguration(resolvedTestConfig, config, env) diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 19b3f05fa97b..127d80461c47 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -37,6 +37,7 @@ import ProxyLogging from './cypress/proxy-logging' import * as $Events from './cypress/events' import $Keyboard from './cy/keyboard' import * as resolvers from './cypress/resolvers' +import { PrimaryOriginCommunicator, SpecBridgeCommunicator } from './multi-domain/communicator' const debug = debugFn('cypress:driver:cypress') @@ -104,6 +105,9 @@ class $Cypress { emit: any emitThen: any emitMap: any + primaryOriginCommunicator: PrimaryOriginCommunicator + specBridgeCommunicator: SpecBridgeCommunicator + isCrossOriginSpecBridge: boolean // attach to $Cypress to access // all of the constructors @@ -141,7 +145,7 @@ class $Cypress { static $: any static utils: any - constructor (config = {}) { + constructor () { this.cy = null this.chai = null this.mocha = null @@ -150,43 +154,21 @@ class $Cypress { this.Commands = null this.$autIframe = null this.onSpecReady = null + this.primaryOriginCommunicator = new PrimaryOriginCommunicator() + this.specBridgeCommunicator = new SpecBridgeCommunicator() + this.isCrossOriginSpecBridge = false this.events = $Events.extend(this) this.$ = jqueryProxyFn.bind(this) _.extend(this.$, $) - - this.setConfig(config) } - setConfig (config: Record = {}) { - // config.remote - // { - // origin: "http://localhost:2020" - // domainName: "localhost" - // props: null - // strategy: "file" - // } - - // -- or -- - - // { - // origin: "https://foo.google.com" - // domainName: "google.com" - // strategy: "http" - // props: { - // port: 443 - // tld: "com" - // domain: "google" - // } - // } - - let d = config.remote ? config.remote.domainName : undefined - - // set domainName but allow us to turn - // off this feature in testing - if (d) { - document.domain = d + configure (config: Cypress.ObjectLike = {}) { + const domainName = config.remote ? config.remote.domainName : undefined + + if (domainName) { + document.domain = domainName } // a few static props for the host OS, browser @@ -207,6 +189,9 @@ class $Cypress { // slice up the behavior config.isInteractive = !config.isTextTerminal + // true if this Cypress belongs to a cross origin spec bridge + this.isCrossOriginSpecBridge = config.isCrossOriginSpecBridge || false + // enable long stack traces when // we not are running headlessly // for debuggability but disable @@ -221,14 +206,14 @@ class $Cypress { // change this in the NEXT_BREAKING const { env } = config - config = _.omit(config, 'env', 'remote', 'resolved', 'scaffoldedFiles', 'state', 'testingType') + config = _.omit(config, 'env', 'remote', 'resolved', 'scaffoldedFiles', 'state', 'testingType', 'isCrossOriginSpecBridge') _.extend(this, browserInfo(config)) this.state = $SetterGetter.create({}) this.originalConfig = _.cloneDeep(config) this.config = $SetterGetter.create(config, (config) => { - if (!window.top!.__cySkipValidateConfig) { + if (this.isCrossOriginSpecBridge ? !window.__cySkipValidateConfig : !window.top!.__cySkipValidateConfig) { validateNoReadOnlyConfig(config, (errProperty) => { const errPath = this.state('runnable') ? 'config.invalid_cypress_config_override' @@ -273,7 +258,7 @@ class $Cypress { return null } - this.Cookies = $Cookies.create(config.namespace, d) + this.Cookies = $Cookies.create(config.namespace, domainName) // TODO: Remove this after $Events functions are added to $Cypress. // @ts-ignore @@ -588,6 +573,9 @@ class $Cypress { case 'cy:command:end': return this.emit('command:end', ...args) + case 'cy:skipped:command:end': + return this.emit('skipped:command:end', ...args) + case 'cy:command:retry': return this.emit('command:retry', ...args) @@ -600,6 +588,9 @@ class $Cypress { case 'cy:command:queue:end': return this.emit('command:queue:end') + case 'cy:enqueue:command': + return this.emit('enqueue:command', ...args) + case 'cy:url:changed': return this.emit('url:changed', args[0]) @@ -642,6 +633,11 @@ class $Cypress { return this.emit('form:submitted', args[0]) case 'app:window:load': + this.emit('internal:window:load', { + type: 'same:origin', + window: args[0], + }) + return this.emit('window:load', args[0]) case 'app:window:before:unload': @@ -756,7 +752,11 @@ class $Cypress { } static create (config) { - return new $Cypress(config) + const cypress = new $Cypress() + + cypress.configure(config) + + return cypress } } diff --git a/packages/driver/src/cypress/chainer.ts b/packages/driver/src/cypress/chainer.ts index c81ed819d00e..3fe45e41fc33 100644 --- a/packages/driver/src/cypress/chainer.ts +++ b/packages/driver/src/cypress/chainer.ts @@ -11,7 +11,10 @@ export class $Chainer { constructor (userInvocationStack, specWindow) { this.userInvocationStack = userInvocationStack this.specWindow = specWindow - this.chainerId = _.uniqueId('chainer') + // the id prefix needs to be unique per origin, so there are not + // collisions when chainers created in a secondary origin are passed + // to the primary origin for the command log, etc. + this.chainerId = _.uniqueId(`ch-${window.location.origin}-`) this.firstCall = true this.useInitialStack = null } diff --git a/packages/driver/src/cypress/command.ts b/packages/driver/src/cypress/command.ts index 12b824d53823..2aad61f04e8e 100644 --- a/packages/driver/src/cypress/command.ts +++ b/packages/driver/src/cypress/command.ts @@ -6,10 +6,18 @@ export class $Command { // @ts-ignore attributes: Record - constructor (obj = {}) { + constructor (attrs: any = {}) { this.reset() - this.set(obj) + // if the command came from a secondary origin, it already has an id + if (!attrs.id) { + // the id prefix needs to be unique per origin, so there are not + // collisions when commands created in a secondary origin are passed + // to the primary origin for the command log, etc. + attrs.id = _.uniqueId(`cmd-${window.location.origin}-`) + } + + this.set(attrs) } set (key, val?) { diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index d9321d35de45..ea9b091e295a 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -63,16 +63,16 @@ const commandRunningFailed = (Cypress, state, err) => { export class CommandQueue extends Queue { state: any timeout: any - whenStable: any + stability: any cleanup: any fail: any isCy: any - constructor (state, timeout, whenStable, cleanup, fail, isCy) { + constructor (state, timeout, stability, cleanup, fail, isCy) { super() this.state = state this.timeout = timeout - this.whenStable = whenStable + this.stability = stability this.cleanup = cleanup this.fail = fail this.isCy = isCy @@ -123,6 +123,14 @@ export class CommandQueue extends Queue { }) } + /** + * Check if the current command index is the last command in the queue + * @returns boolean + */ + isOnLastCommand (): boolean { + return this.state('index') === this.length + } + private runCommand (command: Command) { // bail here prior to creating a new promise // because we could have stopped / canceled @@ -135,11 +143,11 @@ export class CommandQueue extends Queue { this.state('current', command) this.state('chainerId', command.get('chainerId')) - return this.whenStable(() => { + return this.stability.whenStableOrAnticipatingCrossOriginResponse(() => { this.state('nestedIndex', this.state('index')) return command.get('args') - }) + }, command) .then((args) => { // store this if we enqueue new commands // to check for promise violations @@ -197,7 +205,7 @@ export class CommandQueue extends Queue { // if we got a return value and we enqueued // a new command and we didn't return cy // or an undefined value then throw - return $errUtils.throwErrByPath( + $errUtils.throwErrByPath( 'miscellaneous.returned_value_and_commands_from_custom_command', { args: { current: command.get('name'), @@ -260,14 +268,12 @@ export class CommandQueue extends Queue { return } - // start at 0 index if we dont have one + // start at 0 index if one is not already set let index = this.state('index') || this.state('index', 0) const command = this.at(index) - // if the command should be skipped - // just bail and increment index - // and set the subject + // if the command should be skipped, just bail and increment index if (command && command.get('skip')) { // must set prev + next since other // operations depend on this state being correct @@ -279,6 +285,8 @@ export class CommandQueue extends Queue { this.state('index', index + 1) this.state('subject', command.get('subject')) + Cypress.action('cy:skipped:command:end', command) + return next() } @@ -287,11 +295,18 @@ export class CommandQueue extends Queue { // trigger queue is almost finished Cypress.action('cy:command:queue:before:end') + // If we're enabled experimentalSessionAndOrigin we no longer have to wait for stability at the end of the command queue. + if (Cypress.config('experimentalSessionAndOrigin')) { + Cypress.action('cy:command:queue:end') + + return null + } + // we need to wait after all commands have // finished running if the application under // test is no longer stable because we cannot // move onto the next test until its finished - return this.whenStable(() => { + return this.stability.whenStableOrAnticipatingCrossOriginResponse(() => { Cypress.action('cy:command:queue:end') return null @@ -301,6 +316,12 @@ export class CommandQueue extends Queue { // store the previous timeout const prevTimeout = this.timeout() + // If we have created a timeout but are in an unstable state, clear the + // timeout in favor of the on load timeout already running. + if (!cy.state('isStable')) { + cy.clearTimeout() + } + // store the current runnable const runnable = this.state('runnable') diff --git a/packages/driver/src/cypress/commands.ts b/packages/driver/src/cypress/commands.ts index a14cc7a0b4f5..27b0b7d6d913 100644 --- a/packages/driver/src/cypress/commands.ts +++ b/packages/driver/src/cypress/commands.ts @@ -1,6 +1,7 @@ import _ from 'lodash' import { allCommands } from '../cy/commands' -import { addCommand } from '../cy/net-stubbing' +import { addCommand as addNetstubbingCommand } from '../cy/net-stubbing' +import { addCommands as addCrossOriginCommands } from '../cy/multi-domain' import $errUtils from './error_utils' import $stackUtils from './stack_utils' @@ -8,7 +9,8 @@ const builtInCommands = [ // `default` is necessary if a file uses `export default` syntax. // @ts-ignore ..._.toArray(allCommands).map((c) => c.default || c), - addCommand, + addNetstubbingCommand, + addCrossOriginCommands, ] const getTypeByPrevSubject = (prevSubject) => { diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index ee7037479d52..a0f3a9e5ab6d 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -2,7 +2,6 @@ import _ from 'lodash' import Promise from 'bluebird' import debugFn from 'debug' -import { registerFetch } from 'unfetch' import $dom from '../dom' import $utils from './utils' @@ -26,12 +25,12 @@ import { create as createTimer, ITimer } from '../cy/timers' import { create as createTimeouts, ITimeouts } from '../cy/timeouts' import { create as createRetries, IRetries } from '../cy/retries' import { create as createStability, IStability } from '../cy/stability' -import $selection from '../dom/selection' import { create as createSnapshots, ISnapshots } from '../cy/snapshots' import { $Command } from './command' import { CommandQueue } from './command_queue' import { initVideoRecorder } from '../cy/video-recorder' import { TestConfigOverride } from '../cy/testConfigOverrides' +import { create as createOverrides, IOverrides } from '../cy/overrides' import { historyNavigationTriggeredHashChange } from '../cy/navigation' import { EventEmitter2 } from 'eventemitter2' @@ -69,9 +68,13 @@ const setTopOnError = function (Cypress, cy: $Cy) { curCy = cy - // prevent overriding top.onerror twice when loading more than one - // instance of test runner. - if (top.__alreadySetErrorHandlers__) { + try { + // prevent overriding top.onerror twice when loading more than one + // instance of test runner. + if (top.__alreadySetErrorHandlers__) { + return + } + } catch (err) { return } @@ -122,6 +125,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert config: any Cypress: any Cookies: any + devices: { keyboard: Keyboard mouse: Mouse @@ -133,12 +137,15 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert isStable: IStability['isStable'] whenStable: IStability['whenStable'] + isAnticipatingCrossOriginResponseFor: IStability['isAnticipatingCrossOriginResponseFor'] + whenStableOrAnticipatingCrossOriginResponse: IStability['whenStableOrAnticipatingCrossOriginResponse'] assert: IAssertions['assert'] verifyUpcomingAssertions: IAssertions['verifyUpcomingAssertions'] retry: IRetries['retry'] + $$: IJQuery['$$'] getRemotejQueryInstance: IJQuery['getRemotejQueryInstance'] getRemoteLocation: ILocation['getRemoteLocation'] @@ -195,6 +202,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert documentHasFocus: ReturnType['documentHasFocus'] interceptFocus: ReturnType['interceptFocus'] interceptBlur: ReturnType['interceptBlur'] + overrides: IOverrides private testConfigOverride: TestConfigOverride private commandFns: Record = {} @@ -215,7 +223,6 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.testConfigOverride = new TestConfigOverride() // bind methods - this.$$ = this.$$.bind(this) this.isCy = this.isCy.bind(this) this.fail = this.fail.bind(this) this.isStopped = this.isStopped.bind(this) @@ -238,10 +245,12 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.timeout = timeouts.timeout this.clearTimeout = timeouts.clearTimeout - const statility = createStability(Cypress, state) + const stability = createStability(Cypress, state) - this.isStable = statility.isStable - this.whenStable = statility.whenStable + this.isStable = stability.isStable + this.whenStable = stability.whenStable + this.isAnticipatingCrossOriginResponseFor = stability.isAnticipatingCrossOriginResponseFor + this.whenStableOrAnticipatingCrossOriginResponse = stability.whenStableOrAnticipatingCrossOriginResponse const assertions = createAssertions(Cypress, this) @@ -258,6 +267,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert const jquery = createJQuery(state) + this.$$ = jquery.$$ this.getRemotejQueryInstance = jquery.getRemotejQueryInstance const location = createLocation(state) @@ -336,7 +346,9 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.onCssModified = snapshots.onCssModified this.onBeforeWindowLoad = snapshots.onBeforeWindowLoad - this.queue = new CommandQueue(state, this.timeout, this.whenStable, this.cleanup, this.fail, this.isCy) + this.overrides = createOverrides(state, config, focused, snapshots) + + this.queue = new CommandQueue(state, this.timeout, stability, this.cleanup, this.fail, this.isCy) setTopOnError(Cypress, this) @@ -344,14 +356,10 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert specWindow.cy = this extendEvents(this) - } - - $$ (selector, context) { - if (context == null) { - context = this.state('document') - } - return $dom.query(selector, context) + Cypress.on('enqueue:command', (attrs: Cypress.EnqueuedCommand) => { + this.enqueue(attrs) + }) } isCy (val) { @@ -363,6 +371,11 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert } fail (err, options: { async?: boolean } = {}) { + // if an onFail handler is provided, call this in its place (currently used for cross-origin support) + if (this.state('onFail')) { + return this.state('onFail')(err) + } + // this means the error has already been through this handler and caught // again. but we don't need to run it through again, so we can re-throw // it and it will fail the test as-is @@ -496,7 +509,9 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert // if setting these props failed then we know we're in a cross origin failure try { - setWindowDocumentProps(getContentWindow($autIframe), this.state) + const autWindow = getContentWindow($autIframe) + + setWindowDocumentProps(autWindow, this.state) // we may need to update the url now this.urlNavigationEvent('load') @@ -505,19 +520,55 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert // because they would have been automatically applied during // onBeforeAppWindowLoad, but in the case where we visited // about:blank in a visit, we do need these - this.contentWindowListeners(getContentWindow($autIframe)) + this.contentWindowListeners(autWindow) + + // stability is signalled after the window:load event to give event + // listeners time to be invoked prior to moving on, but not if + // there is a cross-origin error and the cy.origin API is + // not utilized + try { + this.Cypress.action('app:window:load', this.state('window')) + const remoteLocation = this.getRemoteLocation() + + cy.state('autOrigin', remoteLocation.originPolicy) + this.Cypress.primaryOriginCommunicator.toAllSpecBridges('window:load', { url: remoteLocation.href }) + } catch (err: any) { + // this catches errors thrown by user-registered event handlers + // for `window:load`. this is used in the `catch` below so they + // aren't mistaken as cross-origin errors + err.isFromWindowLoadEvent = true + + throw err + } finally { + this.isStable(true, 'load') + } + } catch (err: any) { + if (err.isFromWindowLoadEvent) { + delete err.isFromWindowLoadEvent - this.Cypress.action('app:window:load', this.state('window')) + // the user's window:load handler threw an error, so propagate that + // and fail the test + const r = this.state('reject') + + if (r) { + return r(err) + } + } + + // we failed setting the remote window props which + // means the page navigated to a different origin + + // With cross-origin support, this is an expected error that may or may + // not be bad, we will rely on the page load timeout to throw if we + // don't end up where we expect to be. + if (this.config('experimentalSessionAndOrigin') && err.name === 'SecurityError') { + return + } - // we are now stable again which is purposefully - // the last event we call here, to give our event - // listeners time to be invoked prior to moving on - return this.isStable(true, 'load') - } catch (err) { let e = err // we failed setting the remote window props - // which means we're in a cross domain failure + // which means we're in a cross origin failure // check first to see if you have a callback function // defined and let the page load change the error const onpl = this.state('onPageLoadErr') @@ -526,12 +577,15 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert e = onpl(e) } - // and now reject with it - const r = this.state('reject') + this.Cypress.emit('internal:window:load', { + type: 'cross:origin:failure', + error: e, + }) - if (r) { - return r(e) - } + // need async:true since this is outside the command queue promise + // chain and cy.fail needs to know to use the reference to the + // last command to reject it + this.fail(e, { async: true }) } }) } @@ -662,6 +716,19 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert } cy.queue.run() + .then(() => { + const onQueueEnd = cy.state('onQueueEnd') + + if (onQueueEnd) { + onQueueEnd() + } + }) + .catch(() => { + // errors from the queue are propagated to cy.fail by the queue itself + // and can be safely ignored here. omitting this catch causes + // unhandled rejections to be logged because Bluebird sees a promise + // chain with no catch handler + }) } return chain @@ -774,7 +841,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.contentWindowListeners(contentWindow) - this.wrapNativeMethods(contentWindow) + this.overrides.wrapNativeMethods(contentWindow) this.onBeforeWindowLoad() } @@ -835,13 +902,9 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert // reset the promise again this.state('promise', undefined) - this.state('hookId', hookId) - this.state('runnable', runnable) - this.state('test', $utils.getTestFromRunnable(runnable)) - this.state('ctx', runnable.ctx) const { fn } = runnable @@ -971,66 +1034,6 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert } } - private wrapNativeMethods (contentWindow) { - try { - // return null to trick contentWindow into thinking - // its not been iframed if modifyObstructiveCode is true - if (this.config('modifyObstructiveCode')) { - Object.defineProperty(contentWindow, 'frameElement', { - get () { - return null - }, - }) - } - - const cy = this - - contentWindow.HTMLElement.prototype.focus = function (focusOption) { - return cy.interceptFocus(this, contentWindow, focusOption) - } - - contentWindow.HTMLElement.prototype.blur = function () { - return cy.interceptBlur(this) - } - - contentWindow.SVGElement.prototype.focus = function (focusOption) { - return cy.interceptFocus(this, contentWindow, focusOption) - } - - contentWindow.SVGElement.prototype.blur = function () { - return cy.interceptBlur(this) - } - - contentWindow.HTMLInputElement.prototype.select = function () { - return $selection.interceptSelect.call(this) - } - - contentWindow.document.hasFocus = function () { - return cy.documentHasFocus.call(this) - } - - const cssModificationSpy = function (original, ...args) { - cy.onCssModified(this.href) - - return original.apply(this, args) - } - - const { insertRule } = contentWindow.CSSStyleSheet.prototype - const { deleteRule } = contentWindow.CSSStyleSheet.prototype - - contentWindow.CSSStyleSheet.prototype.insertRule = _.wrap(insertRule, cssModificationSpy) - contentWindow.CSSStyleSheet.prototype.deleteRule = _.wrap(deleteRule, cssModificationSpy) - - if (this.config('experimentalFetchPolyfill')) { - // drop "fetch" polyfill that replaces it with XMLHttpRequest - // from the app iframe that we wrap for network stubbing - contentWindow.fetch = registerFetch(contentWindow) - // flag the polyfill to test this experimental feature easier - this.state('fetchPolyfilled', true) - } - } catch (error) { } // eslint-disable-line no-empty - } - private warnMixingPromisesAndCommands () { const title = this.state('runnable').fullTitle() @@ -1101,6 +1104,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert onSubmit (e) { return cy.Cypress.action('app:form:submitted', e) }, + onLoad () {}, onBeforeUnload (e) { cy.isStable(false, 'beforeunload') @@ -1137,7 +1141,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert }) } - private enqueue (obj) { + private enqueue (obj: PartialBy) { // if we have a nestedIndex it means we're processing // nested commands and need to insert them into the // index past the current index as opposed to diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 7fe1564d79ba..4c41358263e4 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -894,6 +894,12 @@ export default { retry_timed_out ({ ms }) { return `Timed out retrying after ${ms}ms: ` }, + cross_origin_command ({ commandOrigin, autOrigin }) { + return stripIndent`\ + The command was expected to run against origin \`${commandOrigin }\` but the application is at origin \`${autOrigin}\`. + + This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly.` + }, }, mocha: { @@ -985,6 +991,29 @@ export default { If so, increase \`redirectionLimit\` value in configuration.` }, + cross_origin_load_timed_out ({ ms, configFile, crossOriginUrl, originPolicies }) { + return { + message: stripIndent`\ + Timed out after waiting \`${ms}ms\` for your remote page to load on origin(s): + + - ${originPolicies.map((originPolicy) => `\`${originPolicy}\``).join('\n -')} + + A cross-origin request for \`${crossOriginUrl.href}\` was detected. + + A command that triggers cross-origin navigation must be immediately followed by a ${cmd('origin')} command: + + \`cy.origin('${crossOriginUrl.originPolicy}', () => {\` + \` \` + \`})\` + + If the cross-origin request was an intermediary state, you can try increasing the \`pageLoadTimeout\` value in ${formatConfigFile(configFile)} to wait longer. + + Browsers will not fire the \`load\` event until all stylesheets and scripts are done downloading. + + When this \`load\` event occurs, Cypress will continue running commands.`, + docsUrl: 'https://on.cypress.io/origin', + } + }, }, net_stubbing: { @@ -1105,6 +1134,127 @@ export default { no_global: `Angular global (\`window.angular\`) was not found in your window. You cannot use ${cmd('ng')} methods without angular.`, }, + origin: { + docsUrl: 'https://on.cypress.io/origin', + experiment_not_enabled: { + message: `${cmd('origin')} requires enabling the experimentalSessionAndOrigin flag`, + }, + invalid_url_argument: { + message: `${cmd('origin')} requires the first argument to be either a url (\`https://www.example.com/path\`) or a domain name (\`example.com\`). Query parameters are not allowed. You passed: \`{{arg}}\``, + }, + invalid_options_argument: { + message: `${cmd('origin')} requires the 'options' argument to be an object. You passed: \`{{arg}}\``, + }, + extraneous_options_argument ({ extraneousKeys, validOptionKeys }) { + return stripIndent`\ + ${cmd('origin')} detected extraneous keys in your options configuration. + + The extraneous keys detected were: + + > \`${extraneousKeys}\` + + Valid keys include the following: + + > \`${validOptionKeys}\` + ` + }, + invalid_fn_argument: { + message: `${cmd('origin')} requires the last argument to be a function. You passed: \`{{arg}}\``, + }, + run_origin_fn_errored: { + message: stripIndent` + {{error}} + + This is likely because the arguments specified are not serializable. Note that functions and DOM objects cannot be serialized.`, + }, + ran_origin_fn_reference_error: { + message: stripIndent` + {{error}} + + Variables must either be defined within the ${cmd('origin')} command or passed in using the args option.`, + }, + callback_mixes_sync_and_async: { + message: stripIndent`\ + ${cmd('origin')} failed because you are mixing up async and sync code. + + In your callback function you invoked one or more cy commands but then returned a synchronous value. + + Cypress commands are asynchronous and it doesn't make sense to queue cy commands and yet return a synchronous value. + + You likely forgot to properly chain the cy commands using another \`cy.then()\`. + + The value you synchronously returned was: \`{{value}}\``, + }, + failed_to_serialize_object: { + message: stripIndent`\ + ${cmd('origin')} could not serialize the subject due to one of its properties not being supported by the structured clone algorithm. + + To properly serialize this subject, remove or serialize any unsupported properties.`, + }, + failed_to_serialize_function: { + message: stripIndent`\ + ${cmd('origin')} could not serialize the subject due to functions not being supported by the structured clone algorithm.`, + }, + failed_to_serialize_symbol: { + message: stripIndent`\ + ${cmd('origin')} could not serialize the subject due to symbols not being supported by the structured clone algorithm.`, + }, + failed_to_serialize_or_map_thrown_value: { + message: stripIndent`\ + ${cmd('origin')} could not serialize the thrown value. Please make sure the value being thrown is supported by the structured clone algorithm.`, + }, + unsupported: { + route: { + message: `${cmd('route')} has been deprecated and its use is not supported in the ${cmd('origin')} callback. Consider using ${cmd('intercept')} (outside of the callback) instead.`, + docsUrl: 'https://on.cypress.io/intercept', + }, + server: { + message: `${cmd('server')} has been deprecated and its use is not supported in the ${cmd('origin')} callback. Consider using ${cmd('intercept')} (outside of the callback) instead.`, + docsUrl: 'https://on.cypress.io/intercept', + }, + Server: { + message: `\`Cypress.Server.*\` has been deprecated and its use is not supported in the ${cmd('origin')} callback. Consider using ${cmd('intercept')} (outside of the callback) instead.`, + docsUrl: 'https://on.cypress.io/intercept', + }, + Cookies_preserveOnce: { + message: `\`Cypress.Cookies.preserveOnce\` use is not supported in the ${cmd('origin')} callback. Consider using ${cmd('session')} (outside of the callback) instead.`, + docsUrl: 'https://on.cypress.io/session', + }, + origin: { + message: `${cmd('origin')} use is not currently supported in the ${cmd('origin')} callback, but is planned for a future release. Please 👍 the following issue and leave a comment with your use-case:`, + docsUrl: 'https://on.cypress.io/github-issue/20718', + }, + intercept: { + message: `${cmd('intercept')} use is not supported in the ${cmd('origin')} callback. Consider using it outside of the callback instead. Otherwise, please 👍 the following issue and leave a comment with your use-case:`, + docsUrl: 'https://on.cypress.io/github-issue/20720', + }, + session: { + message: `${cmd('session')} use is not supported in the ${cmd('origin')} callback. Consider using it outside of the callback instead. Otherwise, please 👍 the following issue and leave a comment with your use-case:`, + docsUrl: 'https://on.cypress.io/github-issue/20721', + }, + Cypress_session: { + message: `\`Cypress.session.*\` methods are not supported in the ${cmd('switchToDomain')} callback. Consider using them outside of the callback instead.`, + docsUrl: 'https://on.cypress.io/session-api', + }, + }, + cannot_visit_previous_origin (args) { + return { + message: stripIndent`\ + ${cmd('visit')} failed because you are attempting to visit a URL from a previous origin inside of ${cmd('origin')}. + + Instead of placing the ${cmd('visit')} inside of ${cmd('origin')}, the ${cmd('visit')} should be placed outside of the ${cmd('origin')} block. + + \`\` + + \`cy.origin('${args.previousUrl.originPolicy}', () => {\` + \` \` + \`})\` + + \`cy.visit('${args.originalUrl}')\``, + } + }, + }, + proxy: { js_rewriting_failed: stripIndent`\ An error occurred in the Cypress proxy layer while rewriting your source code. This is a bug in Cypress. Open an issue if you see this message. @@ -1503,9 +1653,18 @@ export default { validate_callback_false: { message: 'Your `cy.session` **validate** callback {{reason}}', }, - experimentNotEnabled: { - message: 'experimentalSessionSupport is not enabled. You must enable the experimentalSessionSupport flag in order to use Cypress session commands', - docsUrl: 'https://on.cypress.io/session', + experimentNotEnabled (experimentalSessionSupport) { + if (experimentalSessionSupport) { + return { + message: stripIndent` + ${cmd('session')} requires enabling the \`experimentalSessionAndOrigin\` flag. The \`experimentalSessionSupport\` flag was enabled but was removed in Cypress version 9.6.0.`, + } + } + + return { + message: `${cmd('session')} requires enabling the \`experimentalSessionAndOrigin\` flag`, + docsUrl: 'https://on.cypress.io/session', + } }, session: { duplicateId: { @@ -1951,26 +2110,42 @@ export default { \`url\` from the \`url\` parameter: {{url}}`, docsUrl: 'https://on.cypress.io/visit', }, - cannot_visit_different_origin: { - message: stripIndent`\ - ${cmd('visit')} failed because you are attempting to visit a URL that is of a different origin. + cannot_visit_different_origin (args) { + return { + message: stripIndent`\ + ${cmd('visit')} failed because you are attempting to visit a URL that is of a different origin. + + ${args.experimentalSessionAndOrigin ? `You likely forgot to use ${cmd('origin')}:` : `In order to visit a different origin, you can enable the \`experimentalSessionAndOrigin\` flag and use ${cmd('origin')}:` } - The new URL is considered a different origin because the following parts of the URL are different: + ${args.isCrossOriginSpecBridge ? + `\`cy.origin('${args.previousUrl.originPolicy}', () => {\` + \` cy.visit('${args.previousUrl}')\` + \` \` + \`})\`` : + `\`cy.visit('${args.previousUrl}')\` + \`\`` + } - > {{differences}} + \`cy.origin('${args.attemptedUrl.originPolicy}', () => {\` + \` cy.visit('${args.originalUrl}')\` + \` \` + \`})\` - You may only ${cmd('visit')} same-origin URLs within a single test. + The new URL is considered a different origin because the following parts of the URL are different: - The previous URL you visited was: + > {{differences}} - > '{{previousUrl}}' + You may only ${cmd('visit')} same-origin URLs within ${args.isCrossOriginSpecBridge ? cmd('origin') : 'a single test'}. - You're attempting to visit this URL: + The previous URL you visited was: - > '{{attemptedUrl}}' + > '${args.previousUrl.origin}' - You may need to restructure some of your test code to avoid this problem.`, - docsUrl: 'https://on.cypress.io/cannot-visit-different-origin-domain', + You're attempting to visit this URL: + + > '${args.attemptedUrl.origin}'`, + docsUrl: 'https://on.cypress.io/cannot-visit-different-origin-domain', + } }, loading_network_failed: stripIndent`\ ${cmd('visit')} failed trying to load: diff --git a/packages/driver/src/cypress/error_utils.ts b/packages/driver/src/cypress/error_utils.ts index 11e79a769782..9424ea8d2c07 100644 --- a/packages/driver/src/cypress/error_utils.ts +++ b/packages/driver/src/cypress/error_utils.ts @@ -260,6 +260,8 @@ const warnByPath = (errPath, options: any = {}) => { } export class InternalCypressError extends Error { + onFail?: undefined | Function + constructor (message) { super(message) @@ -275,6 +277,7 @@ export class CypressError extends Error { docsUrl?: string retry?: boolean userInvocationStack?: any + onFail?: undefined | Function constructor (message) { super(message) diff --git a/packages/driver/src/cypress/location.ts b/packages/driver/src/cypress/location.ts index 96837afd3b4c..50a24e0e5a19 100644 --- a/packages/driver/src/cypress/location.ts +++ b/packages/driver/src/cypress/location.ts @@ -16,6 +16,23 @@ const reFile = /^file:\/\// const reLocalHost = /^(localhost|0\.0\.0\.0|127\.0\.0\.1)/ const reQueryParam = /\?[^/]+/ +export interface LocationObject { + auth: string + authObj?: Cypress.Auth + hash: string + href: string + host: string + hostname: string + origin: string + pathname: string + port: number + protocol: string + search: string + originPolicy: string + superDomain: string + toString: () => string +} + export class $Location { remote: UrlParse @@ -89,13 +106,7 @@ export class $Location { } getOriginPolicy () { - // origin policy is comprised of - // protocol + superdomain - // and subdomain is not factored in - return _.compact([ - `${this.getProtocol()}//${this.getSuperDomain()}`, - this.getPort(), - ]).join(':') + return cors.getOriginPolicy(this.remote.href) } getSuperDomain () { @@ -106,7 +117,7 @@ export class $Location { return this.remote.toString() } - getObject () { + getObject (): LocationObject { return { auth: this.getAuth(), authObj: this.getAuthObj(), @@ -249,7 +260,7 @@ export class $Location { return new URL(to, from).toString() } - static create (remote) { + static create (remote): LocationObject { const location = new $Location(remote) return location.getObject() diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index 9418a200bb6a..be36e099d648 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -49,7 +49,7 @@ export const LogUtils = { return value() } - if (_.isFunction(value)) { + if (_.isFunction(value) || _.isSymbol(value)) { return value.toString() } @@ -92,14 +92,12 @@ export const LogUtils = { return _ .chain(tests) - .flatMap((test) => { - return [test, test.prevAttempts] - }) - .flatMap<{id: number}>((tests) => { - return [].concat(tests.agents, tests.routes, tests.commands) - }).compact() - .union([{ id: 0 }]) - .map('id') + .flatMap((test) => test.prevAttempts ? [test, ...test.prevAttempts] : [test]) + .flatMap<{id: string}>((tests) => [].concat(tests.agents, tests.routes, tests.commands)) + .compact() + .union([{ id: '0' }]) + // id is a string in the form of 'log-origin-#', grab the number off the end. + .map(({ id }) => parseInt((id.match(/\d*$/) || ['0'])[0])) .max() .value() }, @@ -108,6 +106,10 @@ export const LogUtils = { setCounter: (num) => { return counter = num }, + + getCounter: () => { + return counter + }, } const defaults = function (state: Cypress.State, config, obj) { @@ -178,8 +180,10 @@ const defaults = function (state: Cypress.State, config, obj) { return t._currentRetry || 0 } + counter++ + _.defaults(obj, { - id: (counter += 1), + id: `log-${window.location.origin}-${counter}`, state: 'pending', instrument: 'command', url: state('url'), @@ -440,7 +444,7 @@ export class Log { this.obj = { highlightAttr: HIGHLIGHT_ATTR, numElements: $el.length, - visible: $el.length === $el.filter(':visible').length, + visible: this.get('visible') ?? $el.length === $el.filter(':visible').length, } return this.set(this.obj, { silent: true }) @@ -501,8 +505,11 @@ export class Log { consoleObj[key] = _this.get('name') + // in the case a log is being recreated from the cross-origin spec bridge to the primary, consoleProps may be an Object + const consoleObjDefaults = _.isFunction(consoleProps) ? consoleProps.apply(this, args) : consoleProps + // merge in the other properties from consoleProps - _.extend(consoleObj, consoleProps.apply(this, args)) + _.extend(consoleObj, consoleObjDefaults) // TODO: right here we need to automatically // merge in "Yielded + Element" if there is an $el diff --git a/packages/driver/src/cypress/runner.ts b/packages/driver/src/cypress/runner.ts index d487590f6e0e..59566780bac6 100644 --- a/packages/driver/src/cypress/runner.ts +++ b/packages/driver/src/cypress/runner.ts @@ -1541,6 +1541,7 @@ export default { } cy.state('duringUserTestExecution', false) + Cypress.primaryOriginCommunicator.toAllSpecBridges('sync:state', { 'duringUserTestExecution': false }) // our runnable is about to run, so let cy know. this enables // us to always have a correct runnable set even when we are diff --git a/packages/driver/src/dom/animation.ts b/packages/driver/src/dom/animation.ts new file mode 100644 index 000000000000..e18c2b2c2a31 --- /dev/null +++ b/packages/driver/src/dom/animation.ts @@ -0,0 +1,21 @@ +import $ from 'jquery' + +function addCssAnimationDisabler ($body) { + $(` + + `).appendTo($body) +} + +function removeCssAnimationDisabler ($body) { + $body.find('#__cypress-animation-disabler').remove() +} + +export default { + addCssAnimationDisabler, + removeCssAnimationDisabler, +} diff --git a/packages/driver/src/dom/blackout.ts b/packages/driver/src/dom/blackout.ts new file mode 100644 index 000000000000..bf8e991b475a --- /dev/null +++ b/packages/driver/src/dom/blackout.ts @@ -0,0 +1,58 @@ +import $ from 'jquery' +import $dimensions from '@packages/runner-shared/src/dimensions' + +const resetStyles = ` + border: none !important; + margin: 0 !important; + padding: 0 !important; +` + +const styles = (styleString) => { + return styleString.replace(/\s*\n\s*/g, '') +} + +function addBlackoutForElement ($body, $el) { + const dimensions = $dimensions.getElementDimensions($el) + const width = dimensions.widthWithBorder + const height = dimensions.heightWithBorder + const top = dimensions.offset.top + const left = dimensions.offset.left + + const style = styles(` + ${resetStyles} + position: absolute; + top: ${top}px; + left: ${left}px; + width: ${width}px; + height: ${height}px; + background-color: black; + z-index: 2147483647; + `) + + $(`
`).appendTo($body) +} + +function addBlackouts ($body, selector) { + let $el + + try { + $el = $body.find(selector) + if (!$el.length) return + } catch (err) { + // if it's an invalid selector, just ignore it + return + } + + $el.each(function (this: HTMLElement) { + addBlackoutForElement($body, $(this)) + }) +} + +function removeBlackouts ($body) { + $body.find('.__cypress-blackout').remove() +} + +export default { + addBlackouts, + removeBlackouts, +} diff --git a/packages/driver/src/dom/coordinates.ts b/packages/driver/src/dom/coordinates.ts index 29ca38d389d7..976f6665e9ef 100644 --- a/packages/driver/src/dom/coordinates.ts +++ b/packages/driver/src/dom/coordinates.ts @@ -7,12 +7,31 @@ const getElementAtPointFromViewport = (doc, x, y) => { return $elements.elementFromPoint(doc, x, y) } -const isAutIframe = (win) => { +const isAUTFrame = (win) => { const parent = win.parent // https://github.com/cypress-io/cypress/issues/6412 // ensure the parent is a Window before checking prop - return $window.isWindow(parent) && !$elements.getNativeProp(parent, 'frameElement') + if (!$window.isWindow(parent)) { + return false + } + + try { + // window.frameElement only exists on iframe windows, so if it doesn't + // exist on parent, it must be the top frame, and `win` is the AUT + return !$elements.getNativeProp(parent, 'frameElement') + } catch (err) { + // if the AUT is cross-origin, accessing parent.frameElement will throw + // a cross-origin error, meaning this is the AUT + // NOTE: this will need to be updated once we add support for + // cross-origin iframes + if (err.name !== 'SecurityError') { + // re-throw any error that's not a cross-origin error + throw err + } + + return true + } } const getFirstValidSizedRect = (el) => { @@ -98,7 +117,7 @@ const getElementPositioning = ($el: JQuery | HTMLElement): ElementP // https://github.com/cypress-io/cypress/issues/6412 // ensure the parent is a Window before checking prop // walk up from a nested iframe so we continually add the x + y values - while ($window.isWindow(curWindow) && !isAutIframe(curWindow) && curWindow.parent !== curWindow) { + while ($window.isWindow(curWindow) && !isAUTFrame(curWindow) && curWindow.parent !== curWindow) { frame = $elements.getNativeProp(curWindow, 'frameElement') if (curWindow && frame) { @@ -375,4 +394,6 @@ export default { getElementCoordinatesByPosition, getElementCoordinatesByPositionRelativeToXY, + + isAUTFrame, } diff --git a/packages/driver/src/dom/elements/detached.ts b/packages/driver/src/dom/elements/detached.ts index 6864d45fefc4..fe676233d6c2 100644 --- a/packages/driver/src/dom/elements/detached.ts +++ b/packages/driver/src/dom/elements/detached.ts @@ -9,7 +9,7 @@ export const isDetached = ($el) => { export const isAttached = function ($el) { // if we're being given window - // then these are automaticallyed attached + // then these are automatically attached if ($window.isWindow($el)) { // there is a code path when forcing focus and // blur on the window where this check is necessary. diff --git a/packages/driver/src/dom/index.ts b/packages/driver/src/dom/index.ts index 92bbc4bd0ea8..4018e3e6ed4f 100644 --- a/packages/driver/src/dom/index.ts +++ b/packages/driver/src/dom/index.ts @@ -5,6 +5,8 @@ import $elements from './elements' import $coordinates from './coordinates' import $selection from './selection' import $visibility from './visibility' +import $blackout from './blackout' +import $animation from './animation' const { isWindow, getWindowByElement } = $window const { isDocument, getDocumentFromElement } = $document @@ -13,6 +15,8 @@ const { isVisible, isHidden, isStrictlyHidden, isHiddenByAncestors, getReasonIsH const { isInputType, isFocusable, isElement, isScrollable, isFocused, stringify, getElements, getContainsSelector, getFirstDeepestElement, getInputFromLabel, isDetached, isAttached, isTextLike, isSelector, isDescendent, getFirstFixedOrStickyPositionParent, getFirstStickyPositionParent, getFirstScrollableParent, isUndefinedOrHTMLBodyDoc, elementFromPoint, getParent, findAllShadowRoots, isWithinShadowRoot, getHostContenteditable } = $elements const { getCoordsByPosition, getElementPositioning, getElementCoordinatesByPosition, getElementAtPointFromViewport, getElementCoordinatesByPositionRelativeToXY } = $coordinates const { getSelectionBounds } = $selection +const { addBlackouts, removeBlackouts } = $blackout +const { removeCssAnimationDisabler, addCssAnimationDisabler } = $animation const isDom = (obj) => { return isElement(obj) || isWindow(obj) || isDocument(obj) @@ -24,6 +28,10 @@ const isDom = (obj) => { // purposes or for overriding. Everything else // can be tucked away behind these interfaces. export default { + removeBlackouts, + addBlackouts, + removeCssAnimationDisabler, + addCssAnimationDisabler, wrap, isW3CFocusable, isW3CRendered, diff --git a/packages/driver/src/multi-domain/communicator.ts b/packages/driver/src/multi-domain/communicator.ts new file mode 100644 index 000000000000..0e98df5ee5ef --- /dev/null +++ b/packages/driver/src/multi-domain/communicator.ts @@ -0,0 +1,210 @@ +import debugFn from 'debug' +import { EventEmitter } from 'events' +import { preprocessConfig, preprocessEnv } from '../util/config' +import { preprocessForSerialization, reifySerializedError } from '../util/serialization' +import { $Location } from '../cypress/location' +import { preprocessLogForSerialization, reifyLogFromSerialization, preprocessSnapshotForSerialization, reifySnapshotFromSerialization } from '../util/serialization/log' + +const debug = debugFn('cypress:driver:multi-origin') + +const CROSS_ORIGIN_PREFIX = 'cross:origin:' +const LOG_EVENTS = [`${CROSS_ORIGIN_PREFIX}log:added`, `${CROSS_ORIGIN_PREFIX}log:changed`] +const FINAL_SNAPSHOT_EVENT = `${CROSS_ORIGIN_PREFIX}final:snapshot:generated` + +/** + * Primary Origin communicator. Responsible for sending/receiving events throughout + * the driver responsible for multi-origin communication, as well as sending/receiving events to/from the + * spec bridge communicator, respectively. + * + * The 'postMessage' method is used to send events to the spec bridge communicator, while + * the 'message' event is used to receive messages from the spec bridge communicator. + * All events communicating across origins are prefixed with 'cross:origin:' under the hood. + * See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage for more details. + * @extends EventEmitter + */ +export class PrimaryOriginCommunicator extends EventEmitter { + private crossOriginDriverWindows: {[key: string]: Window} = {} + userInvocationStack?: string + + /** + * The callback handler that receives messages from secondary origins. + * @param {MessageEvent.data} data - a reference to the MessageEvent.data sent through the postMessage event. See https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data + * @param {MessageEvent.source} source - a reference to the MessageEvent.source sent through the postMessage event. See https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/source + * @returns {Void} + */ + onMessage ({ data, source }) { + // check if message is cross origin and if so, feed the message into + // the cross origin bus with args and strip prefix + if (data?.event?.startsWith(CROSS_ORIGIN_PREFIX)) { + const messageName = data.event.replace(CROSS_ORIGIN_PREFIX, '') + + // NOTE: need a special case here for 'bridge:ready' + // where we need to set the crossOriginDriverWindows to source to + // communicate back to the iframe + if (messageName === 'bridge:ready' && source) { + this.crossOriginDriverWindows[data.originPolicy] = source as Window + } + + // reify any logs coming back from the cross-origin spec bridges to serialize snapshot/consoleProp DOM elements as well as select functions. + if (LOG_EVENTS.includes(data?.event)) { + data.data = reifyLogFromSerialization(data.data as any) + } + + // reify the final snapshot coming back from the secondary domain if requested by the runner. + if (FINAL_SNAPSHOT_EVENT === data?.event) { + data.data = reifySnapshotFromSerialization(data.data as any) + } + + if (data?.data?.err) { + data.data.err = reifySerializedError(data.data.err, this.userInvocationStack as string) + } + + this.emit(messageName, data.data, data.originPolicy) + + return + } + + debug('Unexpected postMessage:', data) + } + + /** + * Events to be sent to the spec bridge communicator instance. + * @param {string} event - the name of the event to be sent. + * @param {any} data - any meta data to be sent with the event. + */ + toAllSpecBridges (event: string, data?: any) { + debug('=> to all spec bridges', event, data) + + const preprocessedData = preprocessForSerialization(data) + + // if user defined arguments are passed in, do NOT sanitize them. + if (data?.args) { + preprocessedData.args = data.args + } + + // If there is no crossOriginDriverWindows, there is no need to send the message. + Object.values(this.crossOriginDriverWindows).forEach((win: Window) => { + win.postMessage({ + event, + data: preprocessedData, + }, '*') + }) + } + + toSpecBridge (originPolicy: string, event: string, data?: any) { + debug('=> to spec bridge', originPolicy, event, data) + + const preprocessedData = preprocessForSerialization(data) + + // if user defined arguments are passed in, do NOT sanitize them. + if (data?.args) { + preprocessedData.args = data.args + } + + // If there is no crossOriginDriverWindows, there is no need to send the message. + this.crossOriginDriverWindows[originPolicy]?.postMessage({ + event, + data: preprocessedData, + }, '*') + } +} + +/** + * Spec bridge communicator. Responsible for sending/receiving events to/from the + * primary origin communicator, respectively. + * + * The 'postMessage' method is used to send events to the primary communicator, while + * the 'message' event is used to receive messages from the primary communicator. + * All events communicating across origins are prefixed with 'cross:origin:' under the hood. + * See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage for more details. + * @extends EventEmitter + */ +export class SpecBridgeCommunicator extends EventEmitter { + private handleSubjectAndErr = (data: Cypress.ObjectLike = {}, send: (data: Cypress.ObjectLike) => void) => { + let { subject, err, ...rest } = data + + // check to see if the 'err' key is defined, and if it is, we have an error of any type + const hasError = !!Object.getOwnPropertyDescriptor(data, 'err') + + if (!subject && !hasError) { + return send(rest) + } + + try { + if (hasError) { + try { + // give the `err` truthiness if it's a falsey value like undefined/null/false + if (!err) { + err = new Error(`${err}`) + } + + err = preprocessForSerialization(err) + } catch (e) { + err = e + } + } + + // We always want to make sure errors are posted, so clean it up to send. + send({ ...rest, subject, err }) + } catch (err: any) { + if (subject && err.name === 'DataCloneError') { + // Send the type of object that failed to serialize. + // If the subject threw the 'DataCloneError', the subject cannot be + // serialized, at which point try again with an undefined subject. + return this.handleSubjectAndErr({ ...rest, unserializableSubjectType: typeof subject }, send) + } + + // Try to send the message again, with the new error. + this.handleSubjectAndErr({ ...rest, err }, send) + } + } + + private syncGlobalsToPrimary = () => { + this.toPrimary('sync:globals', { + config: preprocessConfig(Cypress.config()), + env: preprocessEnv(Cypress.env()), + }) + } + + /** + * The callback handler that receives messages from the primary origin. + * @param {MessageEvent.data} data - a reference to the MessageEvent.data sent through the postMessage event. See https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data + * @returns {Void} + */ + onMessage ({ data }) { + if (!data) return + + this.emit(data.event, data.data) + } + + /** + * Events to be sent to the primary communicator instance. + * @param {string} event - the name of the event to be sent. + * @param {Cypress.ObjectLike} data - any meta data to be sent with the event. + */ + toPrimary (event: string, data?: Cypress.ObjectLike, options: { syncGlobals: boolean } = { syncGlobals: false }) { + const { originPolicy } = $Location.create(window.location.href) + const eventName = `${CROSS_ORIGIN_PREFIX}${event}` + + // Preprocess logs before sending through postMessage() to attempt to serialize some DOM nodes and functions. + if (LOG_EVENTS.includes(eventName)) { + data = preprocessLogForSerialization(data as any) + } + + // If requested by the runner, preprocess the final snapshot before sending through postMessage() to attempt to serialize the DOM body of the snapshot. + if (FINAL_SNAPSHOT_EVENT === eventName) { + data = preprocessSnapshotForSerialization(data as any) + } + + debug('<= to Primary ', event, data, originPolicy) + if (options.syncGlobals) this.syncGlobalsToPrimary() + + this.handleSubjectAndErr(data, (data: Cypress.ObjectLike) => { + window.top?.postMessage({ + event: eventName, + data, + originPolicy, + }, '*') + }) + } +} diff --git a/packages/driver/src/multi-domain/cypress.ts b/packages/driver/src/multi-domain/cypress.ts new file mode 100644 index 000000000000..5a8d8cfb9425 --- /dev/null +++ b/packages/driver/src/multi-domain/cypress.ts @@ -0,0 +1,191 @@ +import 'setimmediate' + +import '../config/bluebird' +import '../config/jquery' +import '../config/lodash' + +import $Cypress from '../cypress' +import { $Cy } from '../cypress/cy' +import { $Location } from '../cypress/location' +import $Commands from '../cypress/commands' +import { create as createLog } from '../cypress/log' +import { bindToListeners } from '../cy/listeners' +import { handleOriginFn } from './domain_fn' +import { FINAL_SNAPSHOT_NAME } from '../cy/snapshots' +import { handleLogs } from './events/logs' +import { handleSocketEvents } from './events/socket' +import { handleSpecWindowEvents } from './events/spec_window' +import { handleErrorEvent } from './events/errors' +import { handleScreenshots } from './events/screenshots' +import { handleTestEvents } from './events/test' +import { handleMiscEvents } from './events/misc' +import { handleUnsupportedAPIs } from './unsupported_apis' +import $Mocha from '../cypress/mocha' +import * as cors from '@packages/network/lib/cors' + +const createCypress = () => { + // @ts-ignore + const Cypress = window.Cypress = new $Cypress() as Cypress.Cypress + + Cypress.specBridgeCommunicator.once('initialize:cypress', ({ config, env }) => { + // eventually, setup will get called again on rerun and cy will get re-created + setup(config, env) + }) + + Cypress.specBridgeCommunicator.on('generate:final:snapshot', (snapshotUrl: string) => { + const currentAutOriginPolicy = cy.state('autOrigin') + const requestedSnapshotUrlLocation = $Location.create(snapshotUrl) + + if (requestedSnapshotUrlLocation.originPolicy === currentAutOriginPolicy) { + // if true, this is the correct specbridge to take the snapshot and send it back + const finalSnapshot = cy.createSnapshot(FINAL_SNAPSHOT_NAME) + + Cypress.specBridgeCommunicator.toPrimary('final:snapshot:generated', finalSnapshot) + } + }) + + Cypress.specBridgeCommunicator.toPrimary('bridge:ready') +} + +const setup = (cypressConfig: Cypress.Config, env: Cypress.ObjectLike) => { + const Cypress = window.Cypress + + Cypress.configure({ + ...cypressConfig, + env, + // never turn on video for a spec bridge when syncing the config. This is handled in the primary. + video: false, + isCrossOriginSpecBridge: true, + // cross origin spec bridges cannot be used in component testing and is only valid for e2e. + // This value is not synced with the config because it is omitted on big Cypress creation, as well as a few other key properties + testingType: 'e2e', + }) + + // @ts-ignore + const cy = window.cy = new $Cy(window, Cypress, Cypress.Cookies, Cypress.state, Cypress.config, false) + + // @ts-ignore + Cypress.log = createLog(Cypress, cy, Cypress.state, Cypress.config) + + Cypress.mocha = $Mocha.create(window, Cypress, Cypress.config) + + // @ts-ignore + Cypress.runner = { + addLog () {}, + } + + // @ts-ignore + Cypress.cy = cy + // @ts-ignore + Cypress.events.proxyTo(Cypress.cy) + + const { state, config } = Cypress + + // @ts-ignore + Cypress.Commands = $Commands.create(Cypress, cy, state, config) + // @ts-ignore + Cypress.isCy = cy.isCy + + handleOriginFn(Cypress, cy) + handleLogs(Cypress) + handleSocketEvents(Cypress) + handleSpecWindowEvents(cy) + handleMiscEvents(Cypress, cy) + handleScreenshots(Cypress) + handleTestEvents(Cypress) + handleUnsupportedAPIs(Cypress, cy) + + cy.onBeforeAppWindowLoad = onBeforeAppWindowLoad(Cypress, cy) +} + +// eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces +const onBeforeAppWindowLoad = (Cypress: Cypress.Cypress, cy: $Cy) => (autWindow: Window) => { + autWindow.Cypress = Cypress + + Cypress.state('window', autWindow) + Cypress.state('document', autWindow.document) + + // This is typically called by the cy function `urlNavigationEvent` but it is private. For the primary origin this is called in 'onBeforeAppWindowLoad'. + Cypress.action('app:navigation:changed', 'page navigation event (\'before:load\')') + + cy.overrides.wrapNativeMethods(autWindow) + + const onWindowLoadPrimary = ({ url }) => { + cy.isStable(true, 'primary onload') + + cy.state('autOrigin', cors.getOriginPolicy(url)) + Cypress.emit('internal:window:load', { type: 'cross:origin', url }) + } + + // TODO: DRY this up with the mostly-the-same code in src/cypress/cy.js + // https://github.com/cypress-io/cypress/issues/20972 + bindToListeners(autWindow, { + onError: handleErrorEvent(cy, 'app'), + onHistoryNav () {}, + onSubmit (e) { + return Cypress.action('app:form:submitted', e) + }, + onBeforeUnload (e) { + cy.isStable(false, 'beforeunload') + + cy.Cookies.setInitial() + + cy.resetTimer() + + Cypress.action('app:window:before:unload', e) + + Cypress.specBridgeCommunicator.toPrimary('before:unload') + + // return undefined so our beforeunload handler + // doesn't trigger a confirmation dialog + return undefined + }, + onLoad () { + // This is typically called by the cy function `urlNavigationEvent` but it is private. For the primary origin this is called on 'load'. + Cypress.action('app:navigation:changed', 'page navigation event (\'load\')') + // This is also call on the on 'load' event in cy + Cypress.action('app:window:load', autWindow) + + const remoteLocation = cy.getRemoteLocation() + + cy.state('autOrigin', remoteLocation.originPolicy) + + Cypress.specBridgeCommunicator.toPrimary('window:load', { url: remoteLocation.href }) + cy.isStable(true, 'load') + + // If load happened in this spec bridge stop listening. + Cypress.specBridgeCommunicator.off('window:load', onWindowLoadPrimary) + }, + onUnload (e) { + cy.state('window', undefined) + cy.state('document', undefined) + // We only need to listen to this if we've started an unload event and the load happens in another spec bridge. + Cypress.specBridgeCommunicator.once('window:load', onWindowLoadPrimary) + + return Cypress.action('app:window:unload', e) + }, + onNavigation (...args) { + return Cypress.action('app:navigation:changed', ...args) + }, + onAlert (str) { + return Cypress.action('app:window:alert', str) + }, + onConfirm (str) { + const results = Cypress.action('app:window:confirm', str) as any[] + + // return false if ANY results are false + const ret = !results.some((result) => result === false) + + Cypress.action('app:window:confirmed', str, ret) + + return ret + }, + }) +} + +// only bind the message handler one time when the spec bridge is created +window.addEventListener('message', ({ data }) => { + Cypress?.specBridgeCommunicator.onMessage({ data }) +}, false) + +createCypress() diff --git a/packages/driver/src/multi-domain/domain_fn.ts b/packages/driver/src/multi-domain/domain_fn.ts new file mode 100644 index 000000000000..fbb4416cb9d0 --- /dev/null +++ b/packages/driver/src/multi-domain/domain_fn.ts @@ -0,0 +1,169 @@ +import type { $Cy } from '../cypress/cy' +import $errUtils from '../cypress/error_utils' +import $utils from '../cypress/utils' +import { syncConfigToCurrentOrigin, syncEnvToCurrentOrigin } from '../util/config' +import type { Runnable, Test } from 'mocha' +import { LogUtils } from '../cypress/log' + +interface RunOriginFnOptions { + config: Cypress.Config + args: any + env: Cypress.ObjectLike + fn: string + skipConfigValidation: boolean + state: {} + logCounter: number +} + +interface serializedRunnable { + id: string + type: string + title: string + parent: serializedRunnable + ctx: {} + _timeout: number + titlePath: string +} + +const rehydrateRunnable = (data: serializedRunnable): Runnable|Test => { + let runnable + + if (data.type === 'test') { + runnable = Cypress.mocha.createTest(data.title, () => {}) + } else { + runnable = new Cypress.mocha._mocha.Mocha.Runnable(data.title) + runnable.type = data.type + } + + runnable.ctx = data.ctx + runnable.id = data.id + runnable._timeout = data._timeout + // Short circuit title path to avoid implementing it up the parent chain. + runnable.titlePath = () => { + return data.titlePath + } + + if (data.parent) { + runnable.parent = rehydrateRunnable(data.parent) + } + + // This is normally setup in the run command, but we don't call run. + // Any errors this would be reporting will already have been reported previously + runnable.callback = () => {} + + return runnable +} + +export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { + const reset = (state) => { + cy.reset({}) + + const stateUpdates = { + ...state, + redirectionCount: {}, // This is fine to set to an empty object, we want to refresh this count on each cy.origin command. + } + + // Setup the runnable + stateUpdates.runnable = rehydrateRunnable(state.runnable) + + // the viewport could've changed in the primary, so sync it up in the secondary + Cypress.primaryOriginCommunicator.emit('sync:viewport', { viewportWidth: state.viewportWidth, viewportHeight: state.viewportHeight }) + + // Update the state with the necessary values from the primary origin + cy.state(stateUpdates) + + // Set the state ctx to the runnable ctx to ensure they remain in sync + cy.state('ctx', cy.state('runnable').ctx) + } + + const setRunnableStateToPassed = () => { + // HACK: We're telling the runnable that it has passed to avoid a timeout + // on the last (empty) command. Normally this would be set inherently by + // running runnable.run() the test. Set this to passed regardless of the + // state of the test, the runnable isn't responsible for reporting success. + cy.state('runnable').state = 'passed' + } + + Cypress.specBridgeCommunicator.on('run:origin:fn', async (options: RunOriginFnOptions) => { + const { config, args, env, fn, state, skipConfigValidation, logCounter } = options + + let queueFinished = false + + reset(state) + + // Set the counter for log ids + LogUtils.setCounter(logCounter) + + // @ts-ignore + window.__cySkipValidateConfig = skipConfigValidation || false + + // resync the config/env before running the origin:fn + syncConfigToCurrentOrigin(config) + syncEnvToCurrentOrigin(env) + + cy.state('onQueueEnd', () => { + queueFinished = true + setRunnableStateToPassed() + Cypress.specBridgeCommunicator.toPrimary('queue:finished', { + subject: cy.state('subject'), + }, { + syncGlobals: true, + }) + }) + + cy.state('onFail', (err) => { + setRunnableStateToPassed() + if (queueFinished) { + // If the queue is already finished, send this event instead because + // the primary won't be listening for 'queue:finished' anymore + Cypress.specBridgeCommunicator.toPrimary('uncaught:error', { err }) + + return + } + + cy.stop() + Cypress.specBridgeCommunicator.toPrimary('queue:finished', { err }, { syncGlobals: true }) + }) + + try { + const value = window.eval(`(${fn})`)(args) + + // If we detect a non promise value with commands in queue, throw an error + if (value && cy.queue.length > 0 && !value.then) { + $errUtils.throwErrByPath('origin.callback_mixes_sync_and_async', { + args: { value: $utils.stringify(value) }, + }) + } else { + const hasCommands = !!cy.queue.length + + // If there are queued commands, their yielded value will be preferred + // over the value resolved by a return promise. Don't send the subject + // or the primary will think we're done already with a sync-returned + // value + const subject = hasCommands ? undefined : await value + + Cypress.specBridgeCommunicator.toPrimary('ran:origin:fn', { + subject, + finished: !hasCommands, + }, { + // Only sync the globals if there are no commands in queue + // (for instance, only assertions exist in the callback) + // since it means the callback is finished at this point + syncGlobals: !hasCommands, + }) + + if (!hasCommands) { + queueFinished = true + setRunnableStateToPassed() + + return + } + } + } catch (err) { + setRunnableStateToPassed() + Cypress.specBridgeCommunicator.toPrimary('ran:origin:fn', { err }, { syncGlobals: true }) + + return + } + }) +} diff --git a/packages/driver/src/multi-domain/events/errors.ts b/packages/driver/src/multi-domain/events/errors.ts new file mode 100644 index 000000000000..a4f638f4e524 --- /dev/null +++ b/packages/driver/src/multi-domain/events/errors.ts @@ -0,0 +1,31 @@ +import type { $Cy } from '../../cypress/cy' +import $errUtils, { ErrorFromProjectRejectionEvent } from '../../cypress/error_utils' + +export const handleErrorEvent = (cy: $Cy, frameType: 'spec' | 'app') => { + return (handlerType: string) => { + return (event) => { + const { originalErr, err, promise } = $errUtils.errorFromUncaughtEvent(handlerType, event) as ErrorFromProjectRejectionEvent + const handled = cy.onUncaughtException({ + err, + promise, + handlerType, + frameType, + }) + + $errUtils.logError(Cypress, handlerType, originalErr, handled) + + if (!handled) { + // if unhandled, fail the current command to fail the current test in the primary origin + // a current command may not exist if an error occurs in the spec bridge after the test is over + const command = cy.state('current') + const log = command?.getLastLog() + + if (log) log.error(err) + } + + // return undefined so the browser does its default + // uncaught exception behavior (logging to console) + return undefined + } + } +} diff --git a/packages/driver/src/multi-domain/events/logs.ts b/packages/driver/src/multi-domain/events/logs.ts new file mode 100644 index 000000000000..c686c5004020 --- /dev/null +++ b/packages/driver/src/multi-domain/events/logs.ts @@ -0,0 +1,12 @@ +export const handleLogs = (Cypress: Cypress.Cypress) => { + const onLogAdded = (attrs) => { + Cypress.specBridgeCommunicator.toPrimary('log:added', attrs) + } + + const onLogChanged = (attrs) => { + Cypress.specBridgeCommunicator.toPrimary('log:changed', attrs) + } + + Cypress.on('log:added', onLogAdded) + Cypress.on('log:changed', onLogChanged) +} diff --git a/packages/driver/src/multi-domain/events/misc.ts b/packages/driver/src/multi-domain/events/misc.ts new file mode 100644 index 000000000000..691a80799fba --- /dev/null +++ b/packages/driver/src/multi-domain/events/misc.ts @@ -0,0 +1,26 @@ +import type { $Cy } from '../../cypress/cy' + +export const handleMiscEvents = (Cypress: Cypress.Cypress, cy: $Cy) => { + Cypress.on('viewport:changed', (viewport, callbackFn) => { + Cypress.specBridgeCommunicator.once('viewport:changed:end', () => { + callbackFn() + }) + + Cypress.specBridgeCommunicator.toPrimary('viewport:changed', viewport) + }) + + Cypress.specBridgeCommunicator.on('sync:state', (state) => { + cy.state(state) + }) + + // Forward url:changed Message to the primary origin to enable changing the url displayed in the AUT + // @ts-ignore + Cypress.on('url:changed', (url) => { + Cypress.specBridgeCommunicator.toPrimary('url:changed', { url }) + }) + + // Listen for any unload events in other origins, if any have unloaded we should also become unstable. + Cypress.specBridgeCommunicator.on('before:unload', () => { + cy.state('isStable', false) + }) +} diff --git a/packages/driver/src/multi-domain/events/screenshots.ts b/packages/driver/src/multi-domain/events/screenshots.ts new file mode 100644 index 000000000000..644efda0fbd3 --- /dev/null +++ b/packages/driver/src/multi-domain/events/screenshots.ts @@ -0,0 +1,13 @@ +export const handleScreenshots = (Cypress: Cypress.Cypress) => { + Cypress.on('before:screenshot', (config, callbackFn) => { + Cypress.specBridgeCommunicator.once('before:screenshot:end', () => { + callbackFn() + }) + + Cypress.specBridgeCommunicator.toPrimary('before:screenshot', config) + }) + + Cypress.on('after:screenshot', (config) => { + Cypress.specBridgeCommunicator.toPrimary('after:screenshot', config) + }) +} diff --git a/packages/driver/src/multi-domain/events/socket.ts b/packages/driver/src/multi-domain/events/socket.ts new file mode 100644 index 000000000000..3590849850be --- /dev/null +++ b/packages/driver/src/multi-domain/events/socket.ts @@ -0,0 +1,24 @@ +import { client } from '@packages/socket' + +const webSocket = client({ + path: '/__socket.io', + transports: ['websocket'], +}).connect() + +const onBackendRequest = (...args) => { + webSocket.emit('backend:request', ...args) +} + +const onAutomationRequest = (...args) => { + webSocket.emit('automation:request', ...args) +} + +webSocket.on('cross:origin:delaying:html', (request) => { + // Until we do nested cy.origin, we just need to know what the request was for error messaging. + cy.isAnticipatingCrossOriginResponseFor(request) +}) + +export const handleSocketEvents = (Cypress) => { + Cypress.on('backend:request', onBackendRequest) + Cypress.on('automation:request', onAutomationRequest) +} diff --git a/packages/driver/src/multi-domain/events/spec_window.ts b/packages/driver/src/multi-domain/events/spec_window.ts new file mode 100644 index 000000000000..61b26a26f64d --- /dev/null +++ b/packages/driver/src/multi-domain/events/spec_window.ts @@ -0,0 +1,17 @@ +import type { $Cy } from '../../cypress/cy' +import { handleErrorEvent } from './errors' + +export const handleSpecWindowEvents = (cy: $Cy) => { + const handleWindowErrorEvent = handleErrorEvent(cy, 'spec')('error') + const handleWindowUnhandledRejectionEvent = handleErrorEvent(cy, 'spec')('unhandledrejection') + + const handleUnload = () => { + window.removeEventListener('unload', handleUnload) + window.removeEventListener('error', handleWindowErrorEvent) + window.removeEventListener('unhandledrejection', handleWindowUnhandledRejectionEvent) + } + + window.addEventListener('unload', handleUnload) + window.addEventListener('error', handleWindowErrorEvent) + window.addEventListener('unhandledrejection', handleWindowUnhandledRejectionEvent) +} diff --git a/packages/driver/src/multi-domain/events/test.ts b/packages/driver/src/multi-domain/events/test.ts new file mode 100644 index 000000000000..26922646aeca --- /dev/null +++ b/packages/driver/src/multi-domain/events/test.ts @@ -0,0 +1,9 @@ +export const handleTestEvents = (Cypress: Cypress.Cypress) => { + Cypress.specBridgeCommunicator.on('test:before:run', (...args) => { + Cypress.emit('test:before:run', ...args) + }) + + Cypress.specBridgeCommunicator.on('test:before:run:async', (...args) => { + Cypress.emit('test:before:run:async', ...args) + }) +} diff --git a/packages/driver/src/multi-domain/unsupported_apis.ts b/packages/driver/src/multi-domain/unsupported_apis.ts new file mode 100644 index 000000000000..9e7722a9956b --- /dev/null +++ b/packages/driver/src/multi-domain/unsupported_apis.ts @@ -0,0 +1,37 @@ +import type { $Cy } from '../cypress/cy' +import $errUtils from '../cypress/error_utils' + +export const handleUnsupportedAPIs = (Cypress: Cypress.Cypress, cy: $Cy) => { + // The following commands/methods are not supported within the `origin` + // callback since they are deprecated + // @ts-ignore + cy.route = () => $errUtils.throwErrByPath('origin.unsupported.route') + // @ts-ignore + cy.server = () => $errUtils.throwErrByPath('origin.unsupported.server') + Cypress.Server = new Proxy(Cypress.Server, { + get: () => $errUtils.throwErrByPath('origin.unsupported.Server'), + // @ts-ignore + set: () => $errUtils.throwErrByPath('origin.unsupported.Server'), + }) + + // @ts-ignore + Cypress.Cookies.preserveOnce = () => $errUtils.throwErrByPath('origin.unsupported.Cookies_preserveOnce') + + // Nested `origin` is not currently supported, but will be in the future + // @ts-ignore + cy.origin = () => $errUtils.throwErrByPath('origin.unsupported.origin') + + // `intercept` and `session` are not supported within the `origin` + // callback, but can be used outside of it for the same effect. It's unlikely + // they will ever be supported unless we discover uses-cases that require it + // @ts-ignore + cy.intercept = () => $errUtils.throwErrByPath('origin.unsupported.intercept') + // @ts-ignore + cy.session = () => $errUtils.throwErrByPath('origin.unsupported.session') + // @ts-ignore + Cypress.session = new Proxy(Cypress.session, { + get: () => $errUtils.throwErrByPath('origin.unsupported.Cypress_session'), + // @ts-ignore + set: () => $errUtils.throwErrByPath('origin.unsupported.Cypress_session'), + }) +} diff --git a/packages/driver/src/util/config.ts b/packages/driver/src/util/config.ts new file mode 100644 index 000000000000..00dc84e86b67 --- /dev/null +++ b/packages/driver/src/util/config.ts @@ -0,0 +1,62 @@ +import _ from 'lodash' +import { options } from '@packages/config' +import { preprocessForSerialization } from './serialization' + +/** + * Mutates the config/env object serialized from the other origin to omit read-only values + * to prevent trying to update differences in read-only config. This function does mutate this + * serialized config/env by reference, which should be ok since this object is a clone + * of what is being received back from the 'other' origin. + * + * @param objectLikeConfig the config/env object to omit read-only values from. + * @returns a reference to the config/env object passed in + */ +const omitConfigReadOnlyDifferences = (objectLikeConfig: Cypress.ObjectLike) => { + Object.keys(objectLikeConfig).forEach((key) => { + if (options.find((option) => option.name === key)?.canUpdateDuringTestTime === false) { + delete objectLikeConfig[key] + } + }) + + return objectLikeConfig +} + +/** + * Takes a full config object from a different origin, finds the differences in the current config, and updates the config for the current origin. + * @param config The full config object from the other origin, serialized + * @param env The full env object from the other origin, serialized + * @returns Cypress.ObjectLike + */ +const syncToCurrentOrigin = (valuesFromOtherOrigin: Cypress.ObjectLike, valuesFromCurrentOrigin: Cypress.ObjectLike): Cypress.ObjectLike => { + // @ts-ignore + const shallowDifferencesInConfig = _.omitBy(valuesFromOtherOrigin, (value: any, key: string) => { + const valueToSync = value + const currentOriginValue = valuesFromCurrentOrigin[key] + + // if the values being compared are objects, do a value comparison to see if the contents of each object are identical + return _.isEqual(valueToSync, currentOriginValue) + }) + + return shallowDifferencesInConfig +} + +export const syncConfigToCurrentOrigin = (config: Cypress.Config) => { + const shallowConfigDiff = syncToCurrentOrigin(config, Cypress.config()) + const valuesToSync = omitConfigReadOnlyDifferences(shallowConfigDiff) + + Cypress.config(valuesToSync) +} + +export const syncEnvToCurrentOrigin = (env: Cypress.ObjectLike) => { + const shallowConfigDiff = syncToCurrentOrigin(env, Cypress.env()) + + Cypress.env(shallowConfigDiff) +} + +export const preprocessConfig = (config: Cypress.Config) => { + return preprocessForSerialization(config) as Cypress.Config +} + +export const preprocessEnv = (env: Cypress.ObjectLike) => { + return preprocessForSerialization(env) as Cypress.Config +} diff --git a/packages/driver/src/util/queue.ts b/packages/driver/src/util/queue.ts index 4a1564979d3f..1923086f51b2 100644 --- a/packages/driver/src/util/queue.ts +++ b/packages/driver/src/util/queue.ts @@ -77,7 +77,9 @@ export class Queue { // have to go in the opposite direction from outer -> inner rejectOuterAndCancelInner = (err) => { inner.cancel() - reject(err) + + // If this error is thrown after the promise is fulfilled, we still want to throw the error. + promise.isPending() ? reject(err) : onError(err) } }) .catch(onError) @@ -104,4 +106,16 @@ export class Queue { get stopped () { return this._stopped } + + /** + * Helper function to return the last item in the queue. + * @returns The last item or undefined if the queue is empty. + */ + last (): T | undefined { + if (this.length < 1) { + return undefined + } + + return this.at(this.length - 1) + } } diff --git a/packages/driver/src/util/serialization/index.ts b/packages/driver/src/util/serialization/index.ts new file mode 100644 index 000000000000..32cfc3ef353e --- /dev/null +++ b/packages/driver/src/util/serialization/index.ts @@ -0,0 +1,165 @@ +import _ from 'lodash' +import structuredClonePonyfill from 'core-js-pure/actual/structured-clone' +import $stackUtils from '../../cypress/stack_utils' +import $errUtils from '../../cypress/error_utils' + +export const UNSERIALIZABLE = '__cypress_unserializable_value' + +// If a native structuredClone exists, use that to determine if a value can be serialized or not. Otherwise, use the ponyfill. +// we need this because some implementations of SCA treat certain values as unserializable (ex: Error is serializable in ponyfill but NOT in firefox implementations) +// @ts-ignore +const structuredCloneRef = window?.structuredClone || structuredClonePonyfill + +export const isSerializableInCurrentBrowser = (value: any) => { + try { + structuredCloneRef(value) + + // @ts-ignore + if (Cypress.isBrowser('firefox') && _.isError(value) && structuredCloneRef !== window?.structuredClone) { + /** + * NOTE: structuredClone() was introduced in Firefox 94. Supported versions below 94 need to use the ponyfill + * to determine whether or not a value can be serialized through postMessage. Since the ponyfill deems Errors + * as clone-able, but postMessage does not in Firefox, we must make sure we do NOT attempt to send native errors through firefox + */ + return false + } + + // In some instances of structuredClone, Bluebird promises are considered serializable, but can be very deep objects + // For ours needs, we really do NOT want to serialize these + if (value instanceof Cypress.Promise) { + return false + } + + return true + } catch (e) { + return false + } +} + +/** + * Walks the prototype chain and finds any serializable properties that exist on the object or its prototypes. + * If the property can be serialized, the property is added to the literal. + * This means read-only properties are now read/write on the literal. + * + * Please see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#things_that_dont_work_with_structured_clone for more details. + * @param obj Object that is being converted + * @returns a new object void of prototype chain (object literal) with all serializable properties + */ +const convertObjectToSerializableLiteral = (obj): typeof obj => { + const allProps: string[] = [] + let currentObjectRef = obj + + do { + const props = Object.getOwnPropertyNames(currentObjectRef) + + props.forEach((prop: string) => { + try { + if (!allProps.includes(prop) && isSerializableInCurrentBrowser(currentObjectRef[prop])) { + allProps.push(prop) + } + } catch (err) { + /** + * In some browsers, properties of objects on the prototype chain point to the implementation object. + * Depending on implementation constraints, these properties may throw an error when accessed. + * + * ex: DOMException's prototype is Error, and calling the 'name' getter on DOMException's prototype + * throws a TypeError since Error does not implement the DOMException interface. + */ + if (err?.name !== 'TypeError') { + throw err + } + } + }) + + currentObjectRef = Object.getPrototypeOf(currentObjectRef) + } while (currentObjectRef) + + const objectAsLiteral = {} + + allProps.forEach((key) => { + objectAsLiteral[key] = obj[key] + }) + + return objectAsLiteral +} + +/** + * Sanitizes any unserializable values to prep for postMessage serialization. All Objects, including Errors, are mapped to an Object literal with + * whatever serialization properties they have, including their prototype hierarchy. + * This keeps behavior consistent between browsers without having to worry about the inner workings of structuredClone(). For example: + * + * chromium + * new Error('myError') -> Object literal with message key having value 'myError'. Also, other custom properties on the object are omitted and only name, message, and stack are preserved + * + * For instance: + * var a = new Error('myError') + * a.foo = 'bar' + * var b = structuredClone(a) + * b.foo // is undefined + * + * firefox + * structuredClone(new Error('myError')) -> throws error as native error cannot be serialized + * + * This method takes a similar approach as the 'chromium' structuredClone algorithm, except that the prototype chain is walked and ANY serializable value, including getters, is serialized. + * Please see https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#things_that_dont_work_with_structured_clone. + * + * NOTE: If an object nested inside valueToSanitize contains an unserializable property, the whole object is deemed as unserializable + * @param valueToSanitize subject of sanitization that might be unserializable or have unserializable properties + * @returns a serializable form of the subject. If the value passed in cannot be serialized, an error is thrown + * @throws '__cypress_unserializable_value' + */ +export const preprocessForSerialization = (valueToSanitize: { [key: string]: any }): T | undefined => { +// Even if native errors can be serialized through postMessage, many properties are omitted on structuredClone(), including prototypical hierarchy +// because of this, we preprocess native errors to objects and postprocess them once they come back to the primary origin + + // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays. This is important for commands like .selectFile() using buffer streams + if (_.isArray(valueToSanitize) || _.isTypedArray(valueToSanitize)) { + return _.map(valueToSanitize, preprocessForSerialization) as unknown as T + } + + if (_.isObject(valueToSanitize)) { + try { + const sanitizedValueAsLiteral = convertObjectToSerializableLiteral(valueToSanitize) as T + + // convert any nested structures as well, if objects or arrays, to literals. This is needed in the case of Proxy objects + _.forEach(sanitizedValueAsLiteral as any, (value, key) => { + sanitizedValueAsLiteral[key] = preprocessForSerialization(value) + }) + + return sanitizedValueAsLiteral + } catch (err) { + // if its not serializable, tell the primary to inform the user that the value thrown could not be serialized + throw UNSERIALIZABLE + } + } + + if (!isSerializableInCurrentBrowser(valueToSanitize)) { + throw UNSERIALIZABLE + } + + return valueToSanitize +} + +export const reifySerializedError = (serializedError: any, userInvocationStack: string) => { + // we have no idea what type the error this is... could be 'undefined', a plain old object, or something else entirely + + let reifiedError = $errUtils.errByPath('origin.failed_to_serialize_or_map_thrown_value') + + if (_.isArray(serializedError)) { + // if the error is an array of anything, create a normal error with the stringified values of the passed in array + reifiedError = new Error(serializedError.toString()) + } else if (_.isObject(serializedError as any)) { + // otherwise, try to determine if there are any error details in the object and merge the error objects together + let errorToMerge = serializedError?.message ? new Error(serializedError?.message || '') : reifiedError + + reifiedError = _.assignWith(errorToMerge, serializedError) + } else if (serializedError !== UNSERIALIZABLE) { + reifiedError = new Error(`${serializedError}`) + } + + reifiedError.onFail = () => {} + + reifiedError.stack = $stackUtils.replacedStack(reifiedError, userInvocationStack) + + return reifiedError +} diff --git a/packages/driver/src/util/serialization/log.ts b/packages/driver/src/util/serialization/log.ts new file mode 100644 index 000000000000..d8a19f0823c7 --- /dev/null +++ b/packages/driver/src/util/serialization/log.ts @@ -0,0 +1,425 @@ +import _ from 'lodash' +import { isSerializableInCurrentBrowser, preprocessForSerialization } from './index' +import $dom from '../../dom' + +interface PreprocessedHTMLElement { + tagName: string + attributes: { [key: string]: string } + innerHTML: string + serializationKey: 'dom' +} + +interface PreprocessedFunction { + value: any + serializationKey: 'function' +} + +/** + * Takes an HTMLElement that might be a for a given snapshot or any other element, likely pertaining to log consoleProps, + * on the page that needs to be preprocessed for serialization. The method here is to do a very shallow serialization, + * by trying to make the HTML as stateful as possible before preprocessing. + * + * @param {HTMLElement} props - an HTMLElement + * @returns {PreprocessedHTMLElement} a preprocessed element that can be fed through postMessage() that can be reified in the primary. + */ +export const preprocessDomElement = (props: HTMLElement) => { + const inputPreprocessArray = Array.from(props.querySelectorAll('input, textarea, select')) + + // Since we serialize on innerHTML, we also need to account for the props element itself in the case it is an input, select, or textarea. + inputPreprocessArray.push(props) + + // Hydrate values in the HTML copy so when serialized they show up correctly in snapshot. + // We do this by mapping certain properties to attributes that are not already reflected in the attributes map. + // Things like id, class, type, and others are reflected in the attribute map and do not need to be explicitly added. + inputPreprocessArray.forEach((el: any) => { + switch (el.type) { + case 'checkbox': + case 'radio': + if (el.checked) { + el.setAttribute('checked', '') + } + + break + case 'select-one': + case 'select-multiple': { + const options = el.type === 'select-one' ? el.options : el.selectedOptions + + if (el.selectedIndex !== -1) { + for (let option of options) { + if (option.selected) { + option.setAttribute('selected', 'true') + } else { + option.removeAttribute('selected') + } + } + } + } + break + case 'textarea': { + el.innerHTML = el.value + } + break + default: + if (el.value !== undefined) { + el.setAttribute('value', el.value) + } + } + }) + + const el: PreprocessedHTMLElement = { + tagName: props.tagName, + attributes: {}, + innerHTML: props.innerHTML, + serializationKey: 'dom', + } + + // get all attributes and classes off the element + props.getAttributeNames().forEach((attributeName) => { + el.attributes[attributeName] = props.getAttribute(attributeName) || '' + }) + + return el +} + +/** + * Takes an PreprocessedHTMLElement that might represent a given snapshot or any other element that needs to be reified + * after postMessage() serialization. The method here is to do a very basic reification, + * attempting to create an element based off the PreprocessedHTMLElement tagName, and populating some basic state if applicable, + * such as element type, id, value, classes, attributes, etc. + * + * @param {PreprocessedHTMLElement} props - a preprocessed element that was fed through postMessage() that need to be reified in the primary. + * @returns {HTMLElement} a reified element, likely a log snapshot, $el, or consoleProp elements. + */ +export const reifyDomElement = (props: any) => { + const reifiedEl = document.createElement(props.tagName) + + reifiedEl.innerHTML = props.innerHTML + + Object.keys(props.attributes).forEach((attribute) => { + reifiedEl.setAttribute(attribute, props.attributes[attribute]) + }) + + return reifiedEl +} + +/** + * Attempts to preprocess an Object/Array by excluding unserializable values except for DOM elements and possible functions (if attemptToSerializeFunctions is true). + * DOM elements are processed to a serializable object via preprocessDomElement, and functions are serialized to an object with a value key containing their output contents. + * + * @param {any} props an Object/Array that needs to be preprocessed before being sent through postMessage(). + * @param {boolean} [attemptToSerializeFunctions=false] - Whether or not the function should attempt to preprocess a function by invoking it. + * @returns + */ +export const preprocessObjectLikeForSerialization = (props, attemptToSerializeFunctions = false) => { + if (_.isArray(props)) { + return props.map((prop) => preprocessLogLikeForSerialization(prop, attemptToSerializeFunctions)) + } + + if (_.isPlainObject(props)) { + // only attempt to try and serialize dom elements and functions (if attemptToSerializeFunctions is set to true) + let objWithPossiblySerializableProps = _.pickBy(props, (value) => { + const isSerializable = isSerializableInCurrentBrowser(value) + + if (!isSerializable && $dom.isDom(value) || _.isFunction(value) || _.isObject(value)) { + return true + } + + return false + }) + + let objWithOnlySerializableProps = _.pickBy(props, (value) => isSerializableInCurrentBrowser(value)) + + // assign the properties we know we can serialize here + let preprocessed: any = preprocessForSerialization(objWithOnlySerializableProps) + + // and attempt to serialize possibly unserializable props here and fail gracefully if unsuccessful + _.forIn(objWithPossiblySerializableProps, (value, key) => { + preprocessed[key] = preprocessLogLikeForSerialization(value, attemptToSerializeFunctions) + }) + + return preprocessed + } + + return preprocessForSerialization(props) +} + +/** + * Attempts to take an Object and reify it correctly. Most of this is handled by reifyLogLikeFromSerialization, with the exception here being DOM elements. + * DOM elements, if needed to match against the snapshot DOM, are defined as getters on the object to have their values calculated at request. + * This is important for certain log items, such as consoleProps, to be rendered correctly against the snapshot. Other DOM elements, such as snapshots, do not need to be matched + * against the current DOM and can be reified immediately. Since there is a potential need for object getters to exist within an array, arrays are wrapped in a proxy, with array indices + * proxied to the reified object or array, and other methods proxying to the preprocessed array (such as native array methods like map, foreach, etc...). + * + * @param {Object} props - a preprocessed Object/Array that was fed through postMessage() that need to be reified in the primary. + * @param {boolean} matchElementsAgainstSnapshotDOM - whether DOM elements within the Object/Array should be matched against + * @returns {Object|Proxy} - a reified version of the Object or Array (Proxy). + */ +export const reifyObjectLikeForSerialization = (props, matchElementsAgainstSnapshotDOM) => { + let reifiedObjectOrArray = {} + + _.forIn(props, (value, key) => { + const val = reifyLogLikeFromSerialization(value, matchElementsAgainstSnapshotDOM) + + if (val?.serializationKey === 'dom') { + if (matchElementsAgainstSnapshotDOM) { + // dynamically calculate the element (snapshot or otherwise). + // This is important for consoleProp/$el based properties on the log because it calculates the requested element AFTER the snapshot has been rendered into the AUT. + reifiedObjectOrArray = { + ...reifiedObjectOrArray, + get [key] () { + return val.reifyElement() + }, + } + } else { + // The DOM element in question is something like a snapshot. It can be reified immediately + reifiedObjectOrArray[key] = val.reifyElement() + } + } else { + reifiedObjectOrArray[key] = reifyLogLikeFromSerialization(value, matchElementsAgainstSnapshotDOM) + } + }) + + // NOTE: transforms arrays into objects to have defined getters for DOM elements, and proxy back to that object via an ES6 Proxy. + if (_.isArray(props)) { + // if an array, map the array to our special getter object. + return new Proxy(reifiedObjectOrArray, { + get (target, name) { + return target[name] || props[name] + }, + }) + } + + // otherwise, just returned the object with our special getter + return reifiedObjectOrArray +} + +/** + * Attempts to take a generic data structure that is log-like and preprocess them for serialization. This generic may contain properties that are either + * a) unserializable entirely + * b) unserializable natively but can be processed to a serializable form (DOM elements or Functions) + * c) serializable + * + * DOM elements are preprocessed via some key properties + * (attributes, classes, ids, tagName, value) including their innerHTML. Before the innerHTML is captured, inputs are traversed to set their stateful value + * inside the DOM element. This is crucial for body copy snapshots that are being sent to the primary domain to make the snapshot 'stateful'. Functions, if + * explicitly stated, will be preprocessed with whatever value they return (assuming that value is serializable). If a value cannot be preprocessed for whatever reason, + * null is returned. + * + * + * NOTE: this function recursively calls itself to preprocess a log + * + * @param {any} props a generic variable that represents a value that needs to be preprocessed before being sent through postMessage(). + * @param {boolean} [attemptToSerializeFunctions=false] - Whether or not the function should attempt to preprocess a function by invoking it. USE WITH CAUTION! + * @returns {any} the serializable version of the generic. + */ +export const preprocessLogLikeForSerialization = (props, attemptToSerializeFunctions = false) => { + try { + if ($dom.isDom(props)) { + if (props.length !== undefined && $dom.isJquery(props)) { + const serializableArray: any[] = [] + + // in the case we are dealing with a jQuery array, preprocess to a native array to nuke any prevObject(s) or unserializable values + props.each((key) => serializableArray.push(preprocessLogLikeForSerialization(props[key], attemptToSerializeFunctions))) + + return serializableArray + } + + // otherwise, preprocess the element to an object with pertinent DOM properties + const serializedDom = preprocessDomElement(props) + + return serializedDom + } + + /** + * When preprocessing a log, there might be certain functions we want to attempt to serialize. + * One of these instances is the 'table' key in consoleProps, which has contents that CAN be serialized. + * If there are other functions that have serializable contents, the invoker/developer will need to be EXPLICIT + * in what needs serialization. Otherwise, functions should NOT be serialized. + */ + if (_.isFunction(props)) { + if (attemptToSerializeFunctions) { + return { + value: preprocessLogLikeForSerialization(props(), attemptToSerializeFunctions), + serializationKey: 'function', + } as PreprocessedFunction + } + + return null + } + + if (_.isObject(props)) { + return preprocessObjectLikeForSerialization(props, attemptToSerializeFunctions) + } + + return preprocessForSerialization(props) + } catch (e) { + return null + } +} + +/** + * Attempts to take in a preprocessed/serialized log-like attributes and reify them. DOM elements are lazily calculated via + * getter properties on an object. If these DOM elements are in an array, the array is defined as an ES6 proxy that + * ultimately proxies to these getter objects. Functions, if serialized, are rewrapped. If a value cannot be reified for whatever reason, + * null is returned. + * + * This is logLike because there is a need outside of logs, such as in the iframe-model in the runner. + * to serialize DOM elements, such as the final snapshot upon request. + * + * NOTE: this function recursively calls itself to reify a log + * + * @param {any} props - a generic variable that represents a value that has been preprocessed and sent through postMessage() and needs to be reified. + * @param {boolean} matchElementsAgainstSnapshotDOM - Whether or not the element should be reconstructed lazily + * against the currently rendered DOM (usually against a rendered snapshot) or should be completely recreated from scratch (common with snapshots as they will replace the DOM) + * @returns {any} the reified version of the generic. + */ +export const reifyLogLikeFromSerialization = (props, matchElementsAgainstSnapshotDOM = true) => { + try { + if (props?.serializationKey === 'dom') { + props.reifyElement = function () { + let reifiedElement + + // If the element needs to be matched against the currently rendered DOM. This is useful when analyzing consoleProps or $el in a log + // where elements need to be evaluated LAZILY after the snapshot is attached to the page. + // this option is set to false when reifying snapshots, since they will be replacing the current DOM when the user interacts with said snapshot. + if (matchElementsAgainstSnapshotDOM) { + const attributes = Object.keys(props.attributes).map((attribute) => { + return `[${attribute}="${props.attributes[attribute]}"]` + }).join('') + + const selector = `${props.tagName}${attributes}` + + reifiedElement = Cypress.$(selector) + + if (reifiedElement.length) { + return reifiedElement.length > 1 ? reifiedElement : reifiedElement[0] + } + } + + // if the element couldn't be found, return a synthetic copy that doesn't actually exist on the page + return reifyDomElement(props) + } + + return props + } + + if (props?.serializationKey === 'function') { + const reifiedFunctionData = reifyLogLikeFromSerialization(props.value, matchElementsAgainstSnapshotDOM) + + return () => reifiedFunctionData + } + + if (_.isObject(props)) { + return reifyObjectLikeForSerialization(props, matchElementsAgainstSnapshotDOM) + } + + return props + } catch (e) { + return null + } +} + +/** + * Preprocess a snapshot to a serializable form before piping them over through postMessage(). + * This method is also used by a spec bridge on request if a 'final state' snapshot is requested outside that of the primary domain + * + * @param {any} snapshot - a snapshot matching the same structure that is returned from cy.createSnapshot. + * @returns a serializable form of a snapshot, including a serializable with styles + */ +export const preprocessSnapshotForSerialization = (snapshot) => { + try { + const preprocessedSnapshot = preprocessLogLikeForSerialization(snapshot, true) + + if (!preprocessedSnapshot.body.get) { + return null + } + + preprocessedSnapshot.styles = cy.getStyles(snapshot) + + return preprocessedSnapshot + } catch (e) { + return null + } +} + +/** + * Reifies a snapshot from the serializable from to an actual HTML body snapshot that exists in the primary document. + * @param {any} snapshot - a snapshot that has been preprocessed and sent through post message and needs to be reified in the primary. + * @returns the reified snapshot that exists in the primary document + */ +export const reifySnapshotFromSerialization = (snapshot) => { + snapshot.body = reifyLogLikeFromSerialization(snapshot.body, false) + + return cy.createSnapshot(snapshot.name, null, snapshot) +} + +/** + * Sanitizes the log messages going to the primary domain before piping them to postMessage(). + * This is designed to function as an extension of preprocessForSerialization, but also attempts to serialize DOM elements, + * as well as functions if explicitly stated. + * + * DOM elements are serialized with their outermost properties (attributes, classes, ids, tagName) including their innerHTML. + * DOM Traversal serialization is not possible with larger html bodies and will likely cause a stack overflow. + * + * Functions are serialized when explicitly state (ex: table in consoleProps). + * NOTE: If not explicitly handling function serialization for a given property, the property will be set to null + * + * @param logAttrs raw log attributes passed in from either a log:changed or log:added event + * @returns a serializable form of the log, including attempted serialization of DOM elements and Functions (if explicitly stated) + */ +export const preprocessLogForSerialization = (logAttrs) => { + let { snapshots, ...logAttrsRest } = logAttrs + + const preprocessed = preprocessLogLikeForSerialization(logAttrsRest) + + if (preprocessed) { + if (snapshots) { + preprocessed.snapshots = snapshots.map((snapshot) => preprocessSnapshotForSerialization(snapshot)) + } + + if (logAttrs?.consoleProps?.table) { + preprocessed.consoleProps.table = preprocessLogLikeForSerialization(logAttrs.consoleProps.table, true) + } + } + + return preprocessed +} + +/** + * Redefines log messages being received in the primary domain before sending them out through the event-manager. + * + * Efforts here include importing captured snapshots from the spec bridge into the primary snapshot document, importing inline + * snapshot styles into the snapshot css map, and reconstructing DOM elements and functions somewhat naively. + * + * To property render consoleProps/$el elements in snapshots or the console, DOM elements are lazily calculated via + * getter properties on an object. If these DOM elements are in an array, the array is defined as an ES6 proxy that + * ultimately proxies to these getter objects. + * + * The secret here is that consoleProp DOM elements needs to be reified at console printing runtime AFTER the serialized snapshot + * is attached to the DOM so the element can be located and displayed properly + * + * In most cases, the element can be queried by attributes that exist specifically on the element or by the `HIGHLIGHT_ATTR`. If that fails or does not locate an element, + * then a new element is created against the snapshot context. This element will NOT be found on the page, but will represent what the element + * looked like at the time of the snapshot. + * + * @param logAttrs serialized/preprocessed log attributes passed to the primary domain from a spec bridge + * @returns a reified version of what a log is supposed to look like in Cypress + */ +export const reifyLogFromSerialization = (logAttrs) => { + let { snapshots, ... logAttrsRest } = logAttrs + + if (snapshots) { + snapshots = snapshots.filter((snapshot) => !!snapshot).map((snapshot) => reifySnapshotFromSerialization(snapshot)) + } + + const reified = reifyLogLikeFromSerialization(logAttrsRest) + + if (reified.$el && reified.$el.length) { + // Make sure $els are jQuery Arrays to keep what is expected in the log. + reified.$el = Cypress.$(reified.$el.map((el) => el)) + } + + reified.snapshots = snapshots + + return reified +} diff --git a/packages/driver/types/cy/logGroup.d.ts b/packages/driver/types/cy/logGroup.d.ts index 1d2f1a73b7b9..9d496bbd2520 100644 --- a/packages/driver/types/cy/logGroup.d.ts +++ b/packages/driver/types/cy/logGroup.d.ts @@ -3,7 +3,7 @@ declare namespace Cypress { declare namespace LogGroup { type ApiCallback = (log: Cypress.Log) => Chainable type LogGroup = (cypress: Cypress.Cypress, options: Partial, callback: LogGroupCallback) => Chainable - + interface Config { // the JQuery element for the command. This will highlight the command // in the main window when debugging diff --git a/packages/driver/types/cypress/log.d.ts b/packages/driver/types/cypress/log.d.ts index 366b915646a3..877f729a375c 100644 --- a/packages/driver/types/cypress/log.d.ts +++ b/packages/driver/types/cypress/log.d.ts @@ -10,53 +10,110 @@ declare namespace Cypress { groupEnd(): void } - interface InternalLogConfig { - // defaults to command - instrument?: 'agent' | 'command' | 'route' - // name of the log + type ReferenceAlias = { + cardinal: number, + name: string, + ordinal: string, + } + + type Snapshot = { + body?: {get: () => any}, + htmlAttrs?: {[key: string]: any}, name?: string - // the name override for display purposes only - displayName?: string - // additional information to include in the log if not overridden - // the render props message - // defaults to command arguments for command instrument - message?: string | Array | any[] - // whether or not the xhr route had a corresponding response stubbed out - isStubbed?: boolean + } + + type ConsoleProps = { + Command?: string + Snapshot?: string + Elements?: number + Selector?: string + Yielded?: HTMLElement + Event?: string + Message?: string + actual?: any + expected?: any + } + + type RenderProps = { + indicator?: 'aborted' | 'pending' | 'successful' | 'bad' + message?: string + } + + interface InternalLogConfig { + // the JQuery element for the command. This will highlight the command + // in the main window when debugging + $el?: JQuery | string alias?: string aliasType?: 'agent' | 'route' | 'primitive' | 'dom' | undefined + browserPreRequest?: any + callCount?: number + chainerId?: string commandName?: string - // the JQuery element for the command. This will highlight the command - // in the main window when debugging - $el?: JQuery + // provide the content to display in the dev tool's console when a log is + // clicked from the Reporter's Command Log + consoleProps?: () => Command | Command + coords?: { + left: number + leftCenter: number + top: number + topCenter: number + x: number + y: number + } + count?: number + // the name override for display purposes only + displayName?: string // whether or not to show the log in the Reporter UI or only // store the log details on the command and log manager emitOnly?: boolean - // whether or not to start a new log group - groupStart?: boolean - // the type of log - // system - log generated by Cypress - // parent - log generated by Command - // child - log generated by Chained Command - type?: 'system' | 'parent' | 'child' | ((current: State['state']['current'], subject: State['state']['subject']) => 'parent' | 'child') + end?: boolean + ended?: boolean + err?: Error + error?: Error // whether or not the generated log was an event or command event?: boolean + expected?: string + functionName?: string + // whether or not to start a new log group + groupStart?: boolean + hookId?: number + id?: string + // defaults to command + instrument?: 'agent' | 'command' | 'route' + // whether or not the xhr route had a corresponding response stubbed out + isStubbed?: boolean + // additional information to include in the log if not overridden + // the render props message + // defaults to command arguments for command instrument + message?: string | Array | any[] method?: string - url?: string - status?: number + // name of the log + name?: string + numElements?: number // the number of xhr responses that occurred. This is only applicable to // logs defined with instrument=route numResponses?: number + referencesAlias?: ReferenceAlias[] + renderProps?: () => RenderProps | RenderProps response?: string | object - // provide the content to display in the dev tool's console when a log is - // clicked from the Reporter's Command Log - consoleProps?: () => ObjectLike - renderProps?: () => { - indicator?: 'aborted' | 'pending' | 'successful' | 'bad' - message?: string - } - browserPreRequest?: any + selector?: any + snapshot?: boolean + snapshots?: [] + state?: "failed" | "passed" | "pending" // representative of Mocha.Runnable.constants (not publicly exposed by Mocha types) + status?: number + testCurrentRetry?: number + testId?: string // timeout of the group command - defaults to defaultCommandTimeout timeout?: number + // the type of log + // system - log generated by Cypress + // parent - log generated by Command + // child - log generated by Chained Command + type?: 'system' | 'parent' | 'child' | ((current: State['state']['current'], subject: State['state']['subject']) => 'parent' | 'child') + url?: string + viewportHeight?: number + viewportWidth?: number + visible?: boolean + wallClockStartedAt?: string } } diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index 9dde8c54ffb8..e310a6481e9f 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -3,27 +3,52 @@ /// /// /// +/// + +interface InternalWindowLoadDetails { + type: 'same:origin' | 'cross:origin' | 'cross:origin:failure' + error?: Error + window?: AUTWindow +} declare namespace Cypress { interface Actions { + (action: 'internal:window:load', fn: (details: InternalWindowLoadDetails) => void) (action: 'net:stubbing:event', frame: any) (action: 'request:event', data: any) + (action: 'backend:request', fn: (...any) => void) + (action: 'automation:request', fn: (...any) => void) + (action: 'viewport:changed', fn?: (viewport: { viewportWidth: string, viewportHeight: string }, callback: () => void) => void) + (action: 'before:screenshot', fn: (config: {}, fn: () => void) => void) + (action: 'after:screenshot', config: {}) + } + + interface Backend { + (task: 'cross:origin:release:html'): boolean + (task: 'cross:origin:bridge:ready', args: { originPolicy?: string }): boolean + (task: 'cross:origin:finished', originPolicy: string): boolean } interface cy { /** * If `as` is chained to the current command, return the alias name used. */ - getNextAlias: () => string | undefined + getNextAlias: IAliases['getNextAlias'] noop: (v: T) => Cypress.Chainable - queue: any - retry: (fn: () => any, opts: any) => any + queue: CommandQueue + retry: IRetries['retry'] state: State - pauseTimers: (shouldPause: boolean) => Cypress.Chainable + pauseTimers: ITimer['pauseTimers'] // TODO: this function refers to clearTimeout at cy/timeouts.ts, which doesn't have any argument. // But in many cases like cy/commands/screenshot.ts, it's called with a timeout id string. // We should decide whether calling with id is correct or not. - clearTimeout: (timeoutId?: string) => Cypress.Chainable + clearTimeout: ITimeouts['clearTimeout'] + isStable: IStability['isStable'] + isAnticipatingCrossOriginResponseFor: IStability['isAnticipatingCrossOriginResponseFor'] + fail: (err: Error, options:{ async?: boolean }) => Error + getRemoteLocation: ILocation['getRemoteLocation'] + createSnapshot: ISnapshots['createSnapshot'] + getStyles: ISnapshots['getStyles'] } interface Cypress { @@ -39,7 +64,14 @@ declare namespace Cypress { sinon: sinon.SinonApi utils: CypressUtils state: State - originalConfig: Record + events: Events + emit: (event: string, payload?: any) => void + primaryOriginCommunicator: import('../src/multi-domain/communicator').PrimaryOriginCommunicator + specBridgeCommunicator: import('../src/multi-domain/communicator').SpecBridgeCommunicator + mocha: $Mocha + configure: (config: Cypress.ObjectLike) => void + isCrossOriginSpecBridge: boolean + originalConfig: Cypress.ObjectLike } interface CypressUtils { @@ -57,13 +89,25 @@ declare namespace Cypress { (k: 'document', v?: Document): Document (k: 'window', v?: Window): Window (k: 'logGroupIds', v?: Array): Array + (k: 'autOrigin', v?: string): string + (k: 'originCommandBaseUrl', v?: string): string + (k: 'currentActiveOriginPolicy', v?: string): string + (k: 'latestActiveOriginPolicy', v?: string): string + (k: 'duringUserTestExecution', v?: boolean): boolean + (k: 'onQueueEnd', v?: () => void): () => void + (k: 'onFail', v?: (err: Error) => void): (err: Error) => void (k: string, v?: any): any state: Cypress.state } + interface InternalConfig { + (k: keyof ResolvedConfigOptions, v?: any): any + } + interface ResolvedConfigOptions { $autIframe: JQuery document: Document + projectRoot?: string } } @@ -71,3 +115,6 @@ type AliasedRequest = { alias: string request: any } + +// utility types +type PartialBy = Omit & Partial> diff --git a/packages/driver/types/remote-state.d.ts b/packages/driver/types/remote-state.d.ts new file mode 100644 index 000000000000..443f83a66d85 --- /dev/null +++ b/packages/driver/types/remote-state.d.ts @@ -0,0 +1,14 @@ +declare namespace Cypress { + interface RemoteState { + auth?: Auth + domainName: string + strategy: 'file' | 'http' + origin: string + fileServer: string | null + props: Record + } + + interface RuntimeConfigOptions { + remote: RemoteState + } +} diff --git a/packages/driver/types/spec-types.d.ts b/packages/driver/types/spec-types.d.ts new file mode 100644 index 000000000000..c0ebdbfc6d2a --- /dev/null +++ b/packages/driver/types/spec-types.d.ts @@ -0,0 +1,8 @@ +// NOTE: This is for internal Cypress spec types that exist in support/utils.js for testing convenience and do not ship with Cypress + +declare namespace Cypress { + interface Chainable { + getAll(...aliases: string[]): Chainable + shouldWithTimeout(cb: (subj: {}) => void, timeout?: number): Chainable + } +} diff --git a/packages/errors/__snapshot-html__/EXPERIMENTAL_SESSION_SUPPORT_REMOVED.html b/packages/errors/__snapshot-html__/EXPERIMENTAL_SESSION_SUPPORT_REMOVED.html new file mode 100644 index 000000000000..9b86f4dc903b --- /dev/null +++ b/packages/errors/__snapshot-html__/EXPERIMENTAL_SESSION_SUPPORT_REMOVED.html @@ -0,0 +1,40 @@ + + + + + + + + + + + +
The experimentalSessionSupport configuration option was removed in Cypress version 9.6.0 and replaced with experimentalSessionAndOrigin. Please update your config to use experimentalSessionAndOrigin instead.
+
+https://on.cypress.io/session
+
\ No newline at end of file diff --git a/packages/errors/src/errors.ts b/packages/errors/src/errors.ts index 84825fd8699d..10e6113467bf 100644 --- a/packages/errors/src/errors.ts +++ b/packages/errors/src/errors.ts @@ -1050,6 +1050,12 @@ export const AllCypressErrors = { https://on.cypress.io/migration-guide` }, + EXPERIMENTAL_SESSION_SUPPORT_REMOVED: () => { + return errTemplate`\ + The ${fmt.highlight(`experimentalSessionSupport`)} configuration option was removed in ${fmt.cypressVersion(`9.6.0`)} and replaced with ${fmt.highlight(`experimentalSessionAndOrigin`)}. Please update your config to use ${fmt.highlight(`experimentalSessionAndOrigin`)} instead. + + https://on.cypress.io/session` + }, EXPERIMENTAL_SHADOW_DOM_REMOVED: () => { return errTemplate`\ The ${fmt.highlight(`experimentalShadowDomSupport`)} configuration option was removed in ${fmt.cypressVersion(`5.2.0`)}. It is no longer necessary when utilizing the ${fmt.highlightSecondary(`includeShadowDom`)} option. diff --git a/packages/errors/test/unit/visualSnapshotErrors_spec.ts b/packages/errors/test/unit/visualSnapshotErrors_spec.ts index 9beadb63f3b4..a3be0afe371e 100644 --- a/packages/errors/test/unit/visualSnapshotErrors_spec.ts +++ b/packages/errors/test/unit/visualSnapshotErrors_spec.ts @@ -1001,6 +1001,11 @@ describe('visual error templates', () => { default: [{ configFile: '/path/to/configFile.json' }], } }, + EXPERIMENTAL_SESSION_SUPPORT_REMOVED: () => { + return { + default: [], + } + }, EXPERIMENTAL_SHADOW_DOM_REMOVED: () => { return { default: [], diff --git a/packages/extension/app/background.js b/packages/extension/app/background.js index 6b5be6aff3a4..9856bbc68370 100644 --- a/packages/extension/app/background.js +++ b/packages/extension/app/background.js @@ -1,3 +1,4 @@ +const get = require('lodash/get') const map = require('lodash/map') const pick = require('lodash/pick') const once = require('lodash/once') @@ -18,6 +19,16 @@ const firstOrNull = (cookies) => { return cookies[0] != null ? cookies[0] : null } +const checkIfFirefox = async () => { + if (!browser || !get(browser, 'runtime.getBrowserInfo')) { + return false + } + + const { name } = await browser.runtime.getBrowserInfo() + + return name === 'Firefox' +} + const connect = function (host, path, extraOpts) { const listenToCookieChanges = once(() => { return browser.cookies.onChanged.addListener((info) => { @@ -46,6 +57,32 @@ const connect = function (host, path, extraOpts) { }) }) + const listenToOnBeforeHeaders = once(() => { + // adds a header to the request to mark it as a request for the AUT frame + // itself, so the proxy can utilize that for injection purposes + browser.webRequest.onBeforeSendHeaders.addListener((details) => { + if ( + // parentFrameId: 0 means the parent is the top-level, so if it isn't + // 0, it's nested inside the AUT and can't be the AUT itself + details.parentFrameId !== 0 + // isn't an iframe + || details.type !== 'sub_frame' + // is the spec frame, not the AUT + || details.url.includes('__cypress') + ) return + + return { + requestHeaders: [ + ...details.requestHeaders, + { + name: 'X-Cypress-Is-AUT-Frame', + value: 'true', + }, + ], + } + }, { urls: [''] }, ['blocking', 'requestHeaders']) + }) + const fail = (id, err) => { return ws.emit('automation:response', id, { __error: err.message, @@ -93,11 +130,22 @@ const connect = function (host, path, extraOpts) { } }) - ws.on('connect', () => { + ws.on('automation:config', async (config) => { + const isFirefox = await checkIfFirefox() + listenToCookieChanges() - listenToDownloads() + // Non-Firefox browsers use CDP for these instead + if (isFirefox) { + listenToDownloads() - return ws.emit('automation:client:connected') + if (config.experimentalSessionAndOrigin) { + listenToOnBeforeHeaders() + } + } + }) + + ws.on('connect', () => { + ws.emit('automation:client:connected') }) return ws diff --git a/packages/extension/app/manifest.json b/packages/extension/app/manifest.json index 5855a80d85d3..3248b1e161e8 100644 --- a/packages/extension/app/manifest.json +++ b/packages/extension/app/manifest.json @@ -10,6 +10,8 @@ "cookies", "downloads", "tabs", + "webRequest", + "webRequestBlocking", "http://*/*", "https://*/*", "" diff --git a/packages/extension/test/integration/background_spec.js b/packages/extension/test/integration/background_spec.js index 2d1758979569..1558e0a966ac 100644 --- a/packages/extension/test/integration/background_spec.js +++ b/packages/extension/test/integration/background_spec.js @@ -4,6 +4,7 @@ const http = require('http') const socket = require('@packages/socket') const Promise = require('bluebird') const mockRequire = require('mock-require') +const client = require('../../app/client') const browser = { cookies: { @@ -25,14 +26,17 @@ const browser = { windows: { getLastFocused () {}, }, - runtime: { - - }, + runtime: {}, tabs: { query () {}, executeScript () {}, captureVisibleTab () {}, }, + webRequest: { + onBeforeSendHeaders: { + addListener () {}, + }, + }, } mockRequire('webextension-polyfill', browser) @@ -106,21 +110,28 @@ const tab3 = { describe('app/background', () => { beforeEach(function (done) { + global.window = {} + this.httpSrv = http.createServer() this.server = socket.server(this.httpSrv, { path: '/__socket.io' }) - this.onConnect = (callback) => { - const client = background.connect(`http://localhost:${PORT}`, '/__socket.io') - - client.on('connect', _.once(() => { - callback(client) - })) + const ws = { + on: sinon.stub(), + emit: sinon.stub(), } - this.stubEmit = (callback) => { - this.onConnect((client) => { - client.emit = _.once(callback) - }) + sinon.stub(client, 'connect').returns(ws) + + browser.runtime.getBrowserInfo = sinon.stub().resolves({ name: 'Firefox' }), + + this.connect = async (options = {}) => { + const ws = background.connect(`http://localhost:${PORT}`, '/__socket.io') + + // skip 'connect' and 'automation:client:connected' and trigger + // the handler that kicks everything off + await ws.on.withArgs('automation:config').args[0][1](options) + + return ws } this.httpSrv.listen(PORT, done) @@ -135,72 +146,47 @@ describe('app/background', () => { }) context('.connect', () => { - it('can connect', function (done) { - this.server.on('connection', () => { - return done() - }) - - return background.connect(`http://localhost:${PORT}`, '/__socket.io') - }) - - it('emits \'automation:client:connected\'', (done) => { - const client = background.connect(`http://localhost:${PORT}`, '/__socket.io') - - sinon.spy(client, 'emit') + it('emits \'automation:client:connected\'', async function () { + const ws = background.connect(`http://localhost:${PORT}`, '/__socket.io') - return client.on('connect', _.once(() => { - expect(client.emit).to.be.calledWith('automation:client:connected') + await ws.on.withArgs('connect').args[0][1]() - return done() - })) + expect(ws.emit).to.be.calledWith('automation:client:connected') }) - it('listens to cookie changes', (done) => { + it('listens to cookie changes', async function () { const addListener = sinon.stub(browser.cookies.onChanged, 'addListener') - const client = background.connect(`http://localhost:${PORT}`, '/__socket.io') - return client.on('connect', _.once(() => { - expect(addListener).to.be.calledOnce + await this.connect() - return done() - })) + expect(addListener).to.be.calledOnce }) }) context('cookies', () => { - it('onChanged does not emit when cause is overwrite', function (done) { + it('onChanged does not emit when cause is overwrite', async function () { const addListener = sinon.stub(browser.cookies.onChanged, 'addListener') + const ws = await this.connect() + const fn = addListener.getCall(0).args[0] - this.onConnect((client) => { - sinon.spy(client, 'emit') + fn({ cause: 'overwrite' }) - const fn = addListener.getCall(0).args[0] - - fn({ cause: 'overwrite' }) - - expect(client.emit).not.to.be.calledWith('automation:push:request') - - done() - }) + expect(ws.emit).not.to.be.calledWith('automation:push:request') }) - it('onChanged emits automation:push:request change:cookie', function (done) { + it('onChanged emits automation:push:request change:cookie', async function () { const info = { cause: 'explicit', cookie: { name: 'foo', value: 'bar' } } - sinon.stub(browser.cookies.onChanged, 'addListener').yieldsAsync(info) + sinon.stub(browser.cookies.onChanged, 'addListener').yields(info) - this.stubEmit((req, msg, data) => { - expect(req).to.eq('automation:push:request') - expect(msg).to.eq('change:cookie') - expect(data).to.deep.eq(info) + const ws = await this.connect() - done() - }) + expect(ws.emit).to.be.calledWith('automation:push:request', 'change:cookie', info) }) }) context('downloads', () => { - it('onCreated emits automation:push:request create:download', function (done) { + it('onCreated emits automation:push:request create:download', async function () { const downloadItem = { id: '1', filename: '/path/to/download.csv', @@ -208,23 +194,19 @@ describe('app/background', () => { url: 'http://localhost:1234/download.csv', } - sinon.stub(browser.downloads.onCreated, 'addListener').yieldsAsync(downloadItem) + sinon.stub(browser.downloads.onCreated, 'addListener').yields(downloadItem) - this.stubEmit((req, msg, data) => { - expect(req).to.eq('automation:push:request') - expect(msg).to.eq('create:download') - expect(data).to.deep.eq({ - id: `${downloadItem.id}`, - filePath: downloadItem.filename, - mime: downloadItem.mime, - url: downloadItem.url, - }) + const ws = await this.connect() - done() + expect(ws.emit).to.be.calledWith('automation:push:request', 'create:download', { + id: `${downloadItem.id}`, + filePath: downloadItem.filename, + mime: downloadItem.mime, + url: downloadItem.url, }) }) - it('onChanged emits automation:push:request complete:download', function (done) { + it('onChanged emits automation:push:request complete:download', async function () { const downloadDelta = { id: '1', state: { @@ -232,34 +214,29 @@ describe('app/background', () => { }, } - sinon.stub(browser.downloads.onChanged, 'addListener').yieldsAsync(downloadDelta) + sinon.stub(browser.downloads.onChanged, 'addListener').yields(downloadDelta) - this.stubEmit((req, msg, data) => { - expect(req).to.eq('automation:push:request') - expect(msg).to.eq('complete:download') - expect(data).to.deep.eq({ id: `${downloadDelta.id}` }) + const ws = await this.connect() - done() + expect(ws.emit).to.be.calledWith('automation:push:request', 'complete:download', { + id: `${downloadDelta.id}`, }) }) - it('onChanged does not emit if state does not exist', function (done) { + it('onChanged does not emit if state does not exist', async function () { const downloadDelta = { id: '1', } const addListener = sinon.stub(browser.downloads.onChanged, 'addListener') - this.onConnect((client) => { - sinon.spy(client, 'emit') - addListener.getCall(0).args[0](downloadDelta) + const ws = await this.connect() - expect(client.emit).not.to.be.calledWith('automation:push:request') + addListener.getCall(0).args[0](downloadDelta) - done() - }) + expect(ws.emit).not.to.be.calledWith('automation:push:request') }) - it('onChanged does not emit if state.current is not "complete"', function (done) { + it('onChanged does not emit if state.current is not "complete"', async function () { const downloadDelta = { id: '1', state: { @@ -268,16 +245,135 @@ describe('app/background', () => { } const addListener = sinon.stub(browser.downloads.onChanged, 'addListener') - this.onConnect((client) => { - sinon.spy(client, 'emit') + const ws = await this.connect() + + addListener.getCall(0).args[0](downloadDelta) + + expect(ws.emit).not.to.be.calledWith('automation:push:request') + }) + + it('does not add downloads listener if in non-Firefox browser', async function () { + browser.runtime.getBrowserInfo = undefined + + const onCreated = sinon.stub(browser.downloads.onCreated, 'addListener') + const onChanged = sinon.stub(browser.downloads.onChanged, 'addListener') + + await this.connect() + + expect(onCreated).not.to.be.called + expect(onChanged).not.to.be.called + }) + }) + + context('add header to aut iframe requests', () => { + const withExperimentalFlagOn = { + experimentalSessionAndOrigin: true, + } + + it('does not listen to `onBeforeSendHeaders` if experimental flag is off', async function () { + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') - addListener.getCall(0).args[0](downloadDelta) + await this.connect() - expect(client.emit).not.to.be.calledWith('automation:push:request') + expect(browser.webRequest.onBeforeSendHeaders.addListener).not.to.be.called + }) + + it('does not add header if it is the top frame', async function () { + const details = { + parentFrameId: -1, + } + + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.be.undefined + }) + + it('does not add header if it is a nested frame', async function () { + const details = { + parentFrameId: 12345, + } + + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.be.undefined + }) + + it('does not add header if it is not a sub frame request', async function () { + const details = { + parentFrameId: 0, + type: 'stylesheet', + } + + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - done() + expect(result).to.be.undefined + }) + + it('does not add header if it is a spec frame request', async function () { + const details = { + parentFrameId: 0, + type: 'sub_frame', + url: '/__cypress/integration/spec.js', + } + + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.be.undefined + }) + + it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', async function () { + const details = { + parentFrameId: 0, + type: 'sub_frame', + url: 'http://localhost:3000/index.html', + requestHeaders: [ + { name: 'X-Foo', value: 'Bar' }, + ], + } + + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.deep.equal({ + requestHeaders: [ + { + name: 'X-Foo', + value: 'Bar', + }, + { + name: 'X-Cypress-Is-AUT-Frame', + value: 'true', + }, + ], }) }) + + it('does not add before-headers listener if in non-Firefox browser', async function () { + browser.runtime.getBrowserInfo = undefined + + const onBeforeSendHeaders = sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + + expect(onBeforeSendHeaders).not.to.be.called + }) }) context('.getAll', () => { @@ -397,6 +493,9 @@ describe('app/background', () => { context('integration', () => { beforeEach(function (done) { done = _.once(done) + + client.connect.restore() + this.server.on('connection', (socket1) => { this.socket = socket1 diff --git a/packages/network/lib/cors.ts b/packages/network/lib/cors.ts index a9b4cb876ef4..a6a3fa119ded 100644 --- a/packages/network/lib/cors.ts +++ b/packages/network/lib/cors.ts @@ -2,18 +2,13 @@ import _ from 'lodash' import * as uri from './uri' import debugModule from 'debug' import _parseDomain, { ParsedDomain } from '@cypress/parse-domain' +import type { ParsedHost } from './types' const debug = debugModule('cypress:network:cors') // match IP addresses or anything following the last . const customTldsRe = /(^[\d\.]+$|\.[^\.]+$)/ -type ParsedHost = { - port?: string - tld?: string - domain?: string -} - export function getSuperDomain (url) { const parsed = parseUrlIntoDomainTldPort(url) @@ -66,6 +61,16 @@ export function parseUrlIntoDomainTldPort (str) { return obj } +export function getDomainNameFromUrl (url: string) { + const parsedHost = parseUrlIntoDomainTldPort(url) + + return getDomainNameFromParsedHost(parsedHost) +} + +export function getDomainNameFromParsedHost (parsedHost: ParsedHost) { + return _.compact([parsedHost.domain, parsedHost.tld]).join('.') +} + export function urlMatchesOriginPolicyProps (urlStr, props) { // take a shortcut here in the case // where remoteHostAndPort is null @@ -79,9 +84,26 @@ export function urlMatchesOriginPolicyProps (urlStr, props) { return _.isEqual(parsedUrl, props) } +export function urlOriginsMatch (url1, url2) { + if (!url1 || !url2) return false + + const parsedUrl1 = parseUrlIntoDomainTldPort(url1) + const parsedUrl2 = parseUrlIntoDomainTldPort(url2) + + return _.isEqual(parsedUrl1, parsedUrl2) +} + export function urlMatchesOriginProtectionSpace (urlStr, origin) { const normalizedUrl = uri.addDefaultPort(urlStr).format() const normalizedOrigin = uri.addDefaultPort(origin).format() return _.startsWith(normalizedUrl, normalizedOrigin) } + +export function getOriginPolicy (url: string) { + const { port, protocol } = new URL(url) + + // origin policy is comprised of: + // protocol + superdomain + port (subdomain is not factored in) + return _.compact([`${protocol}//${getSuperDomain(url)}`, port]).join(':') +} diff --git a/packages/network/lib/types.ts b/packages/network/lib/types.ts new file mode 100644 index 000000000000..c3cf8a2736dc --- /dev/null +++ b/packages/network/lib/types.ts @@ -0,0 +1,5 @@ +export type ParsedHost = { + port?: string + tld?: string + domain?: string +} diff --git a/packages/network/lib/uri.ts b/packages/network/lib/uri.ts index 1d88cf47c138..e7e8d83f7b18 100644 --- a/packages/network/lib/uri.ts +++ b/packages/network/lib/uri.ts @@ -6,7 +6,7 @@ // - https://nodejs.org/api/url.html#url_url_format_urlobject import _ from 'lodash' -import url from 'url' +import url, { URL } from 'url' // yup, protocol contains a: ':' colon // at the end of it (-______________-) @@ -87,3 +87,19 @@ export function addDefaultPort (urlToCheck) { export function getPath (urlToCheck) { return url.parse(urlToCheck).path } + +const localhostIPRegex = /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + +export function isLocalhost (url: URL) { + return ( + // https://datatracker.ietf.org/doc/html/draft-west-let-localhost-be-localhost#section-2 + url.hostname === 'localhost' + || url.hostname.endsWith('.localhost') + // [::1] is the IPv6 localhost address + // See https://datatracker.ietf.org/doc/html/rfc4291#section-2.5.3 + || url.hostname === '[::1]' + // 127.0.0.0/8 are considered localhost for IPv4 + // See https://datatracker.ietf.org/doc/html/rfc5735 (Page 3) + || localhostIPRegex.test(url.hostname) + ) +} diff --git a/packages/network/test/unit/cors_spec.ts b/packages/network/test/unit/cors_spec.ts index 5031badf4c9a..76870b113624 100644 --- a/packages/network/test/unit/cors_spec.ts +++ b/packages/network/test/unit/cors_spec.ts @@ -261,4 +261,16 @@ describe('lib/cors', () => { isNotMatch('http://foo.example.com/', 'http://foo.bar.example.com') }) }) + + context('.getOriginPolicy', () => { + it('ports', () => { + expect(cors.getOriginPolicy('https://example.com')).to.equal('https://example.com') + expect(cors.getOriginPolicy('http://example.com:8080')).to.equal('http://example.com:8080') + }) + + it('subdomain', () => { + expect(cors.getOriginPolicy('http://www.example.com')).to.equal('http://example.com') + expect(cors.getOriginPolicy('http://www.app.herokuapp.com:8080')).to.equal('http://app.herokuapp.com:8080') + }) + }) }) diff --git a/packages/network/test/unit/uri_spec.ts b/packages/network/test/unit/uri_spec.ts new file mode 100644 index 000000000000..3d8fe82c9568 --- /dev/null +++ b/packages/network/test/unit/uri_spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai' +import { URL } from 'url' + +import { uri } from '../../lib' + +describe('lib/uri', () => { + context('.isLocalhost', () => { + it('http://localhost is localhost', () => { + expect(uri.isLocalhost(new URL('http://localhost'))).to.be.true + }) + + it('https://localhost is localhost', () => { + expect(uri.isLocalhost(new URL('https://localhost'))).to.be.true + }) + + it('http://127.0.0.1 is localhost', () => { + expect(uri.isLocalhost(new URL('http://127.0.0.1'))).to.be.true + }) + + it('http://127.0.0.9 is localhost', () => { + expect(uri.isLocalhost(new URL('http://127.0.0.9'))).to.be.true + }) + + it('http://[::1] is localhost', () => { + expect(uri.isLocalhost(new URL('http://[::1]'))).to.be.true + }) + + it('http://128.0.0.1 is NOT localhost', () => { + expect(uri.isLocalhost(new URL('http://128.0.0.1'))).to.be.false + }) + + it('http:foobar.com is NOT localhost', () => { + expect(uri.isLocalhost(new URL('http:foobar.com'))).to.be.false + }) + + it('https:foobar.com is NOT localhost', () => { + expect(uri.isLocalhost(new URL('https:foobar.com'))).to.be.false + }) + }) +}) diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index f6c84930c34b..c2e81ed0288c 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -1,4 +1,5 @@ import _ from 'lodash' +import type EventEmitter from 'events' import type CyServer from '@packages/server' import type { CypressIncomingRequest, @@ -17,8 +18,10 @@ import type { Request, Response } from 'express' import RequestMiddleware from './request-middleware' import ResponseMiddleware from './response-middleware' import { DeferredSourceMapCache } from '@packages/rewriter' +import type { Browser } from '@packages/server/lib/browsers/types' +import type { RemoteStates } from '@packages/server/lib/remote_states' -const debugRequests = Debug('cypress-verbose:proxy:http') +export const debugVerbose = Debug('cypress-verbose:proxy:http') export enum HttpStages { IncomingRequest, @@ -42,7 +45,10 @@ type HttpMiddlewareCtx = { debug: Debug.Debugger middleware: HttpMiddlewareStacks deferSourceMapRewrite: (opts: { js: string, url: string }) => string + getCurrentBrowser: () => Browser | Partial & Pick | null getPreRequest: (cb: GetPreRequestCb) => void + getPreviousAUTRequestUrl: Http['getPreviousAUTRequestUrl'] + setPreviousAUTRequestUrl: Http['setPreviousAUTRequestUrl'] } & T export const defaultMiddleware = { @@ -52,22 +58,23 @@ export const defaultMiddleware = { } export type ServerCtx = Readonly<{ - config: CyServer.Config + config: CyServer.Config & Cypress.Config shouldCorrelatePreRequests?: () => boolean + getCurrentBrowser: () => Browser | Partial & Pick | null getFileServerToken: () => string - getRemoteState: CyServer.getRemoteState + remoteStates: RemoteStates getRenderedHTMLOrigins: Http['getRenderedHTMLOrigins'] netStubbingState: NetStubbingState middleware: HttpMiddlewareStacks socket: CyServer.Socket request: any + serverBus: EventEmitter }> const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [ 'buffers', 'config', 'getFileServerToken', - 'getRemoteState', 'netStubbingState', 'next', 'end', @@ -76,7 +83,7 @@ const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [ 'skipMiddleware', ] -type HttpMiddlewareThis = HttpMiddlewareCtx & ServerCtx & Readonly<{ +export type HttpMiddlewareThis = HttpMiddlewareCtx & ServerCtx & Readonly<{ buffers: HttpBuffers next: () => void @@ -192,14 +199,17 @@ export class Http { config: CyServer.Config shouldCorrelatePreRequests: () => boolean deferredSourceMapCache: DeferredSourceMapCache + getCurrentBrowser: () => Browser | Partial & Pick | null getFileServerToken: () => string - getRemoteState: () => any + remoteStates: RemoteStates middleware: HttpMiddlewareStacks netStubbingState: NetStubbingState preRequests: PreRequests = new PreRequests() request: any socket: CyServer.Socket + serverBus: EventEmitter renderedHTMLOrigins: {[key: string]: boolean} = {} + previousAUTRequestUrl?: string constructor (opts: ServerCtx & { middleware?: HttpMiddlewareStacks }) { this.buffers = new HttpBuffers() @@ -207,12 +217,14 @@ export class Http { this.config = opts.config this.shouldCorrelatePreRequests = opts.shouldCorrelatePreRequests || (() => false) + this.getCurrentBrowser = opts.getCurrentBrowser this.getFileServerToken = opts.getFileServerToken - this.getRemoteState = opts.getRemoteState + this.remoteStates = opts.remoteStates this.middleware = opts.middleware this.netStubbingState = opts.netStubbingState this.socket = opts.socket this.request = opts.request + this.serverBus = opts.serverBus if (typeof opts.middleware === 'undefined') { this.middleware = defaultMiddleware @@ -226,14 +238,16 @@ export class Http { buffers: this.buffers, config: this.config, shouldCorrelatePreRequests: this.shouldCorrelatePreRequests, + getCurrentBrowser: this.getCurrentBrowser, getFileServerToken: this.getFileServerToken, - getRemoteState: this.getRemoteState, + remoteStates: this.remoteStates, request: this.request, middleware: _.cloneDeep(this.middleware), netStubbingState: this.netStubbingState, socket: this.socket, + serverBus: this.serverBus, debug: (formatter, ...args) => { - debugRequests(`%s %s %s ${formatter}`, ctx.req.method, ctx.req.proxiedUrl, ctx.stage, ...args) + debugVerbose(`%s %s %s ${formatter}`, ctx.req.method, ctx.req.proxiedUrl, ctx.stage, ...args) }, deferSourceMapRewrite: (opts) => { this.deferredSourceMapCache.defer({ @@ -242,6 +256,8 @@ export class Http { }) }, getRenderedHTMLOrigins: this.getRenderedHTMLOrigins, + getPreviousAUTRequestUrl: this.getPreviousAUTRequestUrl, + setPreviousAUTRequestUrl: this.setPreviousAUTRequestUrl, getPreRequest: (cb) => { this.preRequests.get(ctx.req, ctx.debug, cb) }, @@ -275,6 +291,14 @@ export class Http { return this.renderedHTMLOrigins } + getPreviousAUTRequestUrl = () => { + return this.previousAUTRequestUrl + } + + setPreviousAUTRequestUrl = (url) => { + this.previousAUTRequestUrl = url + } + async handleSourceMapRequest (req: Request, res: Response) { try { const sm = await this.deferredSourceMapCache.resolve(req.params.id, req.headers) @@ -291,6 +315,7 @@ export class Http { reset () { this.buffers.reset() + this.setPreviousAUTRequestUrl(undefined) } setBuffer (buffer) { diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index e697fff4b8f9..9cf54b0955e7 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -18,6 +18,16 @@ const LogRequest: RequestMiddleware = function () { this.next() } +const ExtractIsAUTFrameHeader: RequestMiddleware = async function () { + this.req.isAUTFrame = !!this.req.headers['x-cypress-is-aut-frame'] + + if (this.req.headers['x-cypress-is-aut-frame']) { + delete this.req.headers['x-cypress-is-aut-frame'] + } + + this.next() +} + const CorrelateBrowserPreRequest: RequestMiddleware = async function () { if (!this.shouldCorrelatePreRequests()) { return this.next() @@ -71,7 +81,7 @@ const MaybeEndRequestWithBufferedResponse: RequestMiddleware = function () { if (buffer) { this.debug('ending request with buffered response') - this.res.wantsInjection = 'full' + this.res.wantsInjection = buffer.isCrossOrigin ? 'fullCrossOrigin' : 'full' return this.onResponse(buffer.response, buffer.stream) } @@ -131,9 +141,10 @@ function reqNeedsBasicAuthHeaders (req, { auth, origin }: Cypress.RemoteState) { } const MaybeSetBasicAuthHeaders: RequestMiddleware = function () { - const remoteState = this.getRemoteState() + // get the remote state for the proxied url + const remoteState = this.remoteStates.get(this.req.proxiedUrl) - if (remoteState.auth && reqNeedsBasicAuthHeaders(this.req, remoteState)) { + if (remoteState?.auth && reqNeedsBasicAuthHeaders(this.req, remoteState)) { const { auth } = remoteState const base64 = Buffer.from(`${auth.username}:${auth.password}`).toString('base64') @@ -154,12 +165,12 @@ const SendRequestOutgoing: RequestMiddleware = function () { const requestBodyBuffered = !!this.req.body - const { strategy, origin, fileServer } = this.getRemoteState() + const { strategy, origin, fileServer } = this.remoteStates.current() if (strategy === 'file' && requestOptions.url.startsWith(origin)) { this.req.headers['x-cypress-authorization'] = this.getFileServerToken() - requestOptions.url = requestOptions.url.replace(origin, fileServer) + requestOptions.url = requestOptions.url.replace(origin, fileServer as string) } if (requestBodyBuffered) { @@ -185,6 +196,7 @@ const SendRequestOutgoing: RequestMiddleware = function () { export default { LogRequest, + ExtractIsAUTFrameHeader, MaybeEndRequestWithBufferedResponse, CorrelateBrowserPreRequest, SendToDriver, diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 52d64534bc6c..2455b4d0306c 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -1,18 +1,20 @@ import _ from 'lodash' import charset from 'charset' import type { CookieOptions } from 'express' -import { cors, concatStream, httpUtils } from '@packages/network' +import { cors, concatStream, httpUtils, uri } from '@packages/network' import type { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/proxy' import debugModule from 'debug' -import type { HttpMiddleware } from '.' +import type { HttpMiddleware, HttpMiddlewareThis } from '.' import iconv from 'iconv-lite' import type { IncomingMessage, IncomingHttpHeaders } from 'http' import { InterceptResponse } from '@packages/net-stubbing' import { PassThrough, Readable } from 'stream' import * as rewriter from './util/rewriter' import zlib from 'zlib' +import { URL } from 'url' +import type { Browser } from '@packages/server/lib/browsers/types' -export type ResponseMiddleware = HttpMiddleware<{ +interface ResponseMiddlewareProps { /** * Before using `res.incomingResStream`, `prepareResStream` can be used * to remove any encoding that prevents it from being returned as plain text. @@ -23,7 +25,9 @@ export type ResponseMiddleware = HttpMiddleware<{ isGunzipped: boolean incomingRes: IncomingMessage incomingResStream: Readable -}> +} + +export type ResponseMiddleware = HttpMiddleware const debug = debugModule('cypress:proxy:http:response-middleware') @@ -225,6 +229,33 @@ const PatchExpressSetHeader: ResponseMiddleware = function () { this.next() } +const MaybeDelayForCrossOrigin: ResponseMiddleware = function () { + const isCrossOrigin = !reqMatchesOriginPolicy(this.req, this.remoteStates.current()) + const isPreviousOrigin = this.remoteStates.isInOriginStack(this.req.proxiedUrl) + const isHTML = resContentTypeIs(this.incomingRes, 'text/html') + const isRenderedHTML = reqWillRenderHtml(this.req) + const isAUTFrame = this.req.isAUTFrame + + // delay the response if this is a cross-origin (and not returning to a previous origin) html request from the AUT iframe + if (this.config.experimentalSessionAndOrigin && isCrossOrigin && !isPreviousOrigin && isAUTFrame && (isHTML || isRenderedHTML)) { + this.debug('is cross-origin, delay until cross:origin:release:html event') + + this.serverBus.once('cross:origin:release:html', () => { + this.debug(`received cross:origin:release:html, let the response proceed`) + + this.next() + }) + + this.serverBus.emit('cross:origin:delaying:html', { + href: this.req.proxiedUrl, + }) + + return + } + + this.next() +} + const SetInjectionLevel: ResponseMiddleware = function () { this.res.isInitial = this.req.cookies['__cypress.initial'] === 'true' @@ -236,28 +267,54 @@ const SetInjectionLevel: ResponseMiddleware = function () { this.getRenderedHTMLOrigins()[origin] = true } - const isReqMatchOriginPolicy = reqMatchesOriginPolicy(this.req, this.getRemoteState()) + this.debug('determine injection') + + const isReqMatchOriginPolicy = reqMatchesOriginPolicy(this.req, this.remoteStates.current()) const getInjectionLevel = () => { if (this.incomingRes.headers['x-cypress-file-server-error'] && !this.res.isInitial) { + this.debug('- partial injection (x-cypress-file-server-error)') + return 'partial' } - if (!resContentTypeIs(this.incomingRes, 'text/html') || !isReqMatchOriginPolicy) { + const isSecondaryOrigin = this.remoteStates.isSecondaryOrigin(this.req.proxiedUrl) + const isHTML = resContentTypeIs(this.incomingRes, 'text/html') + const isAUTFrame = this.req.isAUTFrame + + if (this.config.experimentalSessionAndOrigin && isSecondaryOrigin && isAUTFrame && (isHTML || isRenderedHTML)) { + this.debug('- cross origin injection') + + return 'fullCrossOrigin' + } + + if (!isHTML || (!isReqMatchOriginPolicy && !isAUTFrame)) { + this.debug('- no injection (not html)') + return false } if (this.res.isInitial) { + this.debug('- full injection') + return 'full' } if (!isRenderedHTML) { + this.debug('- no injection (not rendered html)') + return false } + this.debug('- partial injection (default)') + return 'partial' } - if (!this.res.wantsInjection) { + if (this.res.wantsInjection != null) { + this.debug('- already has injection: %s', this.res.wantsInjection) + } + + if (this.res.wantsInjection == null) { this.res.wantsInjection = getInjectionLevel() } @@ -331,11 +388,89 @@ const MaybePreventCaching: ResponseMiddleware = function () { this.next() } +const determineIfNeedsCrossOriginHandling = (ctx: HttpMiddlewareThis) => { + const previousAUTRequestUrl = ctx.getPreviousAUTRequestUrl() + + // A cookie needs cross origin handling if it's an AUT request and + // either the request itself is cross-origin or the origins between + // requests don't match, since the browser won't set them in that + // case and if it's secondary-origin -> primary-origin, we don't + // recognize the request as cross-origin + return ( + !!ctx.req.isAUTFrame && + ( + (previousAUTRequestUrl && !cors.urlOriginsMatch(previousAUTRequestUrl, ctx.req.proxiedUrl)) + || !ctx.remoteStates.isPrimaryOrigin(ctx.req.proxiedUrl) + ) + ) +} + +interface EnsureSameSiteNoneProps { + cookie: string + browser: Browser | { family: string | null } + isLocalhost: boolean + url: URL +} + +const cookieSameSiteRegex = /SameSite=(\w+)/i +const cookieSecureRegex = /(^|\W)Secure(\W|$)/i +const cookieSecureSemicolonRegex = /;\s*Secure/i + +const ensureSameSiteNone = ({ cookie, browser, isLocalhost, url }: EnsureSameSiteNoneProps) => { + debug('original cookie: %s', cookie) + + if (cookieSameSiteRegex.test(cookie)) { + debug('change cookie to SameSite=None') + cookie = cookie.replace(cookieSameSiteRegex, 'SameSite=None') + } else { + debug('add SameSite=None to cookie') + cookie += '; SameSite=None' + } + + const isFirefox = browser.family === 'firefox' + + // Secure is required for SameSite=None cookies to be set in secure contexts + // (https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy), + // but will not allow the cookie to be set in an insecure context. + // Normally http://localhost is considered a secure context (see + // https://w3c.github.io/webappsec-secure-contexts/#localhost), but Firefox + // does not consider the Cypress-launched browser to be a secure context (see + // https://github.com/cypress-io/cypress/issues/18217). For that reason, we + // remove Secure from http://localhost cookies in Firefox. + if (cookieSecureRegex.test(cookie)) { + if (isFirefox && isLocalhost && url.protocol === 'http:') { + debug('remove Secure from cookie') + cookie = cookie.replace(cookieSecureSemicolonRegex, '') + } + } else if (!isFirefox || url.protocol === 'https:') { + debug('add Secure to cookie') + cookie += '; Secure' + } + + debug('resulting cookie: %s', cookie) + + return cookie +} + const CopyCookiesFromIncomingRes: ResponseMiddleware = function () { const cookies: string | string[] | undefined = this.incomingRes.headers['set-cookie'] if (cookies) { - ([] as string[]).concat(cookies).forEach((cookie) => { + const needsCrossOriginHandling = ( + this.config.experimentalSessionAndOrigin + && determineIfNeedsCrossOriginHandling(this) + ) + const browser = this.getCurrentBrowser() || { family: null } + const url = new URL(this.req.proxiedUrl) + const isLocalhost = uri.isLocalhost(url) + + debug('force SameSite=None?', needsCrossOriginHandling) + + ;([] as string[]).concat(cookies).forEach((cookie) => { + if (needsCrossOriginHandling) { + cookie = ensureSameSiteNone({ cookie, browser, isLocalhost, url }) + } + try { this.res.append('Set-Cookie', cookie) } catch (err) { @@ -358,7 +493,7 @@ const MaybeSendRedirectToClient: ResponseMiddleware = function () { return this.next() } - setInitialCookie(this.res, this.getRemoteState(), true) + setInitialCookie(this.res, this.remoteStates.current(), true) debug('redirecting to new url %o', { statusCode, newUrl }) this.res.redirect(Number(statusCode), newUrl) @@ -372,7 +507,7 @@ const CopyResponseStatusCode: ResponseMiddleware = function () { } const ClearCyInitialCookie: ResponseMiddleware = function () { - setInitialCookie(this.res, this.getRemoteState(), false) + setInitialCookie(this.res, this.remoteStates.current(), false) this.next() } @@ -401,7 +536,7 @@ const MaybeInjectHtml: ResponseMiddleware = function () { const nodeCharset = getNodeCharsetFromResponse(this.incomingRes.headers, body) const decodedBody = iconv.decode(body, nodeCharset) const injectedBody = await rewriter.html(decodedBody, { - domainName: this.getRemoteState().domainName, + domainName: cors.getDomainNameFromUrl(this.req.proxiedUrl), wantsInjection: this.res.wantsInjection, wantsSecurityRemoved: this.res.wantsSecurityRemoved, isHtml: isHtml(this.incomingRes), @@ -451,6 +586,12 @@ const GzipBody: ResponseMiddleware = function () { } const SendResponseBodyToClient: ResponseMiddleware = function () { + if (this.req.isAUTFrame) { + // track the previous AUT request URL so we know if the next requests + // is cross-origin + this.setPreviousAUTRequestUrl(this.req.proxiedUrl) + } + this.incomingResStream.pipe(this.res).on('error', this.onError) this.res.on('end', () => this.end()) } @@ -460,6 +601,7 @@ export default { AttachPlainTextStreamFn, InterceptResponse, PatchExpressSetHeader, + MaybeDelayForCrossOrigin, SetInjectionLevel, OmitProblematicHeaders, MaybePreventCaching, diff --git a/packages/proxy/lib/http/util/buffers.ts b/packages/proxy/lib/http/util/buffers.ts index b1ad4b63a6ee..4c285143e727 100644 --- a/packages/proxy/lib/http/util/buffers.ts +++ b/packages/proxy/lib/http/util/buffers.ts @@ -12,6 +12,7 @@ export type HttpBuffer = { response: IncomingMessage stream: Readable url: string + isCrossOrigin: boolean } const stripPort = (url) => { diff --git a/packages/proxy/lib/http/util/inject.ts b/packages/proxy/lib/http/util/inject.ts index 72bb70361542..cb0872c3d965 100644 --- a/packages/proxy/lib/http/util/inject.ts +++ b/packages/proxy/lib/http/util/inject.ts @@ -1,5 +1,5 @@ import { oneLine } from 'common-tags' -import { getRunnerInjectionContents } from '@packages/resolve-dist' +import { getRunnerInjectionContents, getRunnerCrossOriginInjectionContents } from '@packages/resolve-dist' export function partial (domain) { return oneLine` @@ -20,3 +20,15 @@ export function full (domain) { ` }) } + +export function fullCrossOrigin (domain) { + return getRunnerCrossOriginInjectionContents().then((contents) => { + return oneLine` + + ` + }) +} diff --git a/packages/proxy/lib/http/util/rewriter.ts b/packages/proxy/lib/http/util/rewriter.ts index 8b101a151739..d6acef973aae 100644 --- a/packages/proxy/lib/http/util/rewriter.ts +++ b/packages/proxy/lib/http/util/rewriter.ts @@ -1,6 +1,7 @@ import * as inject from './inject' import * as astRewriter from './ast-rewriter' import * as regexRewriter from './regex-rewriter' +import type { CypressWantsInjection } from '../../types' export type SecurityOpts = { isHtml?: boolean @@ -11,7 +12,7 @@ export type SecurityOpts = { export type InjectionOpts = { domainName: string - wantsInjection: WantsInjection + wantsInjection: CypressWantsInjection wantsSecurityRemoved: any } @@ -20,8 +21,6 @@ const headRe = /()/i const bodyRe = /()/i const htmlRe = /()/i -type WantsInjection = 'full' | 'partial' | false - function getRewriter (useAstSourceRewriting: boolean) { return useAstSourceRewriting ? astRewriter : regexRewriter } @@ -30,6 +29,8 @@ function getHtmlToInject ({ domainName, wantsInjection }: InjectionOpts) { switch (wantsInjection) { case 'full': return inject.full(domainName) + case 'fullCrossOrigin': + return inject.fullCrossOrigin(domainName) case 'partial': return inject.partial(domainName) default: diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index 0c78c9e86a78..d6a8b2f1f54a 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -12,14 +12,17 @@ export type CypressIncomingRequest = Request & { body?: string responseTimeout?: number followRedirect?: boolean + isAUTFrame: boolean } +export type CypressWantsInjection = 'full' | 'fullCrossOrigin' | 'partial' | false + /** * An outgoing response to an incoming request to the Cypress web server. */ export type CypressOutgoingResponse = Response & { isInitial: null | boolean - wantsInjection: 'full' | 'partial' | false + wantsInjection: CypressWantsInjection wantsSecurityRemoved: null | boolean body?: string | Readable } diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 2d2fb5dc9be5..e0a7893f7208 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -28,6 +28,7 @@ "@cypress/request-promise": "4.2.6", "@cypress/sinon-chai": "2.9.1", "@packages/resolve-dist": "0.0.0-development", + "@packages/server": "0.0.0-development", "@types/express": "4.17.2", "@types/supertest": "2.0.10", "express": "4.17.1", diff --git a/packages/proxy/test/integration/net-stubbing.spec.ts b/packages/proxy/test/integration/net-stubbing.spec.ts index eee1092c9b62..fd5bfe27957d 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -11,13 +11,14 @@ import { expect } from 'chai' import supertest from 'supertest' import { allowDestroy } from '@packages/network' import { EventEmitter } from 'events' +import { RemoteStates } from '@packages/server/lib/remote_states' const Request = require('@packages/server/lib/request') const getFixture = async () => {} context('network stubbing', () => { let config - let remoteState + let remoteStates: RemoteStates let netStubbingState: NetStubbingState let app let destinationApp @@ -27,7 +28,7 @@ context('network stubbing', () => { beforeEach((done) => { config = {} - remoteState = {} + remoteStates = new RemoteStates(() => {}) socket = new EventEmitter() socket.toDriver = sinon.stub() app = express() @@ -38,9 +39,12 @@ context('network stubbing', () => { netStubbingState, config, middleware: defaultMiddleware, - getRemoteState: () => remoteState, + getCurrentBrowser: () => ({ family: 'chromium' }), + remoteStates, getFileServerToken: () => 'fake-token', request: new Request(), + getRenderedHTMLOrigins: () => ({}), + serverBus: new EventEmitter(), }) app.use((req, res, next) => { @@ -59,6 +63,7 @@ context('network stubbing', () => { server = allowDestroy(destinationApp.listen(() => { destinationPort = server.address().port + remoteStates.set(`http://localhost:${destinationPort}`) done() })) }) @@ -92,6 +97,7 @@ context('network stubbing', () => { body: 'foo', }, getFixture: async () => {}, + matches: 1, }) return supertest(app) @@ -120,6 +126,7 @@ context('network stubbing', () => { }, }, getFixture: async () => {}, + matches: 1, }) return supertest(app) @@ -142,6 +149,7 @@ context('network stubbing', () => { body: 'foo', }, getFixture: async () => {}, + matches: 1, }) return supertest(app) @@ -162,6 +170,7 @@ context('network stubbing', () => { }, hasInterceptor: true, getFixture, + matches: 1, }) socket.toDriver.callsFake((_, event, data) => { @@ -179,6 +188,9 @@ context('network stubbing', () => { state: netStubbingState, getFixture, args: [], + socket: { + toDriver () {}, + }, }) } }) @@ -229,6 +241,7 @@ context('network stubbing', () => { }, hasInterceptor: true, getFixture, + matches: 1, }) socket.toDriver.callsFake((_, event, data) => { @@ -246,6 +259,9 @@ context('network stubbing', () => { state: netStubbingState, getFixture, args: [], + socket: { + toDriver () {}, + }, }) } }) diff --git a/packages/proxy/test/unit/http/helpers.ts b/packages/proxy/test/unit/http/helpers.ts index 70b1720fdbf5..654ffc710d30 100644 --- a/packages/proxy/test/unit/http/helpers.ts +++ b/packages/proxy/test/unit/http/helpers.ts @@ -1,11 +1,11 @@ -import { HttpMiddleware, _runStage } from '../../../lib/http' +import { HttpMiddleware, HttpStages, _runStage } from '../../../lib/http' export function testMiddleware (middleware: HttpMiddleware[], ctx = {}) { const fullCtx = { + debug: () => {}, req: {}, res: {}, config: {}, - getRemoteState: () => {}, middleware: { 0: middleware, @@ -14,5 +14,9 @@ export function testMiddleware (middleware: HttpMiddleware[], ctx = {}) { ...ctx, } - return _runStage(0, fullCtx) + const onError = (error) => { + throw error + } + + return _runStage(HttpStages.IncomingRequest, fullCtx, onError) } diff --git a/packages/proxy/test/unit/http/index.spec.ts b/packages/proxy/test/unit/http/index.spec.ts index 4055144e398e..5b9a4c49515d 100644 --- a/packages/proxy/test/unit/http/index.spec.ts +++ b/packages/proxy/test/unit/http/index.spec.ts @@ -5,7 +5,6 @@ import sinon from 'sinon' describe('http', function () { context('Http.handle', function () { let config - let getRemoteState let middleware let incomingRequest let incomingResponse @@ -14,8 +13,6 @@ describe('http', function () { beforeEach(function () { config = {} - getRemoteState = sinon.stub().returns({}) - incomingRequest = sinon.stub() incomingResponse = sinon.stub() error = sinon.stub() @@ -26,7 +23,7 @@ describe('http', function () { [HttpStages.Error]: [error], } - httpOpts = { config, getRemoteState, middleware } + httpOpts = { config, middleware } }) it('calls IncomingRequest stack, then IncomingResponse stack', function () { @@ -99,7 +96,7 @@ describe('http', function () { const resAdded = {} const errorAdded = {} - let expectedKeys = ['req', 'res', 'config', 'getRemoteState', 'middleware'] + let expectedKeys = ['req', 'res', 'config', 'middleware'] incomingRequest.callsFake(function () { expect(this).to.include.keys(expectedKeys) diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index 1bde606e5caf..cdf7c1bbe649 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -1,11 +1,16 @@ import _ from 'lodash' import RequestMiddleware from '../../../lib/http/request-middleware' import { expect } from 'chai' +import { testMiddleware } from './helpers' +import { CypressIncomingRequest, CypressOutgoingResponse } from '../../../lib' +import { HttpBuffer, HttpBuffers } from '../../../lib/http/util/buffers' +import { RemoteStates } from '@packages/server/lib/remote_states' -describe('http/request-middleware', function () { - it('exports the members in the correct order', function () { +describe('http/request-middleware', () => { + it('exports the members in the correct order', () => { expect(_.keys(RequestMiddleware)).to.have.ordered.members([ 'LogRequest', + 'ExtractIsAUTFrameHeader', 'MaybeEndRequestWithBufferedResponse', 'CorrelateBrowserPreRequest', 'SendToDriver', @@ -17,4 +22,215 @@ describe('http/request-middleware', function () { 'SendRequestOutgoing', ]) }) + + describe('ExtractIsAUTFrameHeader', () => { + const { ExtractIsAUTFrameHeader } = RequestMiddleware + + it('removes x-cypress-is-aut-frame header when it exists, sets in on the req', async () => { + const ctx = { + req: { + headers: { + 'x-cypress-is-aut-frame': 'true', + }, + } as Partial, + } + + await testMiddleware([ExtractIsAUTFrameHeader], ctx) + .then(() => { + expect(ctx.req.headers['x-cypress-is-aut-frame']).not.to.exist + expect(ctx.req.isAUTFrame).to.be.true + }) + }) + + it('removes x-cypress-is-aut-frame header when it does not exist, sets in on the req', async () => { + const ctx = { + req: { + headers: {}, + } as Partial, + } + + await testMiddleware([ExtractIsAUTFrameHeader], ctx) + .then(() => { + expect(ctx.req.headers['x-cypress-is-aut-frame']).not.to.exist + expect(ctx.req.isAUTFrame).to.be.false + }) + }) + }) + + describe('MaybeEndRequestWithBufferedResponse', () => { + const { MaybeEndRequestWithBufferedResponse } = RequestMiddleware + + it('sets wantsInjection to full when a request is buffered', async () => { + const buffers = new HttpBuffers() + const buffer = { url: 'https://www.cypress.io/', isCrossOrigin: false } as HttpBuffer + + buffers.set(buffer) + + const ctx = { + buffers, + req: { + proxiedUrl: 'https://www.cypress.io/', + }, + res: {} as Partial, + } + + await testMiddleware([MaybeEndRequestWithBufferedResponse], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.equal('full') + }) + }) + + it('sets wantsInjection to fullCrossOrigin when a cross origin request is buffered', async () => { + const buffers = new HttpBuffers() + const buffer = { url: 'https://www.cypress.io/', isCrossOrigin: true } as HttpBuffer + + buffers.set(buffer) + + const ctx = { + buffers, + req: { + proxiedUrl: 'https://www.cypress.io/', + }, + res: {} as Partial, + } + + await testMiddleware([MaybeEndRequestWithBufferedResponse], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.equal('fullCrossOrigin') + }) + }) + + it('wantsInjection is not set when the request is not buffered', async () => { + const buffers = new HttpBuffers() + const buffer = { url: 'https://www.cypress.io/', isCrossOrigin: true } as HttpBuffer + + buffers.set(buffer) + + const ctx = { + buffers, + req: { + proxiedUrl: 'https://www.not-cypress.io/', + }, + res: {} as Partial, + } + + await testMiddleware([MaybeEndRequestWithBufferedResponse], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.be.undefined + }) + }) + }) + + describe('MaybeSetBasicAuthHeaders', () => { + const { MaybeSetBasicAuthHeaders } = RequestMiddleware + + it('adds auth header from remote state', async () => { + const headers = {} + const remoteStates = new RemoteStates(() => {}) + + remoteStates.set('https://www.cypress.io/', { auth: { username: 'u', password: 'p' } }) + + const ctx = { + req: { + proxiedUrl: 'https://www.cypress.io/', + headers, + }, + res: {} as Partial, + remoteStates, + } + + await testMiddleware([MaybeSetBasicAuthHeaders], ctx) + .then(() => { + const expectedAuthHeader = `Basic ${Buffer.from('u:p').toString('base64')}` + + expect(ctx.req.headers['authorization']).to.equal(expectedAuthHeader) + }) + }) + + it('does not add auth header if origins do not match', async () => { + const headers = {} + const remoteStates = new RemoteStates(() => {}) + + remoteStates.set('https://cypress.io/', { auth: { username: 'u', password: 'p' } }) // does not match due to subdomain + + const ctx = { + req: { + proxiedUrl: 'https://www.cypress.io/', + headers, + }, + res: {} as Partial, + remoteStates, + } + + await testMiddleware([MaybeSetBasicAuthHeaders], ctx) + .then(() => { + expect(ctx.req.headers['authorization']).to.be.undefined + }) + }) + + it('does not add auth header if remote does not have auth', async () => { + const headers = {} + const remoteStates = new RemoteStates(() => {}) + + remoteStates.set('https://www.cypress.io/') + + const ctx = { + req: { + proxiedUrl: 'https://www.cypress.io/', + headers, + }, + res: {} as Partial, + remoteStates, + } + + await testMiddleware([MaybeSetBasicAuthHeaders], ctx) + .then(() => { + expect(ctx.req.headers['authorization']).to.be.undefined + }) + }) + + it('does not add auth header if remote not found', async () => { + const headers = {} + const remoteStates = new RemoteStates(() => {}) + + remoteStates.set('http://localhost:3500', { auth: { username: 'u', password: 'p' } }) + + const ctx = { + req: { + proxiedUrl: 'https://www.cypress.io/', + headers, + }, + res: {} as Partial, + remoteStates, + } + + await testMiddleware([MaybeSetBasicAuthHeaders], ctx) + .then(() => { + expect(ctx.req.headers['authorization']).to.be.undefined + }) + }) + + it('does not update auth header from remote if request already has auth', async () => { + const headers = { + authorization: 'token', + } + const remoteStates = new RemoteStates(() => {}) + + remoteStates.set('https://www.cypress.io/', { auth: { username: 'u', password: 'p' } }) + + const ctx = { + req: { + proxiedUrl: 'https://www.cypress.io/', + headers, + }, + res: {} as Partial, + remoteStates, + } + + await testMiddleware([MaybeSetBasicAuthHeaders], ctx) + .then(() => { + expect(ctx.req.headers['authorization']).to.equal('token') + }) + }) + }) }) diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index 0ba4b6c523d2..912751d6132f 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -1,10 +1,11 @@ import _ from 'lodash' import ResponseMiddleware from '../../../lib/http/response-middleware' +import { debugVerbose } from '../../../lib/http' import { expect } from 'chai' import sinon from 'sinon' -import { - testMiddleware, -} from './helpers' +import { testMiddleware } from './helpers' +import { RemoteStates } from '@packages/server/lib/remote_states' +import EventEmitter from 'events' describe('http/response-middleware', function () { it('exports the members in the correct order', function () { @@ -13,6 +14,7 @@ describe('http/response-middleware', function () { 'AttachPlainTextStreamFn', 'InterceptResponse', 'PatchExpressSetHeader', + 'MaybeDelayForCrossOrigin', 'SetInjectionLevel', 'OmitProblematicHeaders', 'MaybePreventCaching', @@ -128,47 +130,409 @@ describe('http/response-middleware', function () { } }) + describe('MaybeDelayForCrossOrigin', function () { + const { MaybeDelayForCrossOrigin } = ResponseMiddleware + let ctx + + it('doesn\'t do anything when not html or rendered html', function () { + prepareContext({}) + + return testMiddleware([MaybeDelayForCrossOrigin], ctx) + .then(() => { + expect(ctx.serverBus.emit).not.to.be.called + }) + }) + + it('doesn\'t do anything when not AUT frame', function () { + prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + }) + + return testMiddleware([MaybeDelayForCrossOrigin], ctx) + .then(() => { + expect(ctx.serverBus.emit).not.to.be.called + }) + }) + + it('doesn\'t do anything when "experimentalSessionAndOrigin" config flag is not set to true"', function () { + prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + }) + + return testMiddleware([MaybeDelayForCrossOrigin], ctx) + .then(() => { + expect(ctx.serverBus.emit).not.to.be.called + }) + }) + + it('doesn\'t do anything when request is for a previous origin in the stack', function () { + prepareContext({ + req: { + isAUTFrame: true, + proxiedUrl: 'http://www.foobar.com/test', + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + secondaryOrigins: ['http://foobar.com', 'http://example.com'], + config: { + experimentalSessionAndOrigin: true, + }, + }) + + return testMiddleware([MaybeDelayForCrossOrigin], ctx) + .then(() => { + expect(ctx.serverBus.emit).not.to.be.called + }) + }) + + it('waits for server signal if req is not of a previous origin, letting it continue after receiving cross:origin:release:html', function () { + prepareContext({ + req: { + isAUTFrame: true, + proxiedUrl: 'http://www.idp.com/test', + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + secondaryOrigins: ['http://foobar.com', 'http://example.com'], + config: { + experimentalSessionAndOrigin: true, + }, + }) + + const promise = testMiddleware([MaybeDelayForCrossOrigin], ctx) + + expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:delaying:html', { href: 'http://www.idp.com/test' }) + + ctx.serverBus.once.withArgs('cross:origin:release:html').args[0][1]() + + return promise + }) + + it('waits for server signal if res is html, letting it continue after receiving cross:origin:release:html', function () { + prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + req: { + isAUTFrame: true, + proxiedUrl: 'http://www.foobar.com/test', + }, + config: { + experimentalSessionAndOrigin: true, + }, + }) + + const promise = testMiddleware([MaybeDelayForCrossOrigin], ctx) + + expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:delaying:html', { href: 'http://www.foobar.com/test' }) + + ctx.serverBus.once.withArgs('cross:origin:release:html').args[0][1]() + + return promise + }) + + it('waits for server signal if incomingRes is rendered html, letting it continue after receiving cross:origin:release:html', function () { + prepareContext({ + req: { + headers: { + 'accept': [ + 'text/html', + 'application/xhtml+xml', + ], + }, + isAUTFrame: true, + proxiedUrl: 'http://www.foobar.com/test', + }, + config: { + experimentalSessionAndOrigin: true, + }, + }) + + const promise = testMiddleware([MaybeDelayForCrossOrigin], ctx) + + expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:delaying:html', { href: 'http://www.foobar.com/test' }) + + ctx.serverBus.once.withArgs('cross:origin:release:html').args[0][1]() + + return promise + }) + + function prepareContext (props) { + const remoteStates = new RemoteStates(() => {}) + const eventEmitter = new EventEmitter() + + // set the primary remote state + remoteStates.set('http://127.0.0.1:3501') + + // set the secondary remote states + remoteStates.addEventListeners(eventEmitter) + props.secondaryOrigins?.forEach((originPolicy) => { + eventEmitter.emit('cross:origin:bridge:ready', { originPolicy }) + }) + + ctx = { + incomingRes: { + headers: {}, + ...props.incomingRes, + }, + res: { + headers: {}, + ...props.res, + }, + req: { + proxiedUrl: 'http://127.0.0.1:3501/multi-domain.html', + headers: {}, + ...props.req, + }, + serverBus: { + emit: sinon.stub(), + once: sinon.stub(), + }, + remoteStates, + debug () {}, + onError (error) { + throw error + }, + ..._.omit(props, 'incomingRes', 'res', 'req'), + } + } + }) + describe('SetInjectionLevel', function () { const { SetInjectionLevel } = ResponseMiddleware - let ctx - beforeEach(function () { - ctx = { + it('doesn\'t inject anything when not html', function () { + prepareContext({ + req: { + cookies: {}, + headers: {}, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.be.false + }) + }) + + it('doesn\'t inject anything when not rendered html', function () { + prepareContext({ + renderedHTMLOrigins: {}, + getRenderedHTMLOrigins () { + return this.renderedHTMLOrigins + }, req: { - proxiedUrl: 'http://proxy.com', + cookies: {}, + headers: {}, + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.be.false + }) + }) + + it('doesn\'t inject anything when not AUT frame', function () { + prepareContext({ + req: { + cookies: {}, + headers: {}, + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.be.false + }) + }) + + it('doesn\'t inject anything when html does not match origin policy and "experimentalSessionAndOrigin" config flag is NOT set to true', function () { + prepareContext({ + req: { + proxiedUrl: 'http://foobar.com', + isAUTFrame: true, + cookies: {}, + headers: {}, + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.be.false + }) + }) + + it('injects "fullCrossOrigin" when "experimentalSessionAndOrigin" config flag is set to true for cross-origin html"', function () { + prepareContext({ + req: { + proxiedUrl: 'http://foobar.com', + isAUTFrame: true, + cookies: {}, + headers: {}, + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + secondaryOrigins: ['http://foobar.com'], + config: { + experimentalSessionAndOrigin: true, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.equal('fullCrossOrigin') + }) + }) + + it('injects "fullCrossOrigin" when request is in origin stack for cross-origin html"', function () { + prepareContext({ + req: { + proxiedUrl: 'http://example.com', + isAUTFrame: true, + cookies: {}, + headers: {}, + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + secondaryOrigins: ['http://example.com', 'http://foobar.com'], + config: { + experimentalSessionAndOrigin: true, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.equal('fullCrossOrigin') + }) + }) + + it('performs full injection on initial AUT html origin', function () { + prepareContext({ + req: { + isAUTFrame: true, cookies: { - '__cypress.initial': true, + '__cypress.initial': 'true', }, + headers: {}, + }, + incomingRes: { headers: { - accept: ['text/html', 'application/xhtml+xml'], + 'content-type': 'text/html', }, }, - res: { - setHeader: sinon.stub(), + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.equal('full') + }) + }) + + it('otherwise, partial injection is set', function () { + prepareContext({ + renderedHTMLOrigins: {}, + getRenderedHTMLOrigins () { + return this.renderedHTMLOrigins }, - getRemoteState: () => { - return { - strategy: 'http', - props: { - domain: 'proxy', - port: '80', - tld: 'com', - }, - } + req: { + proxiedUrl: 'http://foobar.com', + isAUTFrame: true, + cookies: {}, + headers: { + 'accept': [ + 'text/html', + 'application/xhtml+xml', + ], + }, }, - getRenderedHTMLOrigins: () => { - return {} + incomingRes: { + headers: { + 'content-type': 'text/html', + }, }, - } + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.equal('partial') + }) }) - it('does not set Origin-Agent-Cluster header to false when injection is not expected', function () { - ctx.incomingRes = { - headers: { - 'content-type': 'foo/bar', + it('injects partial when request is for top-level origin', function () { + prepareContext({ + renderedHTMLOrigins: {}, + getRenderedHTMLOrigins () { + return this.renderedHTMLOrigins }, - } + req: { + proxiedUrl: 'http://127.0.0.1:3501/', + isAUTFrame: true, + cookies: {}, + headers: { + 'accept': [ + 'text/html', + 'application/xhtml+xml', + ], + }, + }, + incomingRes: { + headers: { + 'content-type': 'text/html', + }, + }, + secondaryOrigins: ['http://foobar.com'], + config: { + experimentalSessionAndOrigin: true, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.wantsInjection).to.equal('partial') + }) + }) + + it('does not set Origin-Agent-Cluster header to false when injection is not expected', function () { + prepareContext({}) return testMiddleware([SetInjectionLevel], ctx) .then(() => { @@ -177,16 +541,413 @@ describe('http/response-middleware', function () { }) it('sets Origin-Agent-Cluster header to false when injection is expected', function () { - ctx.incomingRes = { - headers: { - 'content-type': 'text/html', + prepareContext({ + incomingRes: { + headers: { + // simplest way to make injection expected + 'x-cypress-file-server-error': true, + }, }, - } + }) return testMiddleware([SetInjectionLevel], ctx) .then(() => { expect(ctx.res.setHeader).to.be.calledWith('Origin-Agent-Cluster', '?0') }) }) + + function prepareContext (props) { + const remoteStates = new RemoteStates(() => {}) + const eventEmitter = new EventEmitter() + + // set the primary remote state + remoteStates.set('http://127.0.0.1:3501') + + // set the secondary remote states + remoteStates.addEventListeners(eventEmitter) + props.secondaryOrigins?.forEach((originPolicy) => { + eventEmitter.emit('cross:origin:bridge:ready', { originPolicy }) + }) + + ctx = { + incomingRes: { + headers: {}, + ...props.incomingRes, + }, + res: { + headers: {}, + setHeader: sinon.stub(), + ...props.res, + }, + req: { + proxiedUrl: 'http://127.0.0.1:3501/multi-domain.html', + headers: {}, + cookies: { + '__cypress.initial': true, + }, + ...props.req, + }, + remoteStates, + debug: (formatter, ...args) => { + debugVerbose(`%s %s %s ${formatter}`, ctx.req.method, ctx.req.proxiedUrl, ctx.stage, ...args) + }, + onError (error) { + throw error + }, + ..._.omit(props, 'incomingRes', 'res', 'req'), + } + } + }) + + describe('CopyCookiesFromIncomingRes', function () { + const { CopyCookiesFromIncomingRes } = ResponseMiddleware + + it('appends cookies on the response when an array', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1', 'cookie2=value2'], + }, + }, + res: { + append: appendStub, + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledTwice + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie2=value2') + }) + + it('appends cookies on the response when a string', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'set-cookie': 'cookie=value', + }, + }, + res: { + append: appendStub, + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledOnce + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + it('is a noop when cookies are undefined', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + res: { + append: appendStub, + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).not.to.be.called + }) + + describe('SameSite', function () { + it('forces SameSite=None when an AUT request and does not match origin policy', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + 'set-cookie': 'cookie=value', + }, + }, + req: { + isAUTFrame: true, + }, + res: { + append: appendStub, + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledOnce + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value; SameSite=None; Secure') + }) + + it('forces SameSite=None when an AUT request and last AUT request was a different origin', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + 'set-cookie': 'cookie=value', + }, + }, + req: { + isAUTFrame: true, + proxiedUrl: 'https://site2.com', + }, + res: { + append: appendStub, + }, + getPreviousAUTRequestUrl () { + return 'https://different.site' + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledOnce + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value; SameSite=None; Secure') + }) + + it('does not force SameSite=None if not an AUT request', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + 'set-cookie': 'cookie=value', + }, + }, + res: { + append: appendStub, + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + it('does not force SameSite=None if the first AUT request', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + 'set-cookie': 'cookie=value', + }, + }, + req: { + isAUTFrame: true, + proxiedUrl: 'http://www.foobar.com/multi-domain.html', + }, + res: { + append: appendStub, + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + it('does not force SameSite=None if an AUT request but not cross-origin', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + 'set-cookie': 'cookie=value', + }, + }, + req: { + isAUTFrame: true, + proxiedUrl: 'http://www.foobar.com/multi-domain.html', + }, + res: { + append: appendStub, + }, + }) + + ctx.getPreviousAUTRequestUrl = () => ctx.req.proxiedUrl + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + it('does not force SameSite=None if experimental flag is off', async function () { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + 'set-cookie': 'cookie=value', + }, + }, + req: { + isAUTFrame: true, + }, + res: { + append: appendStub, + }, + config: { + experimentalSessionAndOrigin: false, + }, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + describe('cookie modification scenarios', function () { + const makeScenarios = (output, flippedOutput?) => { + return [ + ['SameSite=Strict; Secure', output], + ['SameSite=Strict', output], + ['SameSite=Lax; Secure', output], + ['SameSite=Lax', output], + ['SameSite=Invalid; Secure', output], + ['SameSite=Invalid', output], + ['SameSite=None', output], + ['', output], + // When Secure is first or there's no SameSite, it ends up as + // "Secure; SameSite=None" instead of "Secure" being second + ['Secure', flippedOutput || output], + ['Secure; SameSite=None', flippedOutput || output], + ] + } + + const withFirefox = { + getCurrentBrowser: () => ({ family: 'firefox' }), + } + + describe('not Firefox', function () { + makeScenarios('SameSite=None; Secure', 'Secure; SameSite=None').forEach(([input, output]) => { + it(`${input} -> ${output}`, async function () { + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledOnce + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) + }) + }) + }) + + describe('Firefox + non-localhost', function () { + makeScenarios('SameSite=None; Secure', 'Secure; SameSite=None').forEach(([input, output]) => { + it(`${input} -> ${output}`, async function () { + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`, { + req: { proxiedUrl: 'https://foobar.com' }, + ...withFirefox, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledOnce + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) + }) + }) + }) + + describe('Firefox + https://localhost', function () { + makeScenarios('SameSite=None; Secure', 'Secure; SameSite=None').forEach(([input, output]) => { + it(`${input} -> ${output}`, async function () { + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`, { + req: { proxiedUrl: 'https://localhost:3500' }, + ...withFirefox, + }) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledOnce + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) + }) + }) + }) + + describe('Firefox + http://localhost', function () { + makeScenarios('SameSite=None').forEach(([input, output]) => { + it(`${input} -> ${output}`, async function () { + const { appendStub, ctx } = prepareContextWithCookie(`insecure=true; cookie=value${input ? '; ' : ''}${input}`, withFirefox) + + await testMiddleware([CopyCookiesFromIncomingRes], ctx) + + expect(appendStub).to.be.calledOnce + expect(appendStub).to.be.calledWith('Set-Cookie', `insecure=true; cookie=value; ${output}`) + }) + }) + }) + }) + }) + + function prepareContext (props) { + const remoteStates = new RemoteStates(() => {}) + const eventEmitter = new EventEmitter() + + // set the primary remote state + remoteStates.set('http://foobar.com') + + // set the secondary remote states + remoteStates.addEventListeners(eventEmitter) + props.secondaryOrigins?.forEach((originPolicy) => { + eventEmitter.emit('cross:origin:bridge:ready', { originPolicy }) + }) + + return { + incomingRes: { + headers: {}, + ...props.incomingRes, + }, + res: { + headers: {}, + on () {}, + ...props.res, + }, + req: { + proxiedUrl: 'http://127.0.0.1:3501/multi-domain.html', + headers: {}, + ...props.req, + }, + incomingResStream: { + pipe () { + return { on () {} } + }, + }, + config: { + experimentalSessionAndOrigin: true, + }, + getCurrentBrowser () { + return { family: 'chromium' } + }, + getPreviousAUTRequestUrl () {}, + remoteStates, + debug () {}, + onError (error) { + throw error + }, + ..._.omit(props, 'incomingRes', 'res', 'req'), + } + } + + function prepareContextWithCookie (cookie, props: any = {}) { + const appendStub = sinon.stub() + const ctx = prepareContext({ + incomingRes: { + headers: { + 'content-type': 'text/html', + 'set-cookie': cookie, + }, + }, + req: { + isAUTFrame: true, + ...props.req, + }, + res: { + append: appendStub, + }, + ..._.omit(props, 'incomingRes', 'res', 'req'), + }) + + return { appendStub, ctx } + } }) }) diff --git a/packages/resolve-dist/lib/index.ts b/packages/resolve-dist/lib/index.ts index 11f878fa80a1..f215b606d4c4 100644 --- a/packages/resolve-dist/lib/index.ts +++ b/packages/resolve-dist/lib/index.ts @@ -6,14 +6,22 @@ export type RunnerPkg = 'runner' | 'runner-ct' type FoldersWithDist = 'static' | 'driver' | RunnerPkg +const getRunnerContents = (filename) => { + fs ??= require('fs-extra') as typeof import('fs-extra') + + return fs.readFile(getPathToDist('runner', filename)) +} + export const getPathToDist = (folder: FoldersWithDist, ...args: string[]) => { return path.join(...[__dirname, '..', '..', folder, 'dist', ...args]) } export const getRunnerInjectionContents = () => { - fs ??= require('fs-extra') as typeof import('fs-extra') + return getRunnerContents('injection.js') +} - return fs.readFile(getPathToDist('runner', 'injection.js')) +export const getRunnerCrossOriginInjectionContents = () => { + return getRunnerContents('injection_cross_origin.js') } export const getPathToIndex = (pkg: RunnerPkg) => { diff --git a/packages/runner-ct/src/iframe/iframes.tsx b/packages/runner-ct/src/iframe/iframes.tsx index 285ea783bdc3..2658c9a269b3 100644 --- a/packages/runner-ct/src/iframe/iframes.tsx +++ b/packages/runner-ct/src/iframe/iframes.tsx @@ -102,8 +102,6 @@ export const Iframes = namedObserver('Iframes', ({ useEffect(() => { eventManager.on('visit:failed', autIframe.current.showVisitFailure) - eventManager.on('before:screenshot', autIframe.current.beforeScreenshot) - eventManager.on('after:screenshot', autIframe.current.afterScreenshot) eventManager.on('script:error', _setScriptError) // TODO: need to take headless mode into account @@ -135,6 +133,8 @@ export const Iframes = namedObserver('Iframes', ({ restoreDom: autIframe.current.restoreDom, highlightEl: autIframe.current.highlightEl, detachDom: autIframe.current.detachDom, + isAUTSameOrigin: autIframe.current.doesAUTMatchTopOriginPolicy, + removeSrc: autIframe.current.removeSrcAttribute, snapshotControls: (snapshotProps) => ( {
${svg_cy}
-

Because experimentalSessionSupport is enabled, Cypress navigates to the default blank page before each test to ensure test reliability.

+

Because experimentalSessionAndOrigin is enabled, Cypress navigates to the default blank page before each test to ensure test reliability.

This is the default blank page.

To test your web application:

    diff --git a/packages/runner-shared/src/dimensions.js b/packages/runner-shared/src/dimensions.js new file mode 100644 index 000000000000..13aa4ddd7698 --- /dev/null +++ b/packages/runner-shared/src/dimensions.js @@ -0,0 +1,89 @@ +import _ from 'lodash' + +const getElementDimensions = ($el) => { + const el = $el.get(0) + + const { offsetHeight, offsetWidth } = el + + const box = { + // offset disregards margin but takes into account border + padding + offset: $el.offset(), + // dont use jquery here for width/height because it uses getBoundingClientRect() which returns scaled values. + // TODO: switch back to using jquery when upgrading to jquery 3.4+ + paddingTop: getPadding($el, 'top'), + paddingRight: getPadding($el, 'right'), + paddingBottom: getPadding($el, 'bottom'), + paddingLeft: getPadding($el, 'left'), + borderTop: getBorder($el, 'top'), + borderRight: getBorder($el, 'right'), + borderBottom: getBorder($el, 'bottom'), + borderLeft: getBorder($el, 'left'), + marginTop: getMargin($el, 'top'), + marginRight: getMargin($el, 'right'), + marginBottom: getMargin($el, 'bottom'), + marginLeft: getMargin($el, 'left'), + } + + // NOTE: offsetWidth/height always give us content + padding + border, so subtract them + // to get the true "clientHeight" and "clientWidth". + // we CANNOT just use "clientHeight" and "clientWidth" because those always return 0 + // for inline elements >_< + box.width = offsetWidth - (box.paddingLeft + box.paddingRight + box.borderLeft + box.borderRight) + box.height = offsetHeight - (box.paddingTop + box.paddingBottom + box.borderTop + box.borderBottom) + + // innerHeight: Get the current computed height for the first + // element in the set of matched elements, including padding but not border. + + // outerHeight: Get the current computed height for the first + // element in the set of matched elements, including padding, border, + // and optionally margin. Returns a number (without 'px') representation + // of the value or null if called on an empty set of elements. + box.heightWithPadding = box.height + box.paddingTop + box.paddingBottom + + box.heightWithBorder = box.heightWithPadding + box.borderTop + box.borderBottom + + box.heightWithMargin = box.heightWithBorder + box.marginTop + box.marginBottom + + box.widthWithPadding = box.width + box.paddingLeft + box.paddingRight + + box.widthWithBorder = box.widthWithPadding + box.borderLeft + box.borderRight + + box.widthWithMargin = box.widthWithBorder + box.marginLeft + box.marginRight + + return box +} + +const getNumAttrValue = ($el, attr) => { + // nuke anything thats not a number or a negative symbol + const num = _.toNumber($el.css(attr).replace(/[^0-9\.-]+/, '')) + + if (!_.isFinite(num)) { + throw new Error('Element attr did not return a valid number') + } + + return num +} + +const getPadding = ($el, dir) => { + return getNumAttrValue($el, `padding-${dir}`) +} + +const getBorder = ($el, dir) => { + return getNumAttrValue($el, `border-${dir}-width`) +} + +const getMargin = ($el, dir) => { + return getNumAttrValue($el, `margin-${dir}`) +} + +const getOuterSize = ($el) => { + return { + width: $el.outerWidth(true), + height: $el.outerHeight(true), + } +} + +export default { + getOuterSize, + getElementDimensions, +} diff --git a/packages/runner-shared/src/dom.js b/packages/runner-shared/src/dom.js index 9ec11ab05d66..547ae45a93e5 100644 --- a/packages/runner-shared/src/dom.js +++ b/packages/runner-shared/src/dom.js @@ -2,6 +2,7 @@ import _ from 'lodash' import retargetEvents from 'react-shadow-dom-retarget-events' import $Cypress from '@packages/driver' +import $dimensions from './dimensions' import { selectorPlaygroundHighlight } from './selector-playground/highlight' import { studioAssertionsMenu } from './studio/assertions-menu' // The '!' tells webpack to disable normal loaders, and keep loaders with `enforce: 'pre'` and `enforce: 'post'` @@ -72,7 +73,7 @@ function addHitBoxLayer (coords, $body) { function addElementBoxModelLayers ($el, $body) { $body = $body || $('body') - const dimensions = getElementDimensions($el) + const dimensions = $dimensions.getElementDimensions($el) const $container = $('
    ') .css({ @@ -306,89 +307,6 @@ function getZIndex (el) { return _.toNumber(el.css('zIndex')) } -function getElementDimensions ($el) { - const el = $el.get(0) - - const { offsetHeight, offsetWidth } = el - - const box = { - offset: $el.offset(), // offset disregards margin but takes into account border + padding - // dont use jquery here for width/height because it uses getBoundingClientRect() which returns scaled values. - // TODO: switch back to using jquery when upgrading to jquery 3.4+ - paddingTop: getPadding($el, 'top'), - paddingRight: getPadding($el, 'right'), - paddingBottom: getPadding($el, 'bottom'), - paddingLeft: getPadding($el, 'left'), - borderTop: getBorder($el, 'top'), - borderRight: getBorder($el, 'right'), - borderBottom: getBorder($el, 'bottom'), - borderLeft: getBorder($el, 'left'), - marginTop: getMargin($el, 'top'), - marginRight: getMargin($el, 'right'), - marginBottom: getMargin($el, 'bottom'), - marginLeft: getMargin($el, 'left'), - } - - // NOTE: offsetWidth/height always give us content + padding + border, so subtract them - // to get the true "clientHeight" and "clientWidth". - // we CANNOT just use "clientHeight" and "clientWidth" because those always return 0 - // for inline elements >_< - // - box.width = offsetWidth - (box.paddingLeft + box.paddingRight + box.borderLeft + box.borderRight) - box.height = offsetHeight - (box.paddingTop + box.paddingBottom + box.borderTop + box.borderBottom) - - // innerHeight: Get the current computed height for the first - // element in the set of matched elements, including padding but not border. - - // outerHeight: Get the current computed height for the first - // element in the set of matched elements, including padding, border, - // and optionally margin. Returns a number (without 'px') representation - // of the value or null if called on an empty set of elements. - box.heightWithPadding = box.height + box.paddingTop + box.paddingBottom - - box.heightWithBorder = box.heightWithPadding + box.borderTop + box.borderBottom - - box.heightWithMargin = box.heightWithBorder + box.marginTop + box.marginBottom - - box.widthWithPadding = box.width + box.paddingLeft + box.paddingRight - - box.widthWithBorder = box.widthWithPadding + box.borderLeft + box.borderRight - - box.widthWithMargin = box.widthWithBorder + box.marginLeft + box.marginRight - - return box -} - -function getNumAttrValue ($el, attr) { - // nuke anything thats not a number or a negative symbol - const num = _.toNumber($el.css(attr).replace(/[^0-9\.-]+/, '')) - - if (!_.isFinite(num)) { - throw new Error('Element attr did not return a valid number') - } - - return num -} - -function getPadding ($el, dir) { - return getNumAttrValue($el, `padding-${dir}`) -} - -function getBorder ($el, dir) { - return getNumAttrValue($el, `border-${dir}-width`) -} - -function getMargin ($el, dir) { - return getNumAttrValue($el, `margin-${dir}`) -} - -function getOuterSize ($el) { - return { - width: $el.outerWidth(true), - height: $el.outerHeight(true), - } -} - function isInViewport (win, el) { let rect = el.getBoundingClientRect() @@ -428,73 +346,13 @@ function getElementsForSelector ({ $root, selector, method, cypressDom }) { return $el } -function addCssAnimationDisabler ($body) { - $(` - - `).appendTo($body) -} - -function removeCssAnimationDisabler ($body) { - $body.find('#__cypress-animation-disabler').remove() -} - -function addBlackoutForElement ($body, $el) { - const dimensions = getElementDimensions($el) - const width = dimensions.widthWithBorder - const height = dimensions.heightWithBorder - const top = dimensions.offset.top - const left = dimensions.offset.left - - const style = styles(` - ${resetStyles} - position: absolute; - top: ${top}px; - left: ${left}px; - width: ${width}px; - height: ${height}px; - background-color: black; - z-index: 2147483647; - `) - - $(`
    `).appendTo($body) -} - -function addBlackout ($body, selector) { - let $el - - try { - $el = $body.find(selector) - if (!$el.length) return - } catch (err) { - // if it's an invalid selector, just ignore it - return - } - - $el.each(function () { - addBlackoutForElement($body, $(this)) - }) -} - -function removeBlackouts ($body) { - $body.find('.__cypress-blackout').remove() -} - export const dom = { - addBlackout, - removeBlackouts, addElementBoxModelLayers, addHitBoxLayer, addOrUpdateSelectorPlaygroundHighlight, openStudioAssertionsMenu, closeStudioAssertionsMenu, - addCssAnimationDisabler, - removeCssAnimationDisabler, getElementsForSelector, - getOuterSize, + getOuterSize: $dimensions.getOuterSize, scrollIntoView, } diff --git a/packages/runner-shared/src/event-manager.js b/packages/runner-shared/src/event-manager.js index 714e691ccd61..755ac17efe2d 100644 --- a/packages/runner-shared/src/event-manager.js +++ b/packages/runner-shared/src/event-manager.js @@ -11,6 +11,7 @@ import { logger } from './logger' import { selectorPlaygroundModel } from './selector-playground' import $Cypress from '@packages/driver' +import * as cors from '@packages/network/lib/cors' const $ = $Cypress.$ const ws = client.connect({ @@ -130,6 +131,10 @@ export const eventManager = { }) }) + ws.on('cross:origin:delaying:html', (request) => { + Cypress.primaryOriginCommunicator.emit('delaying:html', request) + }) + _.each(localToReporterEvents, (event) => { localBus.on(event, (...args) => { reporterBus.emit(event, ...args) @@ -313,6 +318,14 @@ export const eventManager = { this._clearAllCookies() this._setUnload() }) + + // The window.top should not change between test reloads, and we only need to bind the message event once + // Forward all message events to the current instance of the multi-origin communicator + if (!window.top) throw new Error('missing window.top in event-manager') + + window.top.addEventListener('message', ({ data, source }) => { + Cypress.primaryOriginCommunicator.onMessage({ data, source }) + }, false) }, start (config) { @@ -438,7 +451,7 @@ export const eventManager = { reporterBus.emit('reporter:log:state:changed', displayProps) }) - Cypress.on('before:screenshot', (config, cb) => { + const handleBeforeScreenshot = (config, cb) => { const beforeThenCb = () => { localBus.emit('before:screenshot', config) cb() @@ -455,11 +468,15 @@ export const eventManager = { } if (!wait) beforeThenCb() - }) + } - Cypress.on('after:screenshot', (config) => { + Cypress.on('before:screenshot', handleBeforeScreenshot) + + const handleAfterScreenshot = (config) => { localBus.emit('after:screenshot', config) - }) + } + + Cypress.on('after:screenshot', handleAfterScreenshot) _.each(driverToReporterEvents, (event) => { Cypress.on(event, (...args) => { @@ -500,6 +517,90 @@ export const eventManager = { studioRecorder.testFailed() } }) + + Cypress.on('test:before:run', (...args) => { + Cypress.primaryOriginCommunicator.toAllSpecBridges('test:before:run', ...args) + }) + + Cypress.on('test:before:run:async', (...args) => { + Cypress.primaryOriginCommunicator.toAllSpecBridges('test:before:run:async', ...args) + }) + + // Inform all spec bridges that the primary origin has begun to unload. + Cypress.on('window:before:unload', () => { + Cypress.primaryOriginCommunicator.toAllSpecBridges('before:unload') + }) + + Cypress.primaryOriginCommunicator.on('window:load', ({ url }, originPolicy) => { + // Sync stable if the expected origin has loaded. + // Only listen to window load events from the most recent secondary origin, This prevents nondeterminism in the case where we redirect to an already + // established spec bridge, but one that is not the current or next cy.origin command. + if (cy.state('latestActiveOriginPolicy') === originPolicy) { + // We remain in an anticipating state until either a load even happens or a timeout. + cy.state('autOrigin', cy.state('autOrigin', cors.getOriginPolicy(url))) + cy.isAnticipatingCrossOriginResponseFor(undefined) + cy.isStable(true, 'load') + // Prints out the newly loaded URL + Cypress.emit('internal:window:load', { type: 'cross:origin', url }) + // Re-broadcast to any other specBridges. + Cypress.primaryOriginCommunicator.toAllSpecBridges('window:load', { url }) + } + }) + + Cypress.primaryOriginCommunicator.on('before:unload', () => { + // We specifically don't call 'cy.isStable' here because we don't want to inject another load event. + // Unstable is unstable regardless of where it initiated from. + cy.state('isStable', false) + // Re-broadcast to any other specBridges. + Cypress.primaryOriginCommunicator.toAllSpecBridges('before:unload') + }) + + Cypress.primaryOriginCommunicator.on('expect:origin', (originPolicy) => { + localBus.emit('expect:origin', originPolicy) + }) + + Cypress.primaryOriginCommunicator.on('viewport:changed', (viewport, originPolicy) => { + const callback = () => { + Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'viewport:changed:end') + } + + Cypress.primaryOriginCommunicator.emit('sync:viewport', viewport) + localBus.emit('viewport:changed', viewport, callback) + }) + + Cypress.primaryOriginCommunicator.on('before:screenshot', (config, originPolicy) => { + const callback = () => { + Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'before:screenshot:end') + } + + handleBeforeScreenshot(config, callback) + }) + + Cypress.primaryOriginCommunicator.on('url:changed', ({ url }) => { + localBus.emit('url:changed', url) + }) + + Cypress.primaryOriginCommunicator.on('after:screenshot', handleAfterScreenshot) + + const crossOriginLogs = {} + + Cypress.primaryOriginCommunicator.on('log:added', (attrs) => { + // If the test is over and the user enters interactive snapshot mode, do not add cross origin logs to the test runner. + if (Cypress.state('test')?.final) return + + // Create a new local log representation of the cross origin log. + // It will be attached to the current command. + // We also keep a reference to it to update it in the future. + crossOriginLogs[attrs.id] = Cypress.log(attrs) + }) + + Cypress.primaryOriginCommunicator.on('log:changed', (attrs) => { + // Retrieve the referenced log and update it. + const log = crossOriginLogs[attrs.id] + + // this will trigger a log changed event for the log itself. + log?.set(attrs) + }) }, _runDriver (state) { @@ -544,6 +645,7 @@ export const eventManager = { // but we want to be aggressive here // and force GC early and often Cypress.removeAllListeners() + Cypress.primaryOriginCommunicator.removeAllListeners() localBus.emit('restart') }) @@ -592,6 +694,11 @@ export const eventManager = { ws.emit('spec:changed', specFile) }, + notifyCrossOriginBridgeReady (originPolicy) { + // Any multi-origin event appends the origin as the third parameter and we do the same here for this short circuit + Cypress.primaryOriginCommunicator.emit('bridge:ready', undefined, originPolicy) + }, + focusTests () { ws.emit('focus:tests') }, diff --git a/packages/runner-shared/src/iframe/aut-iframe.js b/packages/runner-shared/src/iframe/aut-iframe.js index 30faf2aa19ef..4a321ba4f31e 100644 --- a/packages/runner-shared/src/iframe/aut-iframe.js +++ b/packages/runner-shared/src/iframe/aut-iframe.js @@ -69,6 +69,40 @@ export class AutIframe { return Cypress.cy.detachDom(this._contents()) } + /** + * If the AUT is cross origin relative to top, a security error is thrown and the method returns false + * If the AUT is cross origin relative to top and chromeWebSecurity is false, origins of the AUT and top need to be compared and returns false + * Otherwise, if top and the AUT match origins, the method returns true. + * If the AUT origin is "about://blank", that means the src attribute has been stripped off the iframe and is adhering to same origin policy + */ + doesAUTMatchTopOriginPolicy = () => { + const Cypress = eventManager.getCypress() + + if (!Cypress) return + + try { + const { href: currentHref } = this.$iframe[0].contentWindow.document.location + const locationTop = Cypress.Location.create(window.location.href) + const locationAUT = Cypress.Location.create(currentHref) + + return locationTop.originPolicy === locationAUT.originPolicy || locationAUT.originPolicy === 'about://blank' + } catch (err) { + if (err.name === 'SecurityError') { + return false + } + + throw err + } + } + + /** + * Removes the src attribute from the AUT iframe, resulting in 'about:blank' being loaded into the iframe + * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-src for more details + */ + removeSrcAttribute = () => { + this.$iframe.removeAttr('src') + } + visitBlank = ({ type } = { type: null }) => { return new Promise((resolve) => { this.$iframe[0].src = 'about:blank' @@ -91,6 +125,23 @@ export class AutIframe { } restoreDom = (snapshot) => { + if (!this.doesAUTMatchTopOriginPolicy()) { + /** + * A load event fires here when the src is removed (as does an unload event). + * This is equivalent to loading about:blank (see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-src). + * This doesn't resort in a log message being generated for a new page. + * In the event-manager code, we stop adding logs from other domains once the spec is finished. + */ + this.$iframe.one('load', () => { + this.restoreDom(snapshot) + }) + + // The iframe is in a cross origin state. Remove the src attribute to adhere to same origin policy. NOTE: This should only be done ONCE. + this.removeSrcAttribute() + + return + } + const Cypress = eventManager.getCypress() const { headStyles, bodyStyles } = Cypress ? Cypress.cy.getStyles(snapshot) : {} const { body, htmlAttrs } = snapshot @@ -394,40 +445,6 @@ export class AutIframe { }) } - beforeScreenshot = (config) => { - // could fail if iframe is cross-origin, so fail gracefully - try { - if (config.disableTimersAndAnimations) { - dom.addCssAnimationDisabler(this._body()) - } - - _.each(config.blackout, (selector) => { - dom.addBlackout(this._body(), selector) - }) - } catch (err) { - /* eslint-disable no-console */ - console.error('Failed to modify app dom:') - console.error(err) - /* eslint-disable no-console */ - } - } - - afterScreenshot = (config) => { - // could fail if iframe is cross-origin, so fail gracefully - try { - if (config.disableTimersAndAnimations) { - dom.removeCssAnimationDisabler(this._body()) - } - - dom.removeBlackouts(this._body()) - } catch (err) { - /* eslint-disable no-console */ - console.error('Failed to modify app dom:') - console.error(err) - /* eslint-disable no-console */ - } - } - startStudio = () => { if (studioRecorder.isLoading) { studioRecorder.start(this._body()[0]) diff --git a/packages/runner-shared/src/iframe/iframe-model.js b/packages/runner-shared/src/iframe/iframe-model.js index e2639ce6974c..fca3665fb22c 100644 --- a/packages/runner-shared/src/iframe/iframe-model.js +++ b/packages/runner-shared/src/iframe/iframe-model.js @@ -6,12 +6,14 @@ import { studioRecorder } from '../studio' import { eventManager } from '../event-manager' export class IframeModel { - constructor ({ state, detachDom, restoreDom, highlightEl, snapshotControls }) { + constructor ({ state, detachDom, restoreDom, highlightEl, snapshotControls, isAUTSameOrigin, removeSrc }) { this.state = state this.detachDom = detachDom this.restoreDom = restoreDom this.highlightEl = highlightEl this.snapshotControls = snapshotControls + this.isAUTSameOrigin = isAUTSameOrigin + this.removeSrc = removeSrc this._reset() } @@ -217,6 +219,31 @@ export class IframeModel { } _storeOriginalState () { + if (!this.isAUTSameOrigin()) { + const Cypress = eventManager.getCypress() + + /** + * This only happens if the AUT ends in a cross origin state that the primary doesn't have access to. + * In this case, the final snapshot request from the primary is sent out to the cross-origin spec bridges. + * The spec bridge that matches the origin policy will take a snapshot and send it back to the primary for the runner to store in originalState. + */ + Cypress.primaryOriginCommunicator.toAllSpecBridges('generate:final:snapshot', this.state.url) + Cypress.primaryOriginCommunicator.once('final:snapshot:generated', (finalSnapshot) => { + this.originalState = { + body: finalSnapshot.body, + htmlAttrs: finalSnapshot.htmlAttrs, + snapshot: finalSnapshot, + url: this.state.url, + // TODO: use same attr for both runner and runner-ct states. + // these refer to the same thing - the viewport dimensions. + viewportWidth: this.state.width, + viewportHeight: this.state.height, + } + }) + + return + } + const finalSnapshot = this.detachDom() if (!finalSnapshot) return diff --git a/packages/runner/cypress/integration/sessions.ui.spec.js b/packages/runner/cypress/integration/sessions.ui.spec.js index 7cb20efc01e0..3c9d4561ace5 100644 --- a/packages/runner/cypress/integration/sessions.ui.spec.js +++ b/packages/runner/cypress/integration/sessions.ui.spec.js @@ -1,6 +1,6 @@ const helpers = require('../support/helpers') -const { runIsolatedCypress } = helpers.createCypress({ config: { experimentalSessionSupport: true } }) +const { runIsolatedCypress } = helpers.createCypress({ config: { experimentalSessionAndOrigin: true } }) describe('runner/cypress sessions.ui.spec', { viewportWidth: 1000, viewportHeight: 660 }, () => { it('empty session with no data', () => { diff --git a/packages/runner/index.d.ts b/packages/runner/index.d.ts index 918d244cd9e7..8e559dc28663 100644 --- a/packages/runner/index.d.ts +++ b/packages/runner/index.d.ts @@ -8,3 +8,4 @@ /// /// +/// diff --git a/packages/runner/injection/index.js b/packages/runner/injection/main.js similarity index 86% rename from packages/runner/injection/index.js rename to packages/runner/injection/main.js index 134cda8fc352..ad3eb91de45a 100644 --- a/packages/runner/injection/index.js +++ b/packages/runner/injection/main.js @@ -12,7 +12,8 @@ import { createTimers } from './timers' const Cypress = window.Cypress = parent.Cypress if (!Cypress) { - throw new Error('Something went terribly wrong and we cannot proceed. We expected to find the global Cypress in the parent window but it is missing!. This should never happen and likely is a bug. Please open an issue!') + throw new Error('Something went terribly wrong and we cannot proceed. We expected to find the global \ +Cypress in the parent window but it is missing. This should never happen and likely is a bug. Please open an issue.') } // We wrap timers in the injection code because if we do it in the driver (like diff --git a/packages/runner/injection/multi-domain.js b/packages/runner/injection/multi-domain.js new file mode 100644 index 000000000000..316752571a2f --- /dev/null +++ b/packages/runner/injection/multi-domain.js @@ -0,0 +1,54 @@ +/** + * This is the entry point for the script that gets injected into + * the AUT on a secondary origin. It gets bundled on its own and injected + * into the of the AUT by `packages/proxy`. + * + * If adding to this bundle, try to keep it light and free of + * dependencies. + */ + +import { createTimers } from './timers' + +const findCypress = () => { + for (let index = 0; index < window.parent.frames.length; index++) { + const frame = window.parent.frames[index] + + try { + // If Cypress is defined and we haven't gotten a cross origin error we have found the correct bridge. + if (frame.Cypress) { + // If the ending $ is included in the template string, it breaks transpilation + // eslint-disable-next-line no-useless-concat + const frameHostRegex = new RegExp(`(^|\\.)${ frame.location.host.replaceAll('.', '\\.') }` + '$') + + // Compare the locations origin policy without pulling in more dependencies. + // Compare host, protocol and test that the window's host ends with the frame's host. + // This works because the spec bridge's host is always created without a sub domain. + if (window.location.port === frame.location.port + && window.location.protocol === frame.location.protocol + && frameHostRegex.test(window.location.host)) { + return frame.Cypress + } + } + } catch (error) { + // Catch DOMException: Blocked a frame from accessing a cross-origin frame. + if (error.name !== 'SecurityError') { + throw error + } + } + } +} + +const Cypress = findCypress() + +// the timers are wrapped in the injection code similar to the primary origin +const timers = createTimers() + +Cypress.removeAllListeners('app:timers:reset') +Cypress.removeAllListeners('app:timers:pause') + +Cypress.on('app:timers:reset', timers.reset) +Cypress.on('app:timers:pause', timers.pause) + +timers.wrap() + +Cypress.action('app:window:before:load', window) diff --git a/packages/runner/src/iframe/iframe.scss b/packages/runner/src/iframe/iframe.scss index e766a261144b..b78847792590 100644 --- a/packages/runner/src/iframe/iframe.scss +++ b/packages/runner/src/iframe/iframe.scss @@ -95,3 +95,11 @@ width: 100%; } } + +.spec-bridge-iframe { + border: none; + height: 100%; + position: fixed; + visibility: hidden; + width: 100%; +} diff --git a/packages/runner/src/iframe/iframes.jsx b/packages/runner/src/iframe/iframes.jsx index afc8449d65a1..f5aea2a75673 100644 --- a/packages/runner/src/iframe/iframes.jsx +++ b/packages/runner/src/iframe/iframes.jsx @@ -74,8 +74,6 @@ export default class Iframes extends Component { this.autIframe = new AutIframe(this.props.config) this.props.eventManager.on('visit:failed', this.autIframe.showVisitFailure) - this.props.eventManager.on('before:screenshot', this.autIframe.beforeScreenshot) - this.props.eventManager.on('after:screenshot', this.autIframe.afterScreenshot) this.props.eventManager.on('script:error', this._setScriptError) this.props.eventManager.on('visit:blank', this.autIframe.visitBlank) @@ -94,6 +92,8 @@ export default class Iframes extends Component { this.props.eventManager.on('print:selector:elements:to:console', this._printSelectorElementsToConsole) + this.props.eventManager.on('expect:origin', this._addCrossOriginIframe) + this._disposers.push(autorun(() => { this.autIframe.toggleSelectorPlayground(selectorPlaygroundModel.isEnabled) })) @@ -109,6 +109,8 @@ export default class Iframes extends Component { restoreDom: this.autIframe.restoreDom, highlightEl: this.autIframe.highlightEl, detachDom: this.autIframe.detachDom, + isAUTSameOrigin: this.autIframe.doesAUTMatchTopOriginPolicy, + removeSrc: this.autIframe.removeSrcAttribute, snapshotControls: (snapshotProps) => ( { + const id = `Spec Bridge: ${location.originPolicy}` + + // if it already exists, don't add another one + if (document.getElementById(id)) { + this.props.eventManager.notifyCrossOriginBridgeReady(location.originPolicy) + + return + } + + this._addIframe({ + id, + // the cross origin iframe is added to the document body instead of the + // container since it needs to match the size of the top window for screenshots + $container: $(document.body), + className: 'spec-bridge-iframe', + src: `${location.originPolicy}/${this.props.config.namespace}/multi-domain-iframes`, + }) + } + + _addIframe ({ $container, id, src, className }) { const $specIframe = $('