diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 5649488074b9..17e85156931c 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -37,6 +37,7 @@ "topnav", "unconfigured", "unplugin", + "unref", "unrunnable", "unstaged", "urql", @@ -48,4 +49,4 @@ ], "ignoreWords": [], "import": [] -} \ No newline at end of file +} diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 15df81c5986d..14a837d82cc9 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -641,12 +641,6 @@ declare namespace Cypress { */ off: Actions - /** - * Used to import dependencies within the cy.origin() callback - * @see https://on.cypress.io/origin - */ - require: (id: string) => any - /** * Trigger action * @private @@ -1095,7 +1089,7 @@ declare namespace Cypress { * * @see https://on.cypress.io/session */ - session(id: string | object, setup?: () => void, options?: SessionOptions): Chainable + session(id: string | object, setup: () => void, options?: SessionOptions): Chainable /** * Get the window.document of the page that is currently active. @@ -2814,12 +2808,32 @@ declare namespace Cypress { */ supportFile: string | false /** - * The test isolation level applied to ensure a clean slate between tests. - * - legacy - resets/clears aliases, intercepts, clock, viewport, cookies, and local storage before each test. - * - strict - applies all resets/clears from legacy, plus clears the page by visiting 'about:blank' to ensure clean app state before each test. - * @default "legacy", however, when experimentalSessionAndOrigin=true, the default is "strict" + * The test isolation ensures a clean browser context between tests. This option is only available when + * `experimentalSessionAndOrigin=true`. + * + * Cypress will always reset/clear aliases, intercepts, clock, and viewport before each test + * to ensure a clean test slate; i.e. this configuration only impacts the browser context. + * + * Note: the [`cy.session()`](https://on.cypress.io/session) command will inherent this value to determine whether + * or not the page is cleared when the command executes. This command is only available in end-to-end testing. + * + * - on - The page is cleared before each test. Cookies, local storage and session storage in all domains are cleared + * before each test. The `cy.session()` command will also clear the page and current browser context when creating + * or restoring the browser session. + * - off - The current browser state will persist between tests. The page does not clear before the test and cookies, local + * storage and session storage will be available in the next test. The `cy.session()` command will only clear the + * current browser context when creating or restoring the browser session - the current page will not clear. + * + * Tradeoffs: + * Turning test isolation off may improve performance of end-to-end tests, however, previous tests could impact the + * browser state of the next test and cause inconsistency when using .only(). Be mindful to write isolated tests when + * test isolation is off. If a test in the suite impacts the state of other tests and it were to fail, you could see + * misleading errors in later tests which makes debugging clunky. See the [documentation](https://on.cypress.io/test-isolation) + * for more information. + * + * @default null, when experimentalSessionAndOrigin=false. The default is 'on' when experimentalSessionAndOrigin=true. */ - testIsolation: 'legacy' | 'strict' + testIsolation: null | 'on' | 'off' /** * Path to folder where videos will be saved after a headless or CI run * @default "cypress/videos" diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index f13753aedc71..f870bf64c50b 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -922,7 +922,6 @@ namespace CypressTaskTests { } namespace CypressSessionsTests { - cy.session('user') cy.session('user', () => {}) cy.session({ name: 'bob' }, () => {}) cy.session('user', () => {}, {}) @@ -931,6 +930,7 @@ namespace CypressSessionsTests { }) cy.session() // $ExpectError + cy.session('user') // $ExpectError cy.session(null) // $ExpectError cy.session('user', () => {}, { validate: { foo: true } // $ExpectError diff --git a/graphql-codegen.yml b/graphql-codegen.yml index 496238eb8d1d..e2b11c51214b 100644 --- a/graphql-codegen.yml +++ b/graphql-codegen.yml @@ -18,7 +18,7 @@ vueOperations: &vueOperations - 'typescript-operations' - 'typed-document-node': # Intentionally specified under typed-document-node rather than top level config, - # becuase we don't want it flattening the types for the operations + # because we don't want it flattening the types for the operations flattenGeneratedTypes: true vueTesting: &vueTesting @@ -122,9 +122,10 @@ generates: './packages/app/src/generated/graphql-test.ts': documents: - './packages/app/src/**/*.{vue,ts,tsx,js,jsx}' - - './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}' + - './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}' <<: *vueTesting './packages/frontend-shared/src/generated/graphql-test.ts': - documents: './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}' + documents: + - './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}' <<: *vueTesting diff --git a/npm/grep/CHANGELOG.md b/npm/grep/CHANGELOG.md new file mode 100644 index 000000000000..88538b9d4ca8 --- /dev/null +++ b/npm/grep/CHANGELOG.md @@ -0,0 +1,6 @@ +# [@cypress/grep-v3.1.0](https://github.com/cypress-io/cypress/compare/@cypress/grep-v3.0.3...@cypress/grep-v3.1.0) (2022-10-21) + + +### Features + +* **grep:** move cypress-grep to @cypress/grep ([#23887](https://github.com/cypress-io/cypress/issues/23887)) ([d422aad](https://github.com/cypress-io/cypress/commit/d422aadfa10e5aaac17ed0e4dd5e18a73d821490)) diff --git a/npm/webpack-dev-server/CHANGELOG.md b/npm/webpack-dev-server/CHANGELOG.md index d29af00484ef..c812894ef00c 100644 --- a/npm/webpack-dev-server/CHANGELOG.md +++ b/npm/webpack-dev-server/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@cypress/webpack-dev-server-v2.4.1](https://github.com/cypress-io/cypress/compare/@cypress/webpack-dev-server-v2.4.0...@cypress/webpack-dev-server-v2.4.1) (2022-10-19) + + +### Bug Fixes + +* clean up inconsistencies in UI between sentence case and title case ([#23681](https://github.com/cypress-io/cypress/issues/23681)) ([f73aef5](https://github.com/cypress-io/cypress/commit/f73aef54b041fe08d939b52e5c6fe1d133502051)) + # [@cypress/webpack-dev-server-v2.4.0](https://github.com/cypress-io/cypress/compare/@cypress/webpack-dev-server-v2.3.0...@cypress/webpack-dev-server-v2.4.0) (2022-10-13) diff --git a/npm/webpack-preprocessor/CHANGELOG.md b/npm/webpack-preprocessor/CHANGELOG.md index d45b5015f748..4da0f28d80fc 100644 --- a/npm/webpack-preprocessor/CHANGELOG.md +++ b/npm/webpack-preprocessor/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@cypress/webpack-preprocessor-v5.15.0](https://github.com/cypress-io/cypress/compare/@cypress/webpack-preprocessor-v5.14.0...@cypress/webpack-preprocessor-v5.15.0) (2022-10-19) + + +### Features + +* Enable requiring cy.origin dependencies with require() and import() ([#24294](https://github.com/cypress-io/cypress/issues/24294)) ([1b29ce7](https://github.com/cypress-io/cypress/commit/1b29ce74aafa0bc5015a93cb618b7fbda243e07a)) + # [@cypress/webpack-preprocessor-v5.14.0](https://github.com/cypress-io/cypress/compare/@cypress/webpack-preprocessor-v5.13.1...@cypress/webpack-preprocessor-v5.14.0) (2022-10-04) diff --git a/npm/webpack-preprocessor/index.ts b/npm/webpack-preprocessor/index.ts index 3d001b25d199..a8b98a3e3f5c 100644 --- a/npm/webpack-preprocessor/index.ts +++ b/npm/webpack-preprocessor/index.ts @@ -341,8 +341,8 @@ const preprocessor: WebpackPreprocessor = (options: PreprocessorOptions = {}): F bundles[filePath].deferreds.length = 0 } - // the cross-origin-callback-loader extracts any cy.origin() callback - // functions that contains Cypress.require() and stores their sources + // the cross-origin-callback-loader extracts any cross-origin callback + // functions that require dependencies and stores their sources // in the CrossOriginCallbackStore. it saves the callbacks per source // files, since that's the context it has. here we need to unfurl // what dependencies the input source file has so we can know which diff --git a/npm/webpack-preprocessor/lib/cross-origin-callback-compile.ts b/npm/webpack-preprocessor/lib/cross-origin-callback-compile.ts index 293b9538d4c6..d66606c5ca5b 100644 --- a/npm/webpack-preprocessor/lib/cross-origin-callback-compile.ts +++ b/npm/webpack-preprocessor/lib/cross-origin-callback-compile.ts @@ -68,7 +68,7 @@ interface CompileOptions { } // the cross-origin-callback-loader extracts any cy.origin() callback functions -// that contains Cypress.require() and stores their sources in the +// that includes dependencies and stores their sources in the // CrossOriginCallbackStore. this sends those sources through webpack again // to process any dependencies and create bundles for each callback function export const compileCrossOriginCallbackFiles = (files: CrossOriginCallbackStoreFile[], options: CompileOptions): Promise => { diff --git a/npm/webpack-preprocessor/lib/cross-origin-callback-loader.ts b/npm/webpack-preprocessor/lib/cross-origin-callback-loader.ts index deddb99ae033..3845bcf253c8 100644 --- a/npm/webpack-preprocessor/lib/cross-origin-callback-loader.ts +++ b/npm/webpack-preprocessor/lib/cross-origin-callback-loader.ts @@ -12,21 +12,13 @@ import utils from './utils' const debug = Debug('cypress:webpack') -// this loader makes supporting dependencies within the cy.origin() callbacks -// possible. it does this by doing the following: -// - extracting callback(s) -// - the callback(s) is/are kept in memory and then run back through webpack +// this loader makes supporting dependencies within cross-origin callbacks +// possible. if there are no dependencies (e.g. no requires/imports), it's a +// noop. otherwise: it does this by doing the following: +// - extracts the callbacks +// - the callbacks are kept in memory and then run back through webpack // once the initial file compilation is complete -// - users use Cypress.require() in their test code instead of require(). -// this is because we don't want require()s nested within the callback -// to be processed in the initial compilation. this both improves -// performance and prevents errors (when the dependency has ES import -// statements, babel will error because they're not top-level since -// the require is not top-level) -// - replacing Cypress.require() with require() -// - this allows the require()s to be processed normally during the -// compilation of the callback itself. -// - replacing the callback(s) with object(s) +// - replaces the callbacks with objects // - this object references the file the callback will be output to by // its own compilation. this allows the runtime to get the file and // run it in its origin's context. @@ -77,9 +69,9 @@ export default function (source: string, map, meta, store = crossOriginCallbackS const lastArg = _.last(path.get('arguments')) - // the user could try an invalid signature for cy.origin() where the - // last argument is not a function. in this case, we'll return the - // unmodified code and it will be a runtime validation error + // the user could try an invalid signature where the last argument is + // not a function. in this case, we'll return the unmodified code and + // it will be a runtime validation error if ( !lastArg || ( !lastArg.isArrowFunctionExpression() @@ -89,21 +81,17 @@ export default function (source: string, map, meta, store = crossOriginCallbackS return } - // replace instances of Cypress.require('dep') with require('dep') + // determine if there are any requires/imports within the callback lastArg.traverse({ CallExpression (path) { - const callee = path.get('callee') as NodePath - - // e.g. const dep = Cypress.require('../path/to/dep') - if (callee.matchesPattern('Cypress.require')) { + if ( + // e.g. const dep = require('../path/to/dep') + // @ts-ignore + path.node.callee.name === 'require' + // e.g. const dep = await import('../path/to/dep') + || path.node.callee.type as string === 'Import' + ) { hasDependencies = true - - path.replaceWith( - t.callExpression( - callee.node.property as t.Expression, // 'require' - path.get('arguments').map((arg) => arg.node), // ['../path/to/dep'] - ), - ) } }, }, this) @@ -150,7 +138,7 @@ export default function (source: string, map, meta, store = crossOriginCallbackS // replaces callback function with object referencing the extracted // function's callback name and output file path in the form // { callbackName: , outputFilePath: } - // this is used at runtime when cy.origin() is run to execute the bundle + // this is used at runtime when the command is run to execute the bundle // generated for the extracted callback function lastArg.replaceWith( t.objectExpression([ @@ -167,7 +155,7 @@ export default function (source: string, map, meta, store = crossOriginCallbackS }, }) - // if we found Cypress.require()s, re-generate the code from the AST + // if we found requires/imports, re-generate the code from the AST if (hasDependencies) { debug('callback with modified source') @@ -183,6 +171,6 @@ export default function (source: string, map, meta, store = crossOriginCallbackS } debug('callback with original source') - // if no Cypress.require()s were found, callback with the original source/map + // if no requires/imports were found, callback with the original source/map this.callback(null, source, map) } diff --git a/npm/webpack-preprocessor/test/unit/cross-origin-callback-loader.spec.ts b/npm/webpack-preprocessor/test/unit/cross-origin-callback-loader.spec.ts index 22888cd11839..98714050e181 100644 --- a/npm/webpack-preprocessor/test/unit/cross-origin-callback-loader.spec.ts +++ b/npm/webpack-preprocessor/test/unit/cross-origin-callback-loader.spec.ts @@ -61,7 +61,7 @@ describe('./lib/cross-origin-callback-loader', () => { expect(store.addFile).not.to.be.called }) - it('is a noop when cy.origin() callback does not contain Cypress.require()', () => { + it('is a noop when cy.origin() callback does not contain require() or import()', () => { const source = `it('test', () => { cy.origin('http://www.foobar.com:3500', () => {}) })` @@ -90,11 +90,30 @@ describe('./lib/cross-origin-callback-loader', () => { sinon.stub(utils, 'tmpdir').returns('/path/to/tmp') }) - it('replaces cy.origin() callback with an object', () => { + it('replaces cy.origin() callback with an object when using require()', () => { const { resultingSource, resultingMap } = callLoader(stripIndent` it('test', () => { cy.origin('http://www.foobar.com:3500', () => { - Cypress.require('../support/utils') + require('../support/utils') + }) + })`) + + expect(resultingSource).to.equal(stripIndent` + it('test', () => { + cy.origin('http://www.foobar.com:3500', { + "callbackName": "__cypressCrossOriginCallback", + "outputFilePath": "/path/to/tmp/cross-origin-cb-abc123.js" + }); + });`) + + expect(resultingMap).to.be.undefined + }) + + it('replaces cy.origin() callback with an object when using import()', () => { + const { resultingSource, resultingMap } = callLoader(stripIndent` + it('test', () => { + cy.origin('http://www.foobar.com:3500', async () => { + await import('../support/utils') }) })`) @@ -113,7 +132,7 @@ describe('./lib/cross-origin-callback-loader', () => { const { resultingSource, resultingMap } = callLoader(stripIndent` it('test', () => { cy.other('http://www.foobar.com:3500', () => { - Cypress.require('../support/utils') + require('../support/utils') }) })`, ['other']) @@ -129,11 +148,11 @@ describe('./lib/cross-origin-callback-loader', () => { expect(resultingMap).to.be.undefined }) - it('adds the file to the store, replacing Cypress.require() with require()', () => { + it('adds the file to the store, replacing require() with require()', () => { const { store } = callLoader( `it('test', () => { cy.origin('http://www.foobar.com:3500', () => { - Cypress.require('../support/utils') + require('../support/utils') }) })`, ) @@ -149,7 +168,7 @@ describe('./lib/cross-origin-callback-loader', () => { const { store } = callLoader( `it('test', () => { cy.origin('http://www.foobar.com:3500', function () { - Cypress.require('../support/utils') + require('../support/utils') }) })`, ) @@ -164,7 +183,7 @@ describe('./lib/cross-origin-callback-loader', () => { const { store } = callLoader( `it('test', () => { cy.origin('http://www.foobar.com:3500', () => { - Cypress.require('../support/utils') + require('../support/utils') }) })`, ) @@ -179,7 +198,7 @@ describe('./lib/cross-origin-callback-loader', () => { const { store } = callLoader( `it('test', () => { cy.origin('http://www.foobar.com:3500', () => { - const utils = Cypress.require('../support/utils') + const utils = require('../support/utils') utils.foo() }) })`, @@ -193,13 +212,13 @@ describe('./lib/cross-origin-callback-loader', () => { }`) }) - it('works with multiple Cypress.require()s', () => { + it('works with multiple require()s', () => { const { store } = callLoader( `it('test', () => { cy.origin('http://www.foobar.com:3500', () => { - Cypress.require('../support/commands') - const utils = Cypress.require('../support/utils') - const _ = Cypress.require('lodash') + require('../support/commands') + const utils = require('../support/utils') + const _ = require('lodash') }) })`, ) @@ -220,7 +239,7 @@ describe('./lib/cross-origin-callback-loader', () => { cy .wrap({}) .origin('http://www.foobar.com:3500', () => { - Cypress.require('../support/commands') + require('../support/commands') }) })`, ) @@ -236,7 +255,7 @@ describe('./lib/cross-origin-callback-loader', () => { `it('test', () => { cy.origin('http://www.foobar.com:3500', () => { const someVar = 'someValue' - const result = Cypress.require('./fn')(someVar) + const result = require('./fn')(someVar) expect(result).to.equal('mutated someVar') }) })`, @@ -256,7 +275,7 @@ describe('./lib/cross-origin-callback-loader', () => { const { store } = callLoader( `it('test', () => { cy.origin('http://www.foobar.com:3500', { args: { foo: 'foo'}}, ({ foo }) => { - const result = Cypress.require('./fn')(foo) + const result = require('./fn')(foo) expect(result).to.equal('mutated someVar') }) })`, diff --git a/package.json b/package.json index 5bc2880accda..4ce2769a4444 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cypress", - "version": "10.10.0", + "version": "10.11.0", "description": "Cypress is a next generation front end testing tool built for the modern web", "private": true, "scripts": { diff --git a/packages/app/cypress/e2e/runner/sessions.ui.cy.ts b/packages/app/cypress/e2e/runner/sessions.ui.cy.ts index 6d1d0e428456..93a8fe22fe7e 100644 --- a/packages/app/cypress/e2e/runner/sessions.ui.cy.ts +++ b/packages/app/cypress/e2e/runner/sessions.ui.cy.ts @@ -132,71 +132,54 @@ describe('runner/cypress sessions.ui.spec', { // cy.percySnapshot() // TODO: restore when Percy CSS is fixed. See https://github.com/cypress-io/cypress/issues/23435 }) - describe('restores saved session', () => { - beforeEach(() => { - loadSpec({ - projectName: 'session-and-origin-e2e-specs', - filePath: 'session/restores_saved_session.cy.js', - passCount: 5, - failCount: 1, - }) + it('restores session as expected', () => { + loadSpec({ + projectName: 'session-and-origin-e2e-specs', + filePath: 'session/restores_saved_session.cy.js', + passCount: 2, }) - it('restores session as expected', () => { - cy.get('.test').each(($el, index) => { - if (index < 5) { // don't click on failed test - cy.wrap($el).click() - } - }) - - cy.log('validate new session was created in first test') - cy.get('.test').eq(0).within(() => { - validateSessionsInstrumentPanel(['user1']) - cy.get('.command-name-session').contains('created') - }) - - cy.log('validate saved session was used in second test') - cy.get('.test').eq(1).within(() => { - validateSessionsInstrumentPanel(['user1']) - cy.get('.command-name-session') - .within(() => { - cy.get('.command-expander').first().click() - cy.contains('user1') - cy.contains('restored') + cy.get('.test').each(($el, index) => { + if (index < 5) { // don't click on failed test + cy.wrap($el).click() + } + }) - cy.get('.command-name-Clear-page').should('have.length', 1) + cy.log('validate new session was created in first test') + cy.get('.test').eq(0).within(() => { + validateSessionsInstrumentPanel(['user1']) + cy.get('.command-name-session').contains('created') + }) - cy.contains('Restore saved session') + cy.log('validate saved session was used in second test') + cy.get('.test').eq(1).within(() => { + validateSessionsInstrumentPanel(['user1']) + cy.get('.command-name-session') + .within(() => { + cy.get('.command-expander').first().click() + cy.contains('user1') + cy.contains('restored') - cy.contains('Validate session') - .closest('.command').as('validateSession') + cy.get('.command-name-Clear-page').should('have.length', 1) - cy.get('@validateSession') - .find('.command-expander') - .should('not.have.class', 'command-expander-is-open') - .click() + cy.contains('Restore saved session') - cy.get('@validateSession') - .find('.command-alias') - .contains('runValidation') - }) + cy.contains('Validate session') + .closest('.command').as('validateSession') - cy.get('.command-name-session').get('.command-expander').first().click() + cy.get('@validateSession') + .find('.command-expander') + .should('not.have.class', 'command-expander-is-open') + .click() - cy.get('.command').should('have.length', 2) + cy.get('@validateSession') + .find('.command-alias') + .contains('runValidation') }) - }) - // https://github.com/cypress-io/cypress/issues/22381 - it('ensures sessionid integrity is maintained across tests', () => { - cy.contains('test sessionid integrity is maintained').closest('.runnable').should('have.class', 'runnable-failed') - cy.get('.test').should('have.length', 6) + cy.get('.command-name-session').get('.command-expander').first().click() - cy.get('.test').eq(2).should('have.class', 'runnable-passed') - cy.get('.test').eq(3).should('have.class', 'runnable-passed') - cy.get('.test').eq(4).should('have.class', 'runnable-passed') - cy.get('.test').eq(5).should('have.class', 'runnable-failed') - cy.contains('This session already exists.').should('exist') + cy.get('.command').should('have.length', 2) }) }) diff --git a/packages/app/cypress/e2e/runs.cy.ts b/packages/app/cypress/e2e/runs.cy.ts index c03d84a13489..263a65218663 100644 --- a/packages/app/cypress/e2e/runs.cy.ts +++ b/packages/app/cypress/e2e/runs.cy.ts @@ -116,6 +116,10 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { obj.result.data.cloudViewer.organizations.nodes = [] } + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } + return obj.result }) @@ -150,8 +154,14 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.startAppServer('component') cy.remoteGraphQLIntercept(async (obj) => { - if (obj?.result?.data?.cloudViewer?.organizations?.nodes) { - obj.result.data.cloudViewer.organizations.nodes = [] + if ((obj.operationName !== 'CreateCloudOrgModal_CloudOrganizationsCheck_refreshOrganizations_cloudViewer')) { + if (obj.result.data?.cloudViewer?.organizations?.nodes) { + obj.result.data.cloudViewer.organizations.nodes = [] + } + + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } } return obj.result @@ -181,7 +191,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.startAppServer('component') cy.remoteGraphQLIntercept(async (obj, testState) => { - if (obj.operationName === 'CloudConnectModals_RefreshCloudViewer_refreshCloudViewer_cloudViewer') { + if (obj.operationName === 'LoginConnectModals_LoginConnectModalsQuery_cloudViewer') { testState.refetchCalled = true } @@ -312,7 +322,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { moveToRunsPage() cy.findByText(defaultMessages.runs.connect.buttonProject).click() - cy.get('button').contains(defaultMessages.runs.connect.modal.selectProject.createProject).click() + cy.contains('button', defaultMessages.runs.connect.modal.selectProject.createProject).click() cy.findByText(defaultMessages.runs.connectSuccessAlert.title).should('be.visible') cy.withCtx(async (ctx) => { @@ -351,7 +361,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.get('[href="#/runs"]').click() cy.findByText(defaultMessages.runs.connect.buttonProject).click() - cy.get('button').contains(defaultMessages.runs.connect.modal.selectProject.createProject).click() + cy.contains('button', defaultMessages.runs.connect.modal.selectProject.createProject).click() cy.get('[data-cy="alert"]').within(() => { cy.contains(defaultMessages.runs.connect.errors.baseError.title) @@ -386,7 +396,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.get('[href="#/runs"]').click() cy.findByText(defaultMessages.runs.connect.buttonProject).click() - cy.get('button').contains(defaultMessages.runs.connect.modal.selectProject.createProject).click() + cy.contains('button', defaultMessages.runs.connect.modal.selectProject.createProject).click() cy.get('[data-cy="alert"]').within(() => { cy.contains(defaultMessages.runs.connect.errors.internalServerError.title) @@ -417,7 +427,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { } if (obj.result.data?.cloudViewer?.organizations?.nodes) { - const projectNodes = obj.result.data?.cloudViewer.organizations.nodes[0].projects.nodes + const projectNodes = obj.result.data.cloudViewer.organizations.nodes[0].projects.nodes projectNodes.push({ __typename: 'CloudProject', @@ -440,9 +450,10 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) it('opens Connect Project modal after clicking Reconnect Project button', () => { - cy.findByText(defaultMessages.runs.errors.notFound.button).should('be.visible').click() + cy.findByText(defaultMessages.runs.errors.notFound.button).click() + cy.get('[aria-modal="true"]').should('exist') - cy.get('[data-cy="selectProject"] button').should('have.text', 'Mock Project') + cy.contains('[data-cy="selectProject"] button', 'Mock Project') cy.get('[data-cy="connect-project"]').click() cy.get('[data-cy="runs"]', { timeout: 7500 }) }) @@ -510,12 +521,13 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { it('updates the button text when the request access button is clicked', () => { cy.remoteGraphQLIntercept(async (obj, testState) => { - if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') { + if (obj.operationName?.includes('cloudProject_cloudProjectBySlug')) { const proj = obj!.result!.data!.cloudProjectBySlug proj.__typename = 'CloudProjectUnauthorized' proj.message = 'Cloud Project Unauthorized' proj.hasRequestedAccess = false + testState.project = proj } else if (obj.operationName === 'RunsErrorRenderer_RequestAccess_cloudProjectRequestAccess') { obj!.result!.data!.cloudProjectRequestAccess = { @@ -696,7 +708,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.loginUser() cy.remoteGraphQLIntercept((obj) => { - if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') { + if (obj.operationName?.includes('cloudProject_cloudProjectBySlug')) { cloudData = obj.result obj.result = {} @@ -714,7 +726,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { // cy.percySnapshot() // TODO: restore when Percy CSS is fixed. See https://github.com/cypress-io/cypress/issues/23435 cy.remoteGraphQLIntercept((obj) => { - if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') { + if (obj.operationName?.includes('cloudProject_cloudProjectBySlug')) { return cloudData } @@ -732,10 +744,6 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.startAppServer('component') }) - afterEach(() => { - cy.goOnline() - }) - it('shows alert warning if runs have been returned already', () => { cy.loginUser() cy.visitApp() @@ -773,11 +781,15 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js']) cy.startAppServer('component') - cy.remoteGraphQLIntercept(async (obj) => { + cy.remoteGraphQLIntercept((obj) => { if (obj.result.data?.cloudViewer?.organizations?.nodes) { obj.result.data.cloudViewer.organizations.nodes = [] } + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } + return obj.result }) @@ -845,7 +857,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.openProject('component-tests') cy.startAppServer('component') cy.loginUser() - cy.remoteGraphQLIntercept((obj, testState) => { + cy.remoteGraphQLIntercept((obj) => { if (obj.result.data?.cloudProjectBySlug?.runs?.nodes.length) { obj.result.data.cloudProjectBySlug.runs.nodes.map((run) => { run.status = 'RUNNING' diff --git a/packages/app/cypress/e2e/settings.cy.ts b/packages/app/cypress/e2e/settings.cy.ts index 672e7cfaba25..831a155f19b8 100644 --- a/packages/app/cypress/e2e/settings.cy.ts +++ b/packages/app/cypress/e2e/settings.cy.ts @@ -409,7 +409,7 @@ describe('App: Settings without cloud', () => { cy.visitApp() cy.get(SidebarSettingsLinkSelector).click() cy.findByText('Dashboard settings').click() - cy.findByText('Project ID').should('exist') + cy.findByText('Project ID').should('not.exist') cy.withCtx((ctx, o) => { o.sinon.spy(ctx._apis.authApi, 'logIn') }) diff --git a/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts b/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts index 172abe75932c..9e865a9b0ea5 100644 --- a/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts +++ b/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts @@ -160,7 +160,7 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW }) context('when no runs are recorded', () => { - beforeEach(() => { + it('shows placeholders for all visible specs', { defaultCommandTimeout: 6000 }, () => { cy.loginUser() cy.remoteGraphQLIntercept(async (obj) => { @@ -181,10 +181,6 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW }) cy.visitApp() - cy.findByTestId('sidebar-link-specs-page').click() - }) - - it('shows placeholders for all visible specs', () => { allVisibleSpecsShouldBePlaceholders() }) }) diff --git a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts index 41f607621d12..1d4365597b50 100644 --- a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts +++ b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts @@ -1,7 +1,7 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' import type { SinonStub } from 'sinon' -describe('App: Runs', { viewportWidth: 1200 }, () => { +describe('CreateCloudOrgModalSubscription', { viewportWidth: 1200 }, () => { beforeEach(() => { cy.scaffoldProject('component-tests') cy.openProject('component-tests') @@ -22,6 +22,10 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { obj.result.data.cloudViewer.organizations.nodes = [] } + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } + return obj.result }) diff --git a/packages/app/cypress/e2e/top-nav.cy.ts b/packages/app/cypress/e2e/top-nav.cy.ts index 330e5f0796b1..e65a10d484f7 100644 --- a/packages/app/cypress/e2e/top-nav.cy.ts +++ b/packages/app/cypress/e2e/top-nav.cy.ts @@ -453,6 +453,17 @@ describe('App Top Nav Workflows', () => { cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js']) cy.startAppServer() cy.visitApp() + cy.remoteGraphQLIntercept(async (obj) => { + if (obj.result.data?.cloudViewer) { + obj.result.data.cloudViewer.organizations = { + __typename: 'CloudOrganizationConnection', + id: 'test', + nodes: [{ __typename: 'CloudOrganization', id: '987' }], + } + } + + return obj.result + }) mockLogInActionsForUser(mockUser) logIn({ expectedNextStepText: 'Connect project', displayName: mockUser.name }) @@ -494,6 +505,24 @@ describe('App Top Nav Workflows', () => { cy.findByTestId('app-header-bar').findByTestId('user-avatar-title').should('be.visible') }) + it('if the project has no runs, shows "record your first run" prompt after clicking', () => { + cy.remoteGraphQLIntercept((obj) => { + if (obj.result?.data?.cloudProjectBySlug?.runs?.nodes?.length) { + obj.result.data.cloudProjectBySlug.runs.nodes = [] + } + + return obj.result + }) + + mockLogInActionsForUser(mockUserNoName) + + logIn({ expectedNextStepText: 'Continue', displayName: mockUserNoName.email }) + + cy.contains('[data-cy=standard-modal] h2', defaultMessages.specPage.banners.record.title).should('be.visible') + cy.contains('[data-cy=standard-modal]', defaultMessages.specPage.banners.record.content).should('be.visible') + cy.contains('button', 'Copy').should('be.visible') + }) + it('shows correct error when browser cannot launch', () => { cy.withCtx((ctx, o) => { o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue index d76b3823321d..c0e8a339e7fe 100644 --- a/packages/app/src/App.vue +++ b/packages/app/src/App.vue @@ -4,13 +4,31 @@ :is="Component" /> + + + + + + +
We detected that the Chrome process just crashed with code 'code' and signal 'signal'.
+
+We have failed the current test and have relaunched Chrome.
+
+This can happen for many different reasons:
+
+- You wrote an endless loop and you must fix your own code
+- You are running lots of tests on a memory intense application
+- You are running in a memory starved VM environment
+- There are problems with your GPU / GPU drivers
+- There are browser bugs
+
\ No newline at end of file diff --git a/packages/errors/__snapshot-html__/RENDERER_CRASHED.html b/packages/errors/__snapshot-html__/RENDERER_CRASHED.html index e3912fc901d3..1a587a4d9a9f 100644 --- a/packages/errors/__snapshot-html__/RENDERER_CRASHED.html +++ b/packages/errors/__snapshot-html__/RENDERER_CRASHED.html @@ -41,7 +41,6 @@ This can happen for a number of different reasons: - You wrote an endless loop and you must fix your own code -- There is a memory leak in Cypress (unlikely but possible) - You are running Docker (there is an easy fix for this: see link below) - You are running lots of tests on a memory intense application - You are running in a memory starved VM environment @@ -50,5 +49,5 @@ You can learn more including how to fix Docker here: -https://on.cypress.io/renderer-process-crashed +https://on.cypress.io/renderer-process-crashed \ No newline at end of file diff --git a/packages/errors/src/errors.ts b/packages/errors/src/errors.ts index 096043e12a2a..173099df05c9 100644 --- a/packages/errors/src/errors.ts +++ b/packages/errors/src/errors.ts @@ -570,7 +570,6 @@ export const AllCypressErrors = { This can happen for a number of different reasons: - You wrote an endless loop and you must fix your own code - - There is a memory leak in Cypress (unlikely but possible) - You are running Docker (there is an easy fix for this: see link below) - You are running lots of tests on a memory intense application - You are running in a memory starved VM environment @@ -581,6 +580,20 @@ export const AllCypressErrors = { https://on.cypress.io/renderer-process-crashed` }, + BROWSER_CRASHED: (browser: string, code: string | number, signal: string) => { + return errTemplate`\ + We detected that the ${fmt.highlight(browser)} process just crashed with code '${fmt.highlight(code)}' and signal '${fmt.highlight(signal)}'. + + We have failed the current test and have relaunched ${fmt.highlight(browser)}. + + This can happen for many different reasons: + + - You wrote an endless loop and you must fix your own code + - You are running lots of tests on a memory intense application + - You are running in a memory starved VM environment + - There are problems with your GPU / GPU drivers + - There are browser bugs` + }, AUTOMATION_SERVER_DISCONNECTED: () => { return errTemplate`The automation client disconnected. Cannot continue running tests.` }, diff --git a/packages/errors/test/unit/visualSnapshotErrors_spec.ts b/packages/errors/test/unit/visualSnapshotErrors_spec.ts index bf5e141f06e1..42bb52ddd61f 100644 --- a/packages/errors/test/unit/visualSnapshotErrors_spec.ts +++ b/packages/errors/test/unit/visualSnapshotErrors_spec.ts @@ -646,6 +646,11 @@ describe('visual error templates', () => { default: [], } }, + BROWSER_CRASHED: () => { + return { + default: ['Chrome', 'code', 'signal'], + } + }, AUTOMATION_SERVER_DISCONNECTED: () => { return { default: [], diff --git a/packages/frontend-shared/src/composables/examples/UseCohortsExample.vue b/packages/frontend-shared/src/composables/examples/UseCohortsExample.vue index 690962b93649..2b7c5fe939b3 100644 --- a/packages/frontend-shared/src/composables/examples/UseCohortsExample.vue +++ b/packages/frontend-shared/src/composables/examples/UseCohortsExample.vue @@ -12,7 +12,7 @@ export type CopyOption = { diff --git a/packages/frontend-shared/src/gql-components/CloudViewerAndProject.vue b/packages/frontend-shared/src/gql-components/CloudViewerAndProject.vue new file mode 100644 index 000000000000..4255bb8a8238 --- /dev/null +++ b/packages/frontend-shared/src/gql-components/CloudViewerAndProject.vue @@ -0,0 +1,140 @@ + + + diff --git a/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx b/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx index f862e47fb101..0a3fd5ca7b8f 100644 --- a/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx +++ b/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx @@ -1,6 +1,8 @@ import { HeaderBar_HeaderBarContentFragmentDoc } from '../generated/graphql-test' import HeaderBarContent from './HeaderBarContent.vue' import { defaultMessages } from '@cy/i18n' +import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes' +import { useLoginConnectStore } from '../store/login-connect-store' const text = defaultMessages.topNav @@ -292,31 +294,25 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, ( cy.contains(`${defaultMessages.topNav.updateCypress.title} 8.7.0`).should('not.exist') }) - it('the login modal reaches "opening browser" status', () => { - mountFragmentWithData() - - cy.findByRole('button', { name: text.login.actionLogin }) - .click() - - cy.contains('h2', text.login.titleInitial).should('be.visible') - cy.percySnapshot() - - cy.findByRole('button', { name: text.login.actionLogin }) - .should('be.visible') - .and('have.focus') - - cy.findByRole('button', { name: defaultMessages.actions.close }).click() + it('the logged in state is correctly presented in header', () => { + const loginConnectStore = useLoginConnectStore() - cy.contains('h2', text.login.titleInitial).should('not.exist') - }) + loginConnectStore.setUserFlag('isLoggedIn', true) - it('the logged in state is correctly presented in header', () => { const cloudViewer = { + ...CloudUserStubs.me, + organizations: null, + firstOrganization: { + __typename: 'CloudOrganizationConnection' as const, + nodes: [], + }, id: '1', email: 'test@test.test', fullName: 'Tester Test', } + loginConnectStore.setUserData(cloudViewer) + cy.mountFragment(HeaderBar_HeaderBarContentFragmentDoc, { onResult: (result) => { result.__typename = 'Query' @@ -376,6 +372,12 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, ( cy.clock(1609891200000) }) + afterEach(() => { + // Setting the clock in the beforeEach was causing the cy.checkA11y call in cy.percySnapshot to timeout only in open mode. Resetting + // the clock here prevents that timeout + cy.clock().invoke('restore') + }) + function mountWithSavedState (options?: {state?: object, projectId?: string }) { const mountResult = cy.mountFragment(HeaderBar_HeaderBarContentFragmentDoc, { onResult: (result) => { diff --git a/packages/frontend-shared/src/gql-components/HeaderBarContent.vue b/packages/frontend-shared/src/gql-components/HeaderBarContent.vue index 3c44d6d39c7f..fee774a9cda2 100644 --- a/packages/frontend-shared/src/gql-components/HeaderBarContent.vue +++ b/packages/frontend-shared/src/gql-components/HeaderBarContent.vue @@ -94,18 +94,18 @@ @clear-force-open="isForceOpenAllowed = false" > -
+
- @@ -173,7 +166,6 @@ import { HeaderBarContent_AuthChangeDocument, } from '../generated/graphql' import TopNav from './topnav/TopNav.vue' -import LoginModal from './topnav/LoginModal.vue' import UserAvatar from './topnav/UserAvatar.vue' import Auth from './Auth.vue' import { useI18n } from '@cy/i18n' @@ -182,6 +174,9 @@ import interval from 'human-interval' import { sortBy } from 'lodash' import Tooltip from '../components/Tooltip.vue' import type { AllowedState } from '@packages/types' +import { useLoginConnectStore } from '../store/login-connect-store' + +const loginConnectStore = useLoginConnectStore() gql` fragment HeaderBarContent_Auth on Query { @@ -243,10 +238,6 @@ fragment HeaderBar_HeaderBarContent on Query { } ` -const userData = computed(() => { - return props.gql.cloudViewer ?? props.gql.cachedUser -}) - const savedState = computed(() => { return props.gql?.currentProject?.savedState as AllowedState }) @@ -257,13 +248,8 @@ const cloudProjectId = computed(() => { return props.gql?.currentProject?.config?.find((item: { field: string }) => item.field === 'projectId')?.value }) -const isLoginOpen = ref(false) const clearCurrentProjectMutation = useMutation(GlobalPageHeader_ClearCurrentProjectDocument) -const openLogin = () => { - isLoginOpen.value = true -} - const clearCurrentProject = () => { if (currentProject.value) { clearCurrentProjectMutation.executeMutation({}) @@ -277,16 +263,6 @@ const props = defineProps<{ allowAutomaticPromptOpen?: boolean }>() -const emit = defineEmits<{ - (event: 'connect-project'): void -}>() - -const isApp = window.__Cypress__ - -const handleConnectProject = () => { - emit('connect-project') -} - const { t } = useI18n() const prompts = sortBy([ { diff --git a/packages/frontend-shared/src/gql-components/LoginConnectModals.vue b/packages/frontend-shared/src/gql-components/LoginConnectModals.vue new file mode 100644 index 000000000000..9fd893d3729c --- /dev/null +++ b/packages/frontend-shared/src/gql-components/LoginConnectModals.vue @@ -0,0 +1,51 @@ + + + + diff --git a/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx new file mode 100644 index 000000000000..ba64ce522afd --- /dev/null +++ b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx @@ -0,0 +1,102 @@ +import { LoginConnectModalsContentFragmentDoc } from '../generated/graphql-test' +import LoginConnectModalsContent from './LoginConnectModalsContent.vue' +import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes' +import { SelectCloudProjectModal_CreateCloudProjectDocument } from '../generated/graphql' + +import { useLoginConnectStore } from '../store/login-connect-store' + +describe('', () => { + context('when user is logged out', () => { + it('shows login modal', () => { + const { openLoginConnectModal } = useLoginConnectStore() + + cy.mountFragment(LoginConnectModalsContentFragmentDoc, { + onResult: (result) => { + result.cloudViewer = null + }, + render: (gqlVal) => { + return + }, + }) + + cy.contains('Log in to Cypress') + .should('not.exist') + .then(() => { + openLoginConnectModal({ utmMedium: 'testing' }) + + cy.contains('Log in to Cypress').should('be.visible') + }) + }) + }) + + context('when user is logged in', () => { + it('shows "Create Project" state if project is not set up', () => { + const { openLoginConnectModal, setUserFlag, setProjectFlag } = useLoginConnectStore() + + setUserFlag('isLoggedIn', true) + setUserFlag('isMemberOfOrganization', true) + setUserFlag('isOrganizationLoaded', true) + setProjectFlag('isConfigLoaded', true) + setProjectFlag('isProjectConnected', false) + + cy.mountFragment(LoginConnectModalsContentFragmentDoc, { + onResult: (result) => { + result.cloudViewer = { ...CloudUserStubs.me, + firstOrganization: { + __typename: 'CloudOrganizationConnection', + nodes: [{ __typename: 'CloudOrganization', id: '122' }], + }, + organizations: { + __typename: 'CloudOrganizationConnection', + nodes: [{ + __typename: 'CloudOrganization', + name: `Cypress Test Account`, + id: '122', + projects: { + nodes: [], + }, + }], + }, + } + + result.currentProject = null + }, + render: (gqlVal) => { + return + }, + }) + + const createProjectStub = cy.stub().as('createProjectStub') + + cy.stubMutationResolver(SelectCloudProjectModal_CreateCloudProjectDocument, (defineResult, variables) => { + createProjectStub(variables) + + return defineResult({} as any) + }) + + cy.contains('Create project') + .should('not.exist') + .then(() => { + openLoginConnectModal({ utmMedium: 'testing' }) + }) + + cy.findAllByLabelText('Project name*(You can change this later)').type('test-project') + + cy.contains('button', 'Create project') + .click() + .then(() => { + expect(createProjectStub.lastCall.args[0]).to.deep.eq({ + name: 'test-project', + orgId: '122', + medium: 'testing', + source: 'Binary: Launchpad', + public: false, + campaign: 'Create project', + cohort: '', + }) + }) + + cy.get('@createProjectStub').should('have.been.calledOnce') + }) + }) +}) diff --git a/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue new file mode 100644 index 000000000000..f8bca76719ec --- /dev/null +++ b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue @@ -0,0 +1,92 @@ + + diff --git a/packages/frontend-shared/src/gql-components/RecordRunModal.cy.tsx b/packages/frontend-shared/src/gql-components/RecordRunModal.cy.tsx index d9f3fa7eff6e..b00c95b29a96 100644 --- a/packages/frontend-shared/src/gql-components/RecordRunModal.cy.tsx +++ b/packages/frontend-shared/src/gql-components/RecordRunModal.cy.tsx @@ -1,20 +1,25 @@ import RecordRunModalVue from './RecordRunModal.vue' import { defaultMessages } from '@cy/i18n' +import { UsePromptManager_SetPreferencesDocument } from '../generated/graphql-test' describe('RecordRunModal', () => { - it('is not open by default', () => { - cy.mount() + it('renders and records that it has been shown', () => { + const now = Date.now() - cy.findByTestId('record-run-modal').should('not.exist') - }) + cy.clock(now) + const setPreferencesStub = cy.stub() + + cy.stubMutationResolver(UsePromptManager_SetPreferencesDocument, (defineResult, { value }) => { + setPreferencesStub(JSON.parse(value)) + }) - it('renders open', () => { - cy.mount() + cy.mount() cy.contains(defaultMessages.specPage.banners.record.title).should('be.visible') cy.findByTestId('copy-button').should('be.visible') cy.findByDisplayValue('npx cypress run --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa').should('be.visible') + cy.wrap(setPreferencesStub).should('have.been.calledWithMatch', { promptsShown: { loginModalRecord: now } }) cy.percySnapshot() }) @@ -22,7 +27,7 @@ describe('RecordRunModal', () => { it('calls close when X is clicked', () => { const closeStub = cy.stub() - cy.mount() + cy.mount() cy.findByRole('button', { name: defaultMessages.actions.close }).click().then(() => { expect(closeStub).to.have.been.called @@ -30,13 +35,13 @@ describe('RecordRunModal', () => { }) it('sends UTM parameters with help link', () => { - cy.mount() + cy.mount() cy.contains(defaultMessages.links.needHelp).should('have.attr', 'href', 'https://on.cypress.io/cypress-run-record-key?utm_medium=Nav&utm_source=Binary%3A+Launchpad&utm_content=content') }) it('sends UTM parameters with help link without UTM content prop', () => { - cy.mount() + cy.mount() cy.contains(defaultMessages.links.needHelp).should('have.attr', 'href', 'https://on.cypress.io/cypress-run-record-key?utm_medium=Nav&utm_source=Binary%3A+Launchpad&utm_content=') }) diff --git a/packages/frontend-shared/src/gql-components/RecordRunModal.vue b/packages/frontend-shared/src/gql-components/RecordRunModal.vue index 4b4a6bcb132f..e68789a8c607 100644 --- a/packages/frontend-shared/src/gql-components/RecordRunModal.vue +++ b/packages/frontend-shared/src/gql-components/RecordRunModal.vue @@ -7,7 +7,7 @@ :help-link="helpLink" :no-help="!helpLink" data-cy="record-run-modal" - @update:model-value="close" + @update:model-value="emit('cancel')" >

@@ -24,16 +24,28 @@ import StandardModal from '../components/StandardModal.vue' import RecordPromptAdapter from './RecordPromptAdapter.vue' import { getUtmSource } from '../utils/getUtmSource' import { getUrlWithParams } from '../utils/getUrlWithParams' +import { usePromptManager } from './composables/usePromptManager' +import { onMounted, ref } from 'vue' + +const { setPromptShown } = usePromptManager() + +onMounted(() => { + setPromptShown('loginModalRecord') +}) const { t } = useI18n() +const isModalOpen = ref(true) + const props = defineProps<{ - isModalOpen: boolean - close: () => void utmMedium: string utmContent?: string }>() +const emit = defineEmits<{ + (eventName: 'cancel'): void +}>() + const helpLink = getUrlWithParams({ url: 'https://on.cypress.io/cypress-run-record-key', params: { diff --git a/packages/frontend-shared/src/composables/useCohorts.ts b/packages/frontend-shared/src/gql-components/composables/useCohorts.ts similarity index 97% rename from packages/frontend-shared/src/composables/useCohorts.ts rename to packages/frontend-shared/src/gql-components/composables/useCohorts.ts index 9e417746fe81..c4dc113f1def 100644 --- a/packages/frontend-shared/src/composables/useCohorts.ts +++ b/packages/frontend-shared/src/gql-components/composables/useCohorts.ts @@ -1,5 +1,5 @@ import { useMutation, gql } from '@urql/vue' -import { UseCohorts_DetermineCohortDocument } from '../generated/graphql' +import { UseCohorts_DetermineCohortDocument } from '../../generated/graphql' import { ref } from 'vue' gql` diff --git a/packages/frontend-shared/src/gql-components/composables/usePromptManager.ts b/packages/frontend-shared/src/gql-components/composables/usePromptManager.ts new file mode 100644 index 000000000000..52a5168c75b4 --- /dev/null +++ b/packages/frontend-shared/src/gql-components/composables/usePromptManager.ts @@ -0,0 +1,42 @@ +import { gql, useMutation } from '@urql/vue' +import { UsePromptManager_SetPreferencesDocument } from '../../generated/graphql' +import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store' +import { isAllowedFeature } from '../../utils/isAllowedFeature' + +gql` +mutation UsePromptManager_SetPreferences($value: String!) { + setPreferences(type: project, value: $value) { + currentProject { + id + savedState + } + } +} +` + +gql` +query UsePromptManager_RefreshProject { + currentProject { + id + savedState + } +} +` + +export function usePromptManager () { + const setPreferencesMutation = useMutation(UsePromptManager_SetPreferencesDocument) + const loginConnectStore = useLoginConnectStore() + + function setPromptShown (slug) { + setPreferencesMutation.executeMutation({ value: JSON.stringify({ promptsShown: { [slug]: Date.now() } }) }) + } + + const wrappedIsAllowedFeature = (featureName: 'specsListBanner' | 'docsCiPrompt') => { + return isAllowedFeature(featureName, loginConnectStore) + } + + return { + setPromptShown, + isAllowedFeature: wrappedIsAllowedFeature, + } +} diff --git a/packages/app/src/runs/modals/CloudConnectModals.spec.tsx b/packages/frontend-shared/src/gql-components/modals/CloudConnectModals.spec.tsx similarity index 95% rename from packages/app/src/runs/modals/CloudConnectModals.spec.tsx rename to packages/frontend-shared/src/gql-components/modals/CloudConnectModals.spec.tsx index 4536838f238d..7e752b3dcce4 100644 --- a/packages/app/src/runs/modals/CloudConnectModals.spec.tsx +++ b/packages/frontend-shared/src/gql-components/modals/CloudConnectModals.spec.tsx @@ -25,6 +25,10 @@ describe('', () => { result.cloudViewer = { ...CloudUserStubs.me, organizations: hasOrg ? cloneDeep(CloudOrganizationConnectionStubs) : null, + firstOrganization: { + __typename: 'CloudOrganizationConnection', + nodes: [], + }, } if (!hasProjects) { diff --git a/packages/app/src/runs/modals/CloudConnectModals.vue b/packages/frontend-shared/src/gql-components/modals/CloudConnectModals.vue similarity index 72% rename from packages/app/src/runs/modals/CloudConnectModals.vue rename to packages/frontend-shared/src/gql-components/modals/CloudConnectModals.vue index 234159a9f851..ba8ff834a09a 100644 --- a/packages/app/src/runs/modals/CloudConnectModals.vue +++ b/packages/frontend-shared/src/gql-components/modals/CloudConnectModals.vue @@ -25,13 +25,13 @@ diff --git a/packages/frontend-shared/src/gql-components/topnav/LoginModal.cy.tsx b/packages/frontend-shared/src/gql-components/modals/LoginModal.cy.tsx similarity index 74% rename from packages/frontend-shared/src/gql-components/topnav/LoginModal.cy.tsx rename to packages/frontend-shared/src/gql-components/modals/LoginModal.cy.tsx index 2c2f4ca34fa6..a62e61e0d2d6 100644 --- a/packages/frontend-shared/src/gql-components/topnav/LoginModal.cy.tsx +++ b/packages/frontend-shared/src/gql-components/modals/LoginModal.cy.tsx @@ -3,6 +3,8 @@ import LoginModal from './LoginModal.vue' import { defaultMessages } from '@cy/i18n' import Tooltip from '../../components/Tooltip.vue' import { ref } from 'vue' +import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes' +import { useLoginConnectStore } from '../../store' const text = defaultMessages.topNav @@ -26,18 +28,30 @@ type TestCloudViewer = { } const mountSuccess = (viewer: TestCloudViewer = cloudViewer) => { + const finalViewer = { + ...CloudUserStubs.me, + organizations: null, + firstOrganization: { + __typename: 'CloudOrganizationConnection' as const, + nodes: [], + }, + ...viewer, + } + + const { setUserFlag } = useLoginConnectStore() + + setUserFlag('isLoggedIn', true) cy.mountFragment(LoginModalFragmentDoc, { onResult: (result) => { result.__typename = 'Query' result.authState.browserOpened = true - result.cloudViewer = viewer + result.cloudViewer = finalViewer result.cloudViewer.__typename = 'CloudUser' }, render: (gqlVal) => (

), @@ -48,7 +62,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { describe('progress communication', () => { it('renders and reaches "opening browser" status', () => { cy.mountFragment(LoginModalFragmentDoc, { - render: (gqlVal) =>
, + render: (gqlVal) =>
, }) cy.contains('h2', text.login.titleInitial).should('be.visible') @@ -68,7 +82,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { render: (gqlVal) => { gqlVal.authState.browserOpened = true - return
+ return
}, }) @@ -85,48 +99,6 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { cy.contains(text.login.bodySuccess.replace('{0}', cloudViewer.fullName)).should('be.visible') cy.contains('a', cloudViewer.fullName).should('have.attr', 'href', 'https://on.cypress.io/dashboard/profile') }) - - it('shows "connect project" after login if required by prop, and emits expected events', () => { - const connectProjectLabel = defaultMessages.runs.connect.modal.selectProject.connectProject - const connectProjectSpy = cy.spy().as('connectProjectSpy') - const loggedInSpy = cy.spy().as('loggedInSpy') - const updateModelSpy = cy.spy().as('updateModelSpy') - - const props = { - 'onUpdate:modelValue': (value: boolean) => { - updateModelSpy(value) - }, - 'onConnect-project': () => connectProjectSpy(), - } - - // mount with extra event spies - cy.mountFragment(LoginModalFragmentDoc, { - onResult: (result) => { - result.__typename = 'Query' - result.authState.browserOpened = true - result.cloudViewer = cloudViewer - result.cloudViewer.__typename = 'CloudUser' - }, - render: (gqlVal) => ( -
- -
), - }) - - cy.contains('button', connectProjectLabel) - .click() - - cy.get('@connectProjectSpy').should('have.been.calledOnce') - cy.get('@loggedInSpy').should('have.been.calledOnce') - cy.get('@updateModelSpy').should('have.been.calledOnceWith', false) - }) }) describe('errors', () => { @@ -141,7 +113,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { }, render: (gqlVal) => (
- +
), }) @@ -155,12 +127,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { }) it('shows non-browser errors from login process', () => { - const updateSpy = cy.spy().as('updateSpy') - const methods = { - 'onUpdate:modelValue': (newValue) => { - updateSpy(newValue) - }, - } + const cancelSpy = cy.spy().as('cancelSpy') const errorText = 'The flux capacitor ran out of battery' cy.mountFragment(LoginModalFragmentDoc, { @@ -169,7 +136,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { gqlVal.authState.message = errorText return (
- +
) }, }) @@ -183,7 +150,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { // but we can test that cancelling closes the modal here: cy.contains('button', text.login.actionCancel).click() - cy.get('@updateSpy').should('have.been.calledWith', false) + cy.get('@cancelSpy').should('have.been.called') }) }) @@ -197,8 +164,8 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { it('emits an event to close the modal when "Continue" button is clicked', () => { mountSuccess() cy.findByRole('button', { name: text.login.actionContinue }).click().then(() => { - cy.wrap(Cypress.vueWrapper.findComponent(LoginModal).emitted('update:modelValue')?.[0]) - .should('deep.equal', [false]) + cy.wrap(Cypress.vueWrapper.findComponent(LoginModal).emitted('close')) + .should('have.length', 1) }) }) @@ -209,7 +176,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { it('renders correct components if there is no internet connection', () => { cy.mountFragment(LoginModalFragmentDoc, { - render: (gqlVal) =>
, + render: (gqlVal) =>
, }) cy.goOffline() @@ -222,7 +189,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { it('shows login action when the internet is back', () => { cy.mountFragment(LoginModalFragmentDoc, { - render: (gqlVal) =>
, + render: (gqlVal) =>
, }) cy.goOffline() @@ -260,7 +227,7 @@ describe('', { viewportWidth: 1000, viewportHeight: 750 }, () => { return (
- + {isOpen.value && }
) }, diff --git a/packages/frontend-shared/src/gql-components/topnav/LoginModal.vue b/packages/frontend-shared/src/gql-components/modals/LoginModal.vue similarity index 79% rename from packages/frontend-shared/src/gql-components/topnav/LoginModal.vue rename to packages/frontend-shared/src/gql-components/modals/LoginModal.vue index 1a9b876d55a0..1527cfdd991e 100644 --- a/packages/frontend-shared/src/gql-components/topnav/LoginModal.vue +++ b/packages/frontend-shared/src/gql-components/modals/LoginModal.vue @@ -1,8 +1,8 @@ diff --git a/packages/frontend-shared/src/gql-components/topnav/TopNav.vue b/packages/frontend-shared/src/gql-components/topnav/TopNav.vue index f1e59710428d..7acf9f208606 100644 --- a/packages/frontend-shared/src/gql-components/topnav/TopNav.vue +++ b/packages/frontend-shared/src/gql-components/topnav/TopNav.vue @@ -144,7 +144,7 @@
this.userStatus as unknown as UserStatus === status }, - projectStatus () { - // TODO: in #23762 look at projectConnectionStatus in SpecHeaderCloudDataTooltip - }, latestBannerShownTime (state) { return state._latestBannerShownTimeForTesting - // TODO: in #23762 return based on bannersState + // TODO: in #23768 return based on bannersState - this will be used to delay the nav CI prompt if a banner was recently shown }, }, diff --git a/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts b/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts index 95902cd37720..08bbf631eeb7 100644 --- a/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts +++ b/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts @@ -39,6 +39,9 @@ describe('isAllowedFeature', () => { case 'needsProjectConnect': setUserFlag('isLoggedIn', true) setUserFlag('isMemberOfOrganization', true) + setUserFlag('isOrganizationLoaded', true) + setProjectFlag('isConfigLoaded', true) + setProjectFlag('isProjectConnected', false) expect(store.userStatus).to.eq('needsProjectConnect') break case 'needsRecordedRun': @@ -46,6 +49,8 @@ describe('isAllowedFeature', () => { setUserFlag('isMemberOfOrganization', true) setProjectFlag('isProjectConnected', true) setProjectFlag('hasNoRecordedRuns', true) + setProjectFlag('isConfigLoaded', true) + setProjectFlag('hasNonExampleSpec', true) expect(store.userStatus).to.eq('needsRecordedRun') break diff --git a/packages/graphql/schemas/cloud.graphql b/packages/graphql/schemas/cloud.graphql index 87341c15c882..2ade51763c55 100644 --- a/packages/graphql/schemas/cloud.graphql +++ b/packages/graphql/schemas/cloud.graphql @@ -291,7 +291,7 @@ union CloudProjectSpecFlakyResult = type CloudProjectSpecFlakyStatus { """ - URL linking to the flaky data in the Cypress Dashboard for this spec + URL linking to the flaky data in the Cypress dashboard for this spec """ dashboardUrl: String diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index 8db6ffdd5f53..71dd3c3ce51c 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -264,7 +264,7 @@ type CloudProjectSpec implements Node { union CloudProjectSpecFlakyResult = CloudFeatureNotEnabled | CloudProjectSpecFlakyStatus type CloudProjectSpecFlakyStatus { - """URL linking to the flaky data in the Cypress Dashboard for this spec""" + """URL linking to the flaky data in the Cypress dashboard for this spec""" dashboardUrl: String """Number of flaky runs from the considered runs""" @@ -629,6 +629,11 @@ type CurrentProject implements Node & ProjectLike { """File extension to use based on if the project has typescript or not""" fileExtensionToUse: FileExtensionEnum + """ + Whether the project has any specs found that do not match an example spec + """ + hasNonExampleSpec: Boolean + """Whether the project has a valid config file""" hasValidConfigFile: Boolean @@ -720,6 +725,7 @@ enum ErrorTypeEnum { AUTOMATION_SERVER_DISCONNECTED BAD_POLICY_WARNING BAD_POLICY_WARNING_TOOLTIP + BROWSER_CRASHED BROWSER_NOT_FOUND_BY_NAME BROWSER_NOT_FOUND_BY_PATH BROWSER_UNSUPPORTED_LAUNCH_OPTION diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-CurrentProject.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-CurrentProject.ts index 1a9a862f360d..940717059bb9 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-CurrentProject.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-CurrentProject.ts @@ -38,6 +38,11 @@ export const CurrentProject = objectType({ description: 'Whether or not the full config is ready', }) + t.boolean('hasNonExampleSpec', { + description: 'Whether the project has any specs found that do not match an example spec', + resolve: (_, args, ctx) => ctx.project.hasNonExampleSpec, + }) + t.field('currentTestingType', { description: 'The mode the interactive runner was launched in', type: TestingTypeEnum, diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 3fd592d3ab9e..8917dd448059 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -192,6 +192,11 @@ export const mutation = mutationType({ }, }) + // TODO: remove server-side setPromptShown helpers in #23768, + // since this will be handled by usePromptManager via existing + // `setPreferences` mutation, there is no need for this other + //way to modify saved sate + t.field('setPromptShown', { type: 'Boolean', description: 'Save the prompt-shown state for this project', diff --git a/packages/launchpad/cypress/e2e/top-nav-launchpad.cy.ts b/packages/launchpad/cypress/e2e/top-nav-launchpad.cy.ts new file mode 100644 index 000000000000..c3195cb0af98 --- /dev/null +++ b/packages/launchpad/cypress/e2e/top-nav-launchpad.cy.ts @@ -0,0 +1,714 @@ +import type { SinonStub } from 'sinon' +import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' +import type Sinon from 'sinon' + +const pkg = require('@packages/root') + +const loginText = defaultMessages.topNav.login + +const headerBarId = 'header-bar-content' + +beforeEach(() => { + cy.clock(Date.UTC(2021, 9, 30), ['Date']) +}) + +describe('Launchpad Top Nav Workflows', () => { + context('Page Name', () => { + it('shows the current page name in the top nav', () => { + cy.scaffoldProject('launchpad') + cy.findBrowsers() + cy.openProject('launchpad') + cy.visitLaunchpad() + + cy.findByTestId(headerBarId).should('be.visible').and('contain', 'launchpad') + }) + + it('shows breadcrumbs in global mode', () => { + cy.scaffoldProject('launchpad') + cy.openGlobalMode() + cy.addProject('launchpad') + cy.visitLaunchpad() + + cy.findByTestId(headerBarId).should('be.visible').and('contain', 'Projects') + + cy.get('[data-cy="project-card"]').click() + + cy.findByTestId(headerBarId).should('be.visible').and('contain', 'Projects').and('contain', 'launchpad') + }) + }) + + context('Cypress Version', () => { + context('user version current', () => { + it('renders link to external docs if version is current', () => { + cy.scaffoldProject('launchpad') + cy.findBrowsers() + cy.withCtx(async (ctx, o) => { + o.sinon.stub(ctx.versions, 'versionData').resolves({ + current: { + id: '1', + version: '10.0.0', + released: '2021-10-15T21:38:59.983Z', + }, + latest: { + id: '1', + version: '10.0.0', + released: '2021-10-25T21:38:59.983Z', + }, + }) + }) + + cy.openProject('launchpad') + cy.visitLaunchpad() + + cy.findByTestId(headerBarId).validateExternalLink({ + name: 'v10.0.0', + href: 'https://on.cypress.io/changelog#10-0-0', + }) + }) + }) + + context('user version outdated', () => { + beforeEach(() => { + cy.findBrowsers() + cy.withCtx(async (ctx, o) => { + const currRelease = new Date(Date.UTC(2021, 9, 30)) + const prevRelease = new Date(Date.UTC(2021, 9, 29)) + + o.sinon.stub(ctx.versions, 'versionData').resolves({ + current: { + id: '1', + version: '10.0.0', + released: prevRelease.toISOString(), + }, + latest: { + id: '2', + version: '10.1.0', + released: currRelease.toISOString(), + }, + }) + }) + + cy.scaffoldProject('launchpad') + cy.openProject('launchpad') + cy.visitLaunchpad() + }) + + it('shows dropdown with version info if user version is outdated', () => { + cy.findByTestId('top-nav-version-list').contains('v10.0.0 • Upgrade').click() + + cy.findByTestId('update-hint').within(() => { + cy.validateExternalLink({ name: '10.1.0', href: 'https://on.cypress.io/changelog#10-1-0' }) + cy.findByText('Latest').should('be.visible') + }) + + cy.findByTestId('cypress-update-popover').findByRole('button', { name: 'Update to 10.1.0' }) + + cy.findByTestId('current-hint').within(() => { + cy.validateExternalLink({ name: '10.0.0', href: 'https://on.cypress.io/changelog#10-0-0' }) + cy.findByText('Installed').should('be.visible') + }) + + cy.findByTestId('cypress-update-popover').validateExternalLink({ + name: 'See all releases', + href: 'https://on.cypress.io/changelog', + }) + }) + + it('hides dropdown when version in header is clicked', () => { + cy.findByTestId('cypress-update-popover').findByRole('button', { expanded: false }).as('topNavVersionButton').click() + + cy.get('@topNavVersionButton').should('have.attr', 'aria-expanded', 'true') + + cy.get('@topNavVersionButton').click() + + cy.get('@topNavVersionButton').should('have.attr', 'aria-expanded', 'false') + }) + + it('shows upgrade modal when update button is pressed', () => { + cy.findByTestId('top-nav-version-list').contains('v10.0.0 • Upgrade').click() + + cy.findByTestId('cypress-update-popover').findByRole('button', { name: 'Update to 10.1.0' }).click() + + cy.findByRole('dialog', { name: 'Upgrade to Cypress 10.1.0' }).as('upgradeModal').within(() => { + cy.contains('You are currently running Version 10.0.0 of Cypress').should('be.visible') + cy.findAllByDisplayValue('npm install -D cypress@10.1.0').should('be.visible') + cy.findByRole('button', { name: 'Close' }).click() + }) + + cy.findAllByRole('dialog').should('not.exist') + }) + }) + + context('version data unreachable', () => { + it('treats unreachable data as current version', () => { + cy.withCtx((ctx, o) => { + (ctx.util.fetch as Sinon.SinonStub).restore() + const oldFetch = ctx.util.fetch + + o.sinon.stub(ctx.util, 'fetch').callsFake(async (url: RequestInfo | URL, init?: RequestInit) => { + await new Promise((resolve) => setTimeout(resolve, 500)) + if (['https://download.cypress.io/desktop.json', 'https://registry.npmjs.org/cypress'].includes(String(url))) { + throw new Error(String(url)) + } + + return oldFetch(url, init) + }) + }) + + cy.scaffoldProject('launchpad') + cy.findBrowsers() + cy.openProject('launchpad') + cy.visitLaunchpad() + + cy.findByTestId(headerBarId).validateExternalLink({ + name: `v${pkg.version}`, + href: `https://on.cypress.io/changelog#${pkg.version.replaceAll('.', '-')}`, + }) + }) + }) + }) + + describe('Docs', () => { + context('user initiated', () => { + beforeEach(() => { + cy.scaffoldProject('launchpad') + cy.findBrowsers() + cy.openProject('launchpad') + cy.visitLaunchpad() + + cy.findByTestId(headerBarId).findByRole('button', { name: 'Docs', expanded: false }).as('docsButton') + }) + + it('shows popover with additional doc links', () => { + cy.get('@docsButton').click().should('have.attr', 'aria-expanded', 'true') + + cy.findByRole('heading', { name: 'Getting started', level: 2 }) + cy.findByRole('heading', { name: 'References', level: 2 }) + cy.findByRole('heading', { name: 'Run in CI/CD', level: 2 }) + + const expectedLinks = [ + { + name: 'Write your first test', + href: 'https://on.cypress.io/writing-first-test?utm_medium=Docs+Menu&utm_content=First+Test&utm_source=Binary%3A+Launchpad', + }, + { + name: 'Testing your app', + href: 'https://on.cypress.io/testing-your-app?utm_medium=Docs+Menu&utm_content=Testing+Your+App&utm_source=Binary%3A+Launchpad', + }, + { + name: 'Organizing tests', + href: 'https://on.cypress.io/writing-and-organizing-tests?utm_medium=Docs+Menu&utm_content=Organizing+Tests&utm_source=Binary%3A+Launchpad', + }, + { + name: 'Best practices', + href: 'https://on.cypress.io/best-practices?utm_medium=Docs+Menu&utm_content=Best+Practices&utm_source=Binary%3A+Launchpad', + }, + { + name: 'Configuration', + href: 'https://on.cypress.io/configuration?utm_medium=Docs+Menu&utm_content=Configuration&utm_source=Binary%3A+Launchpad', + }, + { + name: 'API', + href: 'https://on.cypress.io/api?utm_medium=Docs+Menu&utm_content=API&utm_source=Binary%3A+Launchpad', + }, + ] + + expectedLinks.forEach((link) => { + cy.validateExternalLink(link) + }) + }) + + it('growth prompts appear and call SetPromptShown mutation with the correct payload', () => { + cy.get('@docsButton').click() + + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx.actions.project, 'setPromptShown') + }) + + cy.findByRole('button', { name: 'Set up CI' }).click() + cy.findByText('Configure CI').should('be.visible') + cy.findByRole('button', { name: 'Close' }).click() + + cy.withCtx((ctx) => { + expect(ctx.actions.project.setPromptShown).to.have.been.calledWith('ci1') + }) + + cy.findByRole('button', { name: 'Run tests faster' }).click() + cy.findByText('Run tests faster in CI').should('be.visible') + cy.findByRole('button', { name: 'Close' }).click() + + cy.withCtx((ctx) => { + expect(ctx.actions.project.setPromptShown).to.have.been.calledWith('orchestration1') + }) + }) + }) + + context('time based operations', () => { + beforeEach(() => { + cy.clock(1609891200000) + cy.scaffoldProject('launchpad') + cy.openProject('launchpad') + }) + + it('growth prompts do not auto-open 4 days after first project opened', () => { + cy.withCtx( + (ctx, o) => { + o.sinon.stub(ctx._apis.projectApi, 'getCurrentProjectSavedState').resolves({ + firstOpened: 1609459200000, + lastOpened: 1609459200000, + promptsShown: {}, + banners: { _disabled: true }, + }) + }, + ) + + cy.visitLaunchpad() + cy.contains('launchpad') + cy.wait(1000) + cy.contains('Configure CI').should('not.exist') + }) + }) + }) + + describe('Login', () => { + context('user logged in at launch', () => { + beforeEach(() => { + cy.findBrowsers() + cy.openProject('launchpad') + cy.loginUser() + cy.visitLaunchpad() + + cy.findByTestId(headerBarId).findByRole('button', { name: 'Profile and logout', expanded: false }).as('logInButton') + }) + + it('shows user in top nav when logged in', () => { + cy.get('@logInButton').click() + + cy.findByTestId('login-panel').contains('Test User').should('be.visible') + cy.findByTestId('login-panel').contains('test@example.com').should('be.visible') + + cy.validateExternalLink({ + name: 'Profile Settings', + href: 'https://on.cypress.io/dashboard/profile', + }) + + cy.findByTestId('user-avatar-panel').should('be.visible') + }) + + it('replaces user avatar after logout', () => { + cy.get('@logInButton').click() + + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logOut').callsFake(async () => { + // resolves + }) + }) + + cy.findByRole('button', { name: 'Log out' }).click() + + cy.findByTestId(headerBarId).findByText('Log in').should('be.visible') + }) + }) + + context('user fails log in', () => { + it('logs out user if cloud request returns unauthorized', () => { + cy.findBrowsers() + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.loginUser() + cy.visitLaunchpad() + + cy.remoteGraphQLIntercept((obj) => { + if (obj.result.data?.cloudProjectBySlug) { + return new obj.Response('Unauthorized', { status: 401 }) + } + + return obj.result + }) + + cy.findByTestId(headerBarId).findByRole('button', { name: 'Profile and logout', expanded: false }).as('logInButton') + + cy.get('@logInButton').click() + + cy.findByTestId('login-panel').contains('Test User').should('be.visible') + cy.findByTestId('login-panel').contains('test@example.com').should('be.visible') + + // Navigate somewhere that will query the cloud to trigger the unauthorized response + cy.contains('Component Testing').click() + cy.get('@logInButton').click() + + cy.findByTestId(headerBarId).within(() => { + cy.findByTestId('user-avatar-title').should('not.exist') + cy.findByRole('button', { name: 'Log in' }).click() + }) + }) + }) + + context('user not logged in', () => { + const mockUser = { + authToken: 'test1', + email: 'test_user_a@example.com', + name: 'Test User A', + } + + const mockUserNoName = { + authToken: 'test22', + email: 'test_user_b@example.com', + } + + const mockLogInActionsForUser = (user) => { + cy.withCtx(async (ctx, options) => { + ctx.coreData.app.browserStatus = 'open' + options.sinon.stub(ctx._apis.electronApi, 'isMainWindowFocused').returns(true) + options.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + setTimeout(() => { + onMessage({ browserOpened: true }) + }, 500) + + return new Promise((resolve) => { + setTimeout(() => { + resolve(options.user) + }, 1000) + }) + }) + }, { user }) + } + + type LoginOptions = { + expectedNextStepText: 'Continue' | 'Connect project' + displayName: string + } + + function logIn ({ expectedNextStepText, displayName }: LoginOptions) { + cy.findByTestId(headerBarId).within(() => { + cy.findByTestId('user-avatar-title').should('not.exist') + cy.findByRole('button', { name: 'Log in' }).click() + }) + + cy.findByRole('dialog', { name: 'Log in to Cypress' }).as('logInModal').within(() => { + cy.findByRole('button', { name: 'Log in' }).click() + + // The Log In button transitions through a few states as the browser launch lifecycle completes + cy.findByRole('button', { name: 'Opening browser' }).should('be.visible').and('be.disabled') + cy.findByRole('button', { name: 'Waiting for you to log in' }).should('be.visible').and('be.disabled') + }) + + cy.findByRole('dialog', { name: 'Login successful' }).within(() => { + cy.findByText('You are now logged in as', { exact: false }).should('be.visible') + cy.validateExternalLink({ name: displayName, href: 'https://on.cypress.io/dashboard/profile' }) + + // The dialog can be closed at this point by either the header close button or the Continue button + // The Continue button is tested here + cy.findByRole('button', { name: 'Close' }).should('be.visible').and('not.be.disabled') + cy.findByRole('button', { name: expectedNextStepText }).click() + }) + } + + beforeEach(() => { + cy.scaffoldProject('launchpad') + cy.openProject('launchpad') + cy.visitLaunchpad() + }) + + context('with no project id', () => { + it('shows "continue" button after login if config has not loaded', () => { + mockLogInActionsForUser(mockUser) + logIn({ expectedNextStepText: 'Continue', displayName: mockUser.name }) + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: Launchpad') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Nav') + }) + }) + + it('shows "connect project" button after login if no project id is set', () => { + cy.contains('E2E Testing').click() + + mockLogInActionsForUser(mockUser) + logIn({ expectedNextStepText: 'Connect project', displayName: mockUser.name }) + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: Launchpad') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Nav') + }) + + cy.findByRole('dialog', { name: 'Create project' }).should('be.visible') + }) + }) + + context('when there is a project id', () => { + beforeEach(() => { + cy.findBrowsers() + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.visitLaunchpad() + }) + + it('shows log in modal workflow for user with name and email', () => { + cy.contains('Component Testing').click() + mockLogInActionsForUser(mockUser) + + logIn({ expectedNextStepText: 'Continue', displayName: mockUser.name }) + + cy.get('@logInModal').should('not.exist') + cy.findByTestId(headerBarId).findByTestId('user-avatar-title').should('be.visible') + }) + + it('shows log in modal workflow for user with only email', () => { + cy.contains('Component Testing').click() + mockLogInActionsForUser(mockUserNoName) + + logIn({ expectedNextStepText: 'Continue', displayName: mockUserNoName.email }) + + cy.get('@logInModal').should('not.exist') + cy.findByTestId(headerBarId).findByTestId('user-avatar-title').should('be.visible') + }) + + it('if the project has no runs, shows "record your first run" prompt after clicking', () => { + cy.remoteGraphQLIntercept((obj) => { + if (obj.result?.data?.cloudProjectBySlug?.runs?.nodes?.length) { + obj.result.data.cloudProjectBySlug.runs.nodes = [] + } + + return obj.result + }) + + cy.contains('Component Testing').click() + + mockLogInActionsForUser(mockUserNoName) + + logIn({ expectedNextStepText: 'Continue', displayName: mockUserNoName.email }) + + cy.contains('[data-cy=standard-modal] h2', defaultMessages.specPage.banners.record.title).should('be.visible') + cy.contains('[data-cy=standard-modal]', defaultMessages.specPage.banners.record.content).should('be.visible') + cy.contains('button', 'Copy').should('be.visible') + }) + + it('shows correct error when browser cannot launch', () => { + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_COULD_NOT_LAUNCH_BROWSER', + message: 'http://127.0.0.1:0000/redirect-to-auth', + browserOpened: false, + }) + + throw new Error() + }) + }) + + cy.findByTestId(headerBarId).within(() => { + cy.findByTestId('user-avatar-title').should('not.exist') + cy.findByRole('button', { name: 'Log in' }).click() + }) + + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log in' }).click() + + cy.contains('http://127.0.0.1:0000/redirect-to-auth').should('be.visible') + cy.contains(loginText.titleBrowserError).should('be.visible') + cy.contains(loginText.bodyBrowserError).should('be.visible') + cy.contains(loginText.bodyBrowserErrorDetails).should('be.visible') + + // in this state, there is no retry UI, we ask the user to visit the auth url on their own + cy.contains('button', loginText.actionTryAgain).should('not.be.visible') + cy.contains('button', loginText.actionCancel).should('not.be.visible') + }) + }) + + it('shows correct error when error other than browser-launch happens', () => { + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_ERROR_DURING_LOGIN', + message: 'An unexpected error occurred', + browserOpened: false, + }) + + throw new Error() + }) + }) + + cy.findByTestId(headerBarId).within(() => { + cy.findByTestId('user-avatar-title').should('not.exist') + cy.findByRole('button', { name: 'Log in' }).click() + }) + + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log in' }).click() + + cy.contains(loginText.titleFailed).should('be.visible') + cy.contains(loginText.bodyError).should('be.visible') + cy.contains('An unexpected error occurred').should('be.visible') + + cy.contains('button', loginText.actionTryAgain).should('be.visible').as('tryAgain') + cy.contains('button', loginText.actionCancel).should('be.visible') + }) + + // cy.percySnapshot() // TODO: restore when Percy CSS is fixed. See https://github.com/cypress-io/cypress/issues/23435 + + cy.withCtx((ctx) => { + (ctx._apis.authApi.logIn as SinonStub).callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_BROWSER_LAUNCHED', + message: '', + browserOpened: true, + }) + + return Promise.resolve() + }) + }) + + cy.get('@tryAgain').click() + + cy.findByRole('dialog', { name: loginText.titleInitial }).within(() => { + cy.contains(loginText.actionWaiting).should('be.visible') + }) + }) + + it('cancel button correctly clears error state', () => { + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_ERROR_DURING_LOGIN', + message: 'An unexpected error occurred', + browserOpened: false, + }) + + throw new Error() + }) + }) + + cy.findByTestId(headerBarId).within(() => { + cy.findByTestId('user-avatar-title').should('not.exist') + cy.findByRole('button', { name: 'Log in' }).as('loginButton').click() + }) + + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log in' }).click() + + cy.contains(loginText.titleFailed).should('be.visible') + cy.contains(loginText.bodyError).should('be.visible') + cy.contains('An unexpected error occurred').should('be.visible') + }) + + // cy.percySnapshot() // TODO: restore when Percy CSS is fixed. See https://github.com/cypress-io/cypress/issues/23435 + + cy.findByRole('dialog', { name: loginText.titleFailed }).within(() => { + cy.contains('button', loginText.actionTryAgain).should('be.visible') + cy.contains('button', loginText.actionCancel).click() + }) + + cy.get('@loginButton').click() + cy.contains(loginText.titleInitial).should('be.visible') + }) + + it('closing modal correctly clears error state', () => { + cy.withCtx((ctx, o) => { + o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { + onMessage({ + name: 'AUTH_ERROR_DURING_LOGIN', + message: 'An unexpected error occurred', + browserOpened: false, + }) + + throw new Error() + }) + }) + + cy.findByTestId(headerBarId).within(() => { + cy.findByTestId('user-avatar-title').should('not.exist') + cy.findByRole('button', { name: 'Log in' }).as('loginButton').click() + }) + + cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('button', { name: 'Log in' }).click() + cy.contains(loginText.titleFailed).should('be.visible') + cy.contains(loginText.bodyError).should('be.visible') + cy.contains('An unexpected error occurred').should('be.visible') + + cy.findByLabelText(defaultMessages.actions.close).click() + }) + + cy.get('@loginButton').click() + cy.contains(loginText.titleInitial).should('be.visible') + }) + }) + + context('global mode', () => { + it('shows "continue" button after login if project not selected', () => { + cy.openGlobalMode() + cy.visitLaunchpad() + + mockLogInActionsForUser(mockUser) + logIn({ expectedNextStepText: 'Continue', displayName: mockUser.name }) + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: Launchpad') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Nav') + }) + }) + + it('shows "continue" button after login if project selected', () => { + cy.openGlobalMode() + cy.addProject('component-tests') + cy.visitLaunchpad() + + cy.get('[data-cy="project-card"]').click() + + mockLogInActionsForUser(mockUser) + logIn({ expectedNextStepText: 'Continue', displayName: mockUser.name }) + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: Launchpad') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Nav') + }) + }) + + it('shows "continue" button after login if project selected has project id', () => { + cy.openGlobalMode() + cy.addProject('component-tests') + cy.visitLaunchpad() + + cy.get('[data-cy="project-card"]').click() + + cy.contains('E2E Testing').click() + + mockLogInActionsForUser(mockUser) + logIn({ expectedNextStepText: 'Continue', displayName: mockUser.name }) + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: Launchpad') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Nav') + }) + }) + + it('shows "connect project" button after login if project selected has no project id', () => { + cy.openGlobalMode() + cy.addProject('launchpad') + cy.visitLaunchpad() + + cy.get('[data-cy="project-card"]').click() + + cy.contains('E2E Testing').click() + + mockLogInActionsForUser(mockUser) + logIn({ expectedNextStepText: 'Connect project', displayName: mockUser.name }) + cy.withCtx((ctx, o) => { + // validate utmSource + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[1]).to.eq('Binary: Launchpad') + // validate utmMedium + expect((ctx._apis.authApi.logIn as SinonStub).lastCall.args[2]).to.eq('Nav') + }) + + cy.findByRole('dialog', { name: 'Create project' }).should('be.visible') + }) + }) + }) + }) +}) diff --git a/packages/launchpad/src/Main.vue b/packages/launchpad/src/Main.vue index 7fb87fda11ba..5538b4b35568 100644 --- a/packages/launchpad/src/Main.vue +++ b/packages/launchpad/src/Main.vue @@ -74,6 +74,8 @@
+ +
@@ -98,6 +100,8 @@ import { computed, ref } from 'vue' import LaunchpadHeader from './setup/LaunchpadHeader.vue' import OpenBrowser from './setup/OpenBrowser.vue' import { useOnline } from '@vueuse/core' +import LoginConnectModals from '@cy/gql-components/LoginConnectModals.vue' +import CloudViewerAndProject from '@cy/gql-components/CloudViewerAndProject.vue' const { t } = useI18n() const isTestingTypeModalOpen = ref(false) diff --git a/packages/launchpad/src/main.ts b/packages/launchpad/src/main.ts index f1dc8a5a6795..cc05dc09e83a 100644 --- a/packages/launchpad/src/main.ts +++ b/packages/launchpad/src/main.ts @@ -9,6 +9,7 @@ import 'vue-toastification/dist/index.css' import { makeUrqlClient } from '@packages/frontend-shared/src/graphql/urqlClient' import { createI18n } from '@cy/i18n' import { initHighlighter } from '@packages/frontend-shared/src/components/highlight' +import { createPinia } from '@packages/frontend-shared/src/store' const app = createApp(App) @@ -19,6 +20,7 @@ app.use(Toast, { }) app.use(createI18n()) +app.use(createPinia()) Promise.all([ makeUrqlClient({ target: 'launchpad' }).then((launchpadClient) => { diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index 8c40d84f0640..234b6220c3e2 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -22,7 +22,7 @@ import { DeferredSourceMapCache } from '@packages/rewriter' import type { RemoteStates } from '@packages/server/lib/remote_states' import type { CookieJar } from '@packages/server/lib/util/cookies' import type { ResourceTypeAndCredentialManager } from '@packages/server/lib/util/resourceTypeAndCredentialManager' -import type { Automation } from '@packages/server/lib/automation/automation' +import type { AutomationCookie } from '@packages/server/lib/automation/cookies' function getRandomColorFn () { return chalk.hex(`#${Number( @@ -55,10 +55,10 @@ type HttpMiddlewareCtx = { middleware: HttpMiddlewareStacks getCookieJar: () => CookieJar deferSourceMapRewrite: (opts: { js: string, url: string }) => string - getAutomation: () => Automation getPreRequest: (cb: GetPreRequestCb) => void getAUTUrl: Http['getAUTUrl'] setAUTUrl: Http['setAUTUrl'] + simulatedCookies: AutomationCookie[] } & T export const defaultMiddleware = { @@ -70,7 +70,6 @@ export const defaultMiddleware = { export type ServerCtx = Readonly<{ config: CyServer.Config & Cypress.Config shouldCorrelatePreRequests?: () => boolean - getAutomation: () => Automation getFileServerToken: () => string getCookieJar: () => CookieJar remoteStates: RemoteStates @@ -215,7 +214,6 @@ export class Http { config: CyServer.Config shouldCorrelatePreRequests: () => boolean deferredSourceMapCache: DeferredSourceMapCache - getAutomation: () => Automation getFileServerToken: () => string remoteStates: RemoteStates middleware: HttpMiddlewareStacks @@ -235,7 +233,6 @@ export class Http { this.config = opts.config this.shouldCorrelatePreRequests = opts.shouldCorrelatePreRequests || (() => false) - this.getAutomation = opts.getAutomation this.getFileServerToken = opts.getFileServerToken this.remoteStates = opts.remoteStates this.middleware = opts.middleware @@ -263,7 +260,6 @@ export class Http { buffers: this.buffers, config: this.config, shouldCorrelatePreRequests: this.shouldCorrelatePreRequests, - getAutomation: this.getAutomation, getFileServerToken: this.getFileServerToken, remoteStates: this.remoteStates, request: this.request, @@ -273,6 +269,7 @@ export class Http { serverBus: this.serverBus, resourceTypeAndCredentialManager: this.resourceTypeAndCredentialManager, getCookieJar: this.getCookieJar, + simulatedCookies: [], debug: (formatter, ...args) => { if (!debugVerbose.enabled) return diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index ea3613dc86c3..73303e1048ab 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -4,7 +4,7 @@ import type Debug from 'debug' import type { CookieOptions } from 'express' import { cors, concatStream, httpUtils } from '@packages/network' import type { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/proxy' -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' @@ -14,6 +14,7 @@ import zlib from 'zlib' import { URL } from 'url' import { CookiesHelper } from './util/cookies' import { doesTopNeedToBeSimulated } from './util/top-simulation' +import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' interface ResponseMiddlewareProps { /** @@ -372,10 +373,23 @@ const MaybePreventCaching: ResponseMiddleware = function () { this.next() } +const setSimulatedCookies = (ctx: HttpMiddlewareThis) => { + if (ctx.res.wantsInjection !== 'fullCrossOrigin') return + + const defaultDomain = (new URL(ctx.req.proxiedUrl)).hostname + const allCookiesForRequest = ctx.getCookieJar() + .getCookies(ctx.req.proxiedUrl) + .map((cookie) => toughCookieToAutomationCookie(cookie, defaultDomain)) + + ctx.simulatedCookies = allCookiesForRequest +} + const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { const cookies: string | string[] | undefined = this.incomingRes.headers['set-cookie'] if (!cookies || !cookies.length) { + setSimulatedCookies(this) + return this.next() } @@ -441,17 +455,25 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { appendCookie(cookie) }) + setSimulatedCookies(this) + const addedCookies = await cookiesHelper.getAddedCookies() if (!addedCookies.length) { return this.next() } - this.serverBus.once('cross:origin:automation:cookies:received', () => { + // we want to set the cookies via automation so they exist in the browser + // itself. however, firefox will hang if we try to use the extension + // to set cookies on a url that's in-flight, so we send the cookies down to + // the driver, let the response go, and set the cookies via automation + // from the driver once the page has loaded but before we run any further + // commands + this.serverBus.once('cross:origin:cookies:received', () => { this.next() }) - this.serverBus.emit('cross:origin:automation:cookies', addedCookies) + this.serverBus.emit('cross:origin:cookies', addedCookies) } const REDIRECT_STATUS_CODES: any[] = [301, 302, 303, 307, 308] @@ -524,6 +546,7 @@ const MaybeInjectHtml: ResponseMiddleware = function () { modifyObstructiveCode: this.config.modifyObstructiveCode, url: this.req.proxiedUrl, deferSourceMapRewrite: this.deferSourceMapRewrite, + simulatedCookies: this.simulatedCookies, }) const encodedBody = iconv.encode(injectedBody, nodeCharset) diff --git a/packages/proxy/lib/http/util/inject.ts b/packages/proxy/lib/http/util/inject.ts index 33aef2b2c2cf..f6c20bfe64b9 100644 --- a/packages/proxy/lib/http/util/inject.ts +++ b/packages/proxy/lib/http/util/inject.ts @@ -1,5 +1,12 @@ import { oneLine } from 'common-tags' import { getRunnerInjectionContents, getRunnerCrossOriginInjectionContents } from '@packages/resolve-dist' +import type { AutomationCookie } from '@packages/server/lib/automation/cookies' + +interface FullCrossOriginOpts { + modifyObstructiveThirdPartyCode: boolean + modifyObstructiveCode: boolean + simulatedCookies: AutomationCookie[] +} export function partial (domain) { return oneLine` @@ -21,15 +28,16 @@ export function full (domain) { }) } -export function fullCrossOrigin (domain, { modifyObstructiveThirdPartyCode, modifyObstructiveCode }) { - return getRunnerCrossOriginInjectionContents().then((contents) => { - return oneLine` - - ` - }) + }(${JSON.stringify(options)})); + + ` } diff --git a/packages/proxy/lib/http/util/rewriter.ts b/packages/proxy/lib/http/util/rewriter.ts index c041cdbdc8e0..6e206320eb16 100644 --- a/packages/proxy/lib/http/util/rewriter.ts +++ b/packages/proxy/lib/http/util/rewriter.ts @@ -2,6 +2,7 @@ import * as inject from './inject' import * as astRewriter from './ast-rewriter' import * as regexRewriter from './regex-rewriter' import type { CypressWantsInjection } from '../../types' +import type { AutomationCookie } from '@packages/server/lib/automation/cookies' export type SecurityOpts = { isHtml?: boolean @@ -16,23 +17,36 @@ export type InjectionOpts = { domainName: string wantsInjection: CypressWantsInjection wantsSecurityRemoved: any + simulatedCookies: AutomationCookie[] } -const doctypeRe = /(<\!doctype.*?>)/i -const headRe = /()/i -const bodyRe = /()/i -const htmlRe = /()/i +const doctypeRe = /<\!doctype.*?>/i +const headRe = //i +const bodyRe = //i +const htmlRe = //i function getRewriter (useAstSourceRewriting: boolean) { return useAstSourceRewriting ? astRewriter : regexRewriter } -function getHtmlToInject ({ domainName, wantsInjection, modifyObstructiveThirdPartyCode, modifyObstructiveCode }: InjectionOpts & SecurityOpts) { +function getHtmlToInject (opts: InjectionOpts & SecurityOpts) { + const { + domainName, + wantsInjection, + modifyObstructiveThirdPartyCode, + modifyObstructiveCode, + simulatedCookies, + } = opts + switch (wantsInjection) { case 'full': return inject.full(domainName) case 'fullCrossOrigin': - return inject.fullCrossOrigin(domainName, { modifyObstructiveThirdPartyCode, modifyObstructiveCode }) + return inject.fullCrossOrigin(domainName, { + modifyObstructiveThirdPartyCode, + modifyObstructiveCode, + simulatedCookies, + }) case 'partial': return inject.partial(domainName) default: @@ -40,11 +54,19 @@ function getHtmlToInject ({ domainName, wantsInjection, modifyObstructiveThirdPa } } -export async function html (html: string, opts: SecurityOpts & InjectionOpts) { - const replace = (re, str) => { - return html.replace(re, str) - } +const insertBefore = (originalString, match, stringToInsert) => { + const index = match.index || 0 + + return `${originalString.slice(0, index)}${stringToInsert} ${originalString.slice(index)}` +} +const insertAfter = (originalString, match, stringToInsert) => { + const index = (match.index || 0) + match[0].length + + return `${originalString.slice(0, index)} ${stringToInsert}${originalString.slice(index)}` +} + +export async function html (html: string, opts: SecurityOpts & InjectionOpts) { const htmlToInject = await Promise.resolve(getHtmlToInject(opts)) // strip clickjacking and framebusting @@ -58,23 +80,31 @@ export async function html (html: string, opts: SecurityOpts & InjectionOpts) { } // TODO: move this into regex-rewriting and have ast-rewriting handle this in its own way - switch (false) { - case !headRe.test(html): - return replace(headRe, `$1 ${htmlToInject}`) - case !bodyRe.test(html): - return replace(bodyRe, ` ${htmlToInject} $1`) + const headMatch = html.match(headRe) - case !htmlRe.test(html): - return replace(htmlRe, `$1 ${htmlToInject} `) + if (headMatch) { + return insertAfter(html, headMatch, htmlToInject) + } - case !doctypeRe.test(html): - // if only content, inject after doctype - return `${html} ${htmlToInject} ` + const bodyMatch = html.match(bodyRe) - default: - return ` ${htmlToInject} ${html}` + if (bodyMatch) { + return insertBefore(html, bodyMatch, ` ${htmlToInject} `) + } + + const htmlMatch = html.match(htmlRe) + + if (htmlMatch) { + return insertAfter(html, htmlMatch, ` ${htmlToInject} `) + } + + // if only content, inject after doctype + if (doctypeRe.test(html)) { + return `${html} ${htmlToInject} ` } + + return ` ${htmlToInject} ${html}` } export function security (opts: SecurityOpts) { diff --git a/packages/proxy/test/integration/net-stubbing.spec.ts b/packages/proxy/test/integration/net-stubbing.spec.ts index fd5bfe27957d..fa6923178c2c 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -1,4 +1,4 @@ -import { NetworkProxy } from '../../' +import { NetworkProxy, RequestResourceType } from '../../' import { netStubbingState as _netStubbingState, NetStubbingState, @@ -12,6 +12,7 @@ import supertest from 'supertest' import { allowDestroy } from '@packages/network' import { EventEmitter } from 'events' import { RemoteStates } from '@packages/server/lib/remote_states' +import { CookieJar } from '@packages/server/lib/util/cookies' const Request = require('@packages/server/lib/request') const getFixture = async () => {} @@ -39,12 +40,22 @@ context('network stubbing', () => { netStubbingState, config, middleware: defaultMiddleware, - getCurrentBrowser: () => ({ family: 'chromium' }), + getCookieJar: () => new CookieJar(), remoteStates, getFileServerToken: () => 'fake-token', request: new Request(), getRenderedHTMLOrigins: () => ({}), serverBus: new EventEmitter(), + resourceTypeAndCredentialManager: { + get (url: string, optionalResourceType?: RequestResourceType) { + return { + resourceType: 'xhr', + credentialStatus: 'same-origin', + } + }, + set () {}, + clear () {}, + }, }) app.use((req, res, next) => { diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index 43ee7b0936c5..d239632b2427 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -687,16 +687,19 @@ describe('http/response-middleware', function () { expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') }) + const getCookieJarStub = () => { + return { + getAllCookies: sinon.stub().returns([{ key: 'cookie', value: 'value' }]), + getCookies: sinon.stub().returns([]), + setCookie: sinon.stub(), + } + } + describe('same-origin', () => { ['same-origin', 'include'].forEach((credentialLevel) => { it(`sets first-party cookie context in the jar when simulating top if credentials included with fetch with credential ${credentialLevel}`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -751,12 +754,7 @@ describe('http/response-middleware', function () { ;[true, false].forEach((credentialLevel) => { it(`sets first-party cookie context in the jar when simulating top if withCredentials ${credentialLevel} with xhr`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -810,12 +808,7 @@ describe('http/response-middleware', function () { it(`sets no cookies if fetch level is omit`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -871,12 +864,7 @@ describe('http/response-middleware', function () { describe('same-site', () => { it('sets first-party cookie context in the jar when simulating top if credentials included with fetch via include', async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -929,12 +917,7 @@ describe('http/response-middleware', function () { it('sets first-party cookie context in the jar when simulating top if credentials true with xhr', async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -988,12 +971,7 @@ describe('http/response-middleware', function () { ;['same-origin', 'omit'].forEach((credentialLevel) => { it(`sets no cookies if fetch level is ${credentialLevel}`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1032,12 +1010,7 @@ describe('http/response-middleware', function () { describe('cross-site', () => { it('sets third-party cookie context in the jar when simulating top if credentials included with fetch', async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1088,12 +1061,7 @@ describe('http/response-middleware', function () { ;['same-origin', 'omit'].forEach((credentialLevel) => { it(`does NOT set third-party cookie context in the jar when simulating top if credentials ${credentialLevel} with fetch`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1127,12 +1095,7 @@ describe('http/response-middleware', function () { it('sets third-party cookie context in the jar when simulating top if withCredentials true with xhr', async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1182,12 +1145,7 @@ describe('http/response-middleware', function () { it('does not set third-party cookie context in the jar when simulating top if withCredentials false with xhr', async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1221,12 +1179,7 @@ describe('http/response-middleware', function () { it(`does NOT set third-party cookie context in the jar if secure cookie is not enabled`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1259,12 +1212,7 @@ describe('http/response-middleware', function () { it(`allows setting cookies if request type cannot be determined, but comes from the AUT frame (likely in the case of documents or redirects)`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1299,12 +1247,7 @@ describe('http/response-middleware', function () { it(`otherwise, does not allow setting cookies if request type cannot be determined and is not from the AUT and is cross-origin`, async function () { const appendStub = sinon.stub() - - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - setCookie: sinon.stub(), - } - + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, res: { @@ -1332,7 +1275,7 @@ describe('http/response-middleware', function () { expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') }) - it('does not send cross:origin:automation:cookies if request does not need top simulation', async () => { + it('does not send cross:origin:cookies if request does not need top simulation', async () => { const { ctx } = prepareSameOriginContext() await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) @@ -1340,11 +1283,8 @@ describe('http/response-middleware', function () { expect(ctx.serverBus.emit).not.to.be.called }) - it('does not send cross:origin:automation:cookies if there are no added cookies', async () => { - const cookieJar = { - getAllCookies: () => [{ key: 'cookie', value: 'value' }], - } - + it('does not send cross:origin:cookies if there are no added cookies', async () => { + const cookieJar = getCookieJarStub() const ctx = prepareContext({ cookieJar, incomingRes: { @@ -1359,16 +1299,17 @@ describe('http/response-middleware', function () { expect(ctx.serverBus.emit).not.to.be.called }) - it('sends cross:origin:automation:cookies if there are added cookies and resolves on cross:origin:automation:cookies:received', async () => { - const cookieJar = { - getAllCookies: sinon.stub(), - } + it('sends cross:origin:cookies with origin and cookies if there are added cookies and resolves on cross:origin:cookies:received', async () => { + const cookieJar = getCookieJarStub() cookieJar.getAllCookies.onCall(0).returns([]) cookieJar.getAllCookies.onCall(1).returns([cookieStub({ key: 'cookie', value: 'value' })]) const ctx = prepareContext({ cookieJar, + req: { + isAUTFrame: true, + }, incomingRes: { headers: { 'set-cookie': 'cookie=value', @@ -1378,13 +1319,13 @@ describe('http/response-middleware', function () { // test will hang if this.next() is not called, so this also tests // that we move on once receiving this event - ctx.serverBus.once.withArgs('cross:origin:automation:cookies:received').yields() + ctx.serverBus.once.withArgs('cross:origin:cookies:received').yields() await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) - expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:automation:cookies') + expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:cookies') - const cookies = ctx.serverBus.emit.withArgs('cross:origin:automation:cookies').args[0][1] + const cookies = ctx.serverBus.emit.withArgs('cross:origin:cookies').args[0][1] expect(cookies[0].name).to.equal('cookie') expect(cookies[0].value).to.equal('value') @@ -1405,6 +1346,7 @@ describe('http/response-middleware', function () { const cookieJar = props.cookieJar || { getAllCookies: () => [], + getCookies: () => [], } return { @@ -1496,6 +1438,7 @@ describe('http/response-middleware', function () { req: { proxiedUrl: 'http://www.foobar.com:3501/primary-origin.html', }, + simulatedCookies: [], }) return testMiddleware([MaybeInjectHtml], ctx) @@ -1511,12 +1454,15 @@ describe('http/response-middleware', function () { 'useAstSourceRewriting': undefined, 'wantsInjection': 'full', 'wantsSecurityRemoved': true, + 'simulatedCookies': [], }) }) }) it('modifyObstructiveThirdPartyCode is false for primary requests', function () { - prepareContext({}) + prepareContext({ + simulatedCookies: [], + }) return testMiddleware([MaybeInjectHtml], ctx) .then(() => { @@ -1531,6 +1477,7 @@ describe('http/response-middleware', function () { 'useAstSourceRewriting': undefined, 'wantsInjection': 'full', 'wantsSecurityRemoved': true, + 'simulatedCookies': [], }) }) }) @@ -1544,6 +1491,7 @@ describe('http/response-middleware', function () { modifyObstructiveCode: false, experimentalModifyObstructiveThirdPartyCode: false, }, + simulatedCookies: [], }) return testMiddleware([MaybeInjectHtml], ctx) @@ -1559,6 +1507,7 @@ describe('http/response-middleware', function () { 'useAstSourceRewriting': undefined, 'wantsInjection': 'full', 'wantsSecurityRemoved': true, + 'simulatedCookies': [], }) }) }) diff --git a/packages/reporter/cypress/e2e/commands.cy.ts b/packages/reporter/cypress/e2e/commands.cy.ts index 84bdbeb0a4bd..fffa69a6153d 100644 --- a/packages/reporter/cypress/e2e/commands.cy.ts +++ b/packages/reporter/cypress/e2e/commands.cy.ts @@ -926,24 +926,30 @@ describe('commands', { viewportHeight: 1000 }, () => { context('test error', () => { // this is a unique error permutation currently only observed in cy.session() where an error - // message should be presented during the session validation of a saves/restored session and the - // session command will attempt to recreate a valid session. - it('renders error instead of command', () => { - // font-family: $font-system; + // message should be presented if the session validation fails for a restored session because the + // session command recover and attempt to recreate a valid session. + it('renders recovered error for command', () => { cy.fixture('command_error').then((_commandErr) => { + const groupId = addCommand(runner, { + name: 'session', + message: 'mock restore', + state: 'passed', + type: 'system', + }) + addCommand(runner, { - id: 10, - number: 7, + type: 'system', name: 'validate', - displayMessage: 'mock session validation', state: 'failed', - showError: true, - err: _commandErr, - type: 'parent', + err: { + ..._commandErr, + isRecovered: true, + }, + groupLevel: 1, + group: groupId, }) addCommand(runner, { - id: 11, name: 'recreate session', message: 'mock recreate session cmd', state: 'success', @@ -955,6 +961,58 @@ describe('commands', { viewportHeight: 1000 }, () => { cy.contains('recreate session') cy.percySnapshot() }) + + it('renders recovered error for nested group command', () => { + cy.fixture('command_error').then((_commandErr) => { + const groupId = addCommand(runner, { + name: 'session', + message: 'mock restore', + state: 'passed', + type: 'system', + }) + + const nested = addCommand(runner, { + type: 'system', + name: 'validate', + state: 'failed', + group: groupId, + }) + + addCommand(runner, { + number: 8, + name: 'get', + message: 'does_not_exist', + state: 'failed', + err: { + ..._commandErr, + isRecovered: true, + }, + type: 'parent', + groupLevel: 2, + group: nested, + }) + + addCommand(runner, { + id: 12, + name: 'recreate session', + message: 'mock recreate session cmd', + state: 'success', + type: 'parent', + }) + }) + + cy.contains('.command', 'validate').as('validate') + .find('.command-expander') + .should('have.class', 'command-expander-is-open') + + cy.get('@validate').within(() => { + cy.contains('CommandError') + }) + + cy.contains('recreate session') + + cy.percySnapshot() + }) }) context('studio commands', () => { diff --git a/packages/reporter/cypress/e2e/test_errors.cy.ts b/packages/reporter/cypress/e2e/test_errors.cy.ts index 0634480fc91e..d2bd63474341 100644 --- a/packages/reporter/cypress/e2e/test_errors.cy.ts +++ b/packages/reporter/cypress/e2e/test_errors.cy.ts @@ -227,12 +227,13 @@ describe('test errors', () => { // NOTE: still needs to be implemented it.skip('renders and escapes markdown with leading/trailing whitespace', () => { + setError(commandErr) cy.get('.runnable-err-message') // https://github.com/cypress-io/cypress/issues/1360 // renders ** buzz ** as buzz - .contains('code', 'foo') - .and('not.contain', '`foo`') + .contains('strong', 'buzz') + .and('not.contain', '** buzz **') }) }) diff --git a/packages/reporter/cypress/e2e/unit/err_model.cy.ts b/packages/reporter/cypress/e2e/unit/err_model.cy.ts index 85f228f9468b..86d687b3096f 100644 --- a/packages/reporter/cypress/e2e/unit/err_model.cy.ts +++ b/packages/reporter/cypress/e2e/unit/err_model.cy.ts @@ -69,6 +69,12 @@ describe('Err model', () => { expect(err.stack).to.equal('the stack (path/to/file.js 45:203)') }) + it('updates isRecovered if specified', () => { + expect(err.isRecovered).to.be.false + err.update({ isRecovered: true }) + expect(err.isRecovered).to.be.true + }) + it('does nothing if props is undefined', () => { err.update() expect(err.name).to.equal('BadError') diff --git a/packages/reporter/cypress/e2e/unit/events.cy.ts b/packages/reporter/cypress/e2e/unit/events.cy.ts index 9c666dfe34fb..de05377037e6 100644 --- a/packages/reporter/cypress/e2e/unit/events.cy.ts +++ b/packages/reporter/cypress/e2e/unit/events.cy.ts @@ -306,22 +306,19 @@ describe('events', () => { expect(runner.emit).to.have.been.calledWith('runner:console:log', 'command id') }) - it('emits runner:console:error with test id on show:error', () => { - const test = { err: { isCommandErr: false } } + it('emits runner:console:error with error on show:error', () => { + const errorDetails = { err: { stack: 'hi' } } - runnablesStore.testById.returns(test) - events.emit('show:error', test) + events.emit('show:error', errorDetails) expect(runner.emit).to.have.been.calledWith('runner:console:error', { - err: test.err, + err: errorDetails.err, testId: undefined, logId: undefined, }) }) - it('emits runner:console:error with test id and command id on show:error when it is a command error and there is a matching command', () => { - const test = { err: { isCommandErr: true }, commandMatchingErr: () => { - return { id: 'matching command id', testId: 'test' } - } } + it('emits runner:console:error with error, test id and command id on show:error ', () => { + const test = { err: { isCommandErr: true }, commandId: 'matching command id', testId: 'test' } runnablesStore.testById.returns(test) events.emit('show:error', test) @@ -332,20 +329,6 @@ describe('events', () => { }) }) - it('emits runner:console:error with test id on show:error when it is a command error but there not a matching command', () => { - const test = { err: { isCommandErr: true }, commandMatchingErr: () => { - return null - } } - - runnablesStore.testById.returns(test) - events.emit('show:error', test) - expect(runner.emit).to.have.been.calledWith('runner:console:error', { - err: test.err, - testId: undefined, - logId: undefined, - }) - }) - it('emits runner:show:snapshot on show:snapshot', () => { events.emit('show:snapshot', 'command id') expect(runner.emit).to.have.been.calledWith('runner:show:snapshot', 'command id') diff --git a/packages/reporter/src/attempts/attempt-model.ts b/packages/reporter/src/attempts/attempt-model.ts index ca6a910a5373..59716cb78084 100644 --- a/packages/reporter/src/attempts/attempt-model.ts +++ b/packages/reporter/src/attempts/attempt-model.ts @@ -17,7 +17,7 @@ export default class Attempt { @observable agents: Agent[] = [] @observable sessions: Record = {} @observable commands: Command[] = [] - @observable err = new Err({}) + @observable err?: Err = undefined @observable hooks: Hook[] = [] // TODO: make this an enum with states: 'QUEUED, ACTIVE, INACTIVE' @observable isActive: boolean | null = null @@ -49,7 +49,10 @@ export default class Attempt { this.id = props.currentRetry || 0 this.test = test this._state = props.state - this.err.update(props.err) + + if (props.err) { + this.err = new Err(props.err) + } this.invocationDetails = props.invocationDetails @@ -78,6 +81,16 @@ export default class Attempt { return this._state || (this.isActive ? 'active' : 'processing') } + @computed get error () { + const command = this.err?.isCommandErr ? this.commandMatchingErr() : undefined + + return { + err: this.err, + testId: command?.testId, + commandId: command?.id, + } + } + @computed get isLast () { return this.id === this.test.lastAttempt.id } @@ -137,9 +150,14 @@ export default class Attempt { } } - commandMatchingErr () { + commandMatchingErr (): Command | undefined { + if (!this.err) { + return undefined + } + return _(this.hooks) .map((hook) => { + // @ts-ignore return hook.commandMatchingErr(this.err) }) .compact() @@ -155,7 +173,13 @@ export default class Attempt { this._state = props.state } - this.err.update(props.err) + if (props.err) { + if (this.err) { + this.err.update(props.err) + } else { + this.err = new Err(props.err) + } + } if (props.failedFromHookId) { const hook = _.find(this.hooks, { hookId: props.failedFromHookId }) diff --git a/packages/reporter/src/attempts/attempts.tsx b/packages/reporter/src/attempts/attempts.tsx index 2a40627d6c2a..56b338a7bf98 100644 --- a/packages/reporter/src/attempts/attempts.tsx +++ b/packages/reporter/src/attempts/attempts.tsx @@ -55,10 +55,12 @@ function renderAttemptContent (model: AttemptModel, studioActive: boolean) {
{model.hasCommands ? : }
-
- - {studioActive && model.state === 'failed' && } -
+ {model.state === 'failed' && ( +
+ + {studioActive && } +
+ )}
) } diff --git a/packages/reporter/src/commands/command-model.ts b/packages/reporter/src/commands/command-model.ts index 889a934d1085..dc4988685702 100644 --- a/packages/reporter/src/commands/command-model.ts +++ b/packages/reporter/src/commands/command-model.ts @@ -35,7 +35,6 @@ export interface CommandProps extends InstrumentProps { wallClockStartedAt?: string hookId: string isStudio?: boolean - showError?: boolean group?: number groupLevel?: number hasSnapshot?: boolean @@ -45,7 +44,7 @@ export interface CommandProps extends InstrumentProps { export default class Command extends Instrument { @observable.struct renderProps: RenderProps = {} @observable.struct sessionInfo?: SessionProps['sessionInfo'] - @observable err = new Err({}) + @observable err?: Err @observable event?: boolean = false @observable isLongRunning = false @observable number?: number @@ -56,7 +55,6 @@ export default class Command extends Instrument { @observable children: Array = [] @observable hookId: string @observable isStudio: boolean - @observable showError?: boolean = false @observable group?: number @observable groupLevel?: number @observable hasSnapshot?: boolean @@ -92,6 +90,7 @@ export default class Command extends Instrument { return this._isOpen || (this._isOpen === null && ( + this.err?.isRecovered || // command has nested commands (this.name !== 'session' && this.hasChildren && !this.event && this.type !== 'system') || // command has nested commands with children @@ -123,7 +122,10 @@ export default class Command extends Instrument { constructor (props: CommandProps) { super(props) - this.err.update(props.err) + if (props.err) { + this.err = new Err(props.err) + } + this.event = props.event this.number = props.number this.numElements = props.numElements @@ -136,7 +138,6 @@ export default class Command extends Instrument { this.wallClockStartedAt = props.wallClockStartedAt this.hookId = props.hookId this.isStudio = !!props.isStudio - this.showError = !!props.showError this.group = props.group this.hasSnapshot = !!props.hasSnapshot this.hasConsoleProps = !!props.hasConsoleProps @@ -148,7 +149,14 @@ export default class Command extends Instrument { update (props: CommandProps) { super.update(props) - this.err.update(props.err) + if (props.err) { + if (!this.err) { + this.err = new Err(props.err) + } else { + this.err.update(props.err) + } + } + this.event = props.event this.numElements = props.numElements this.renderProps = props.renderProps || {} @@ -159,7 +167,6 @@ export default class Command extends Instrument { this.timeout = props.timeout this.hasSnapshot = props.hasSnapshot this.hasConsoleProps = props.hasConsoleProps - this.showError = props.showError this._checkLongRunning() } diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index 8c18ecca5590..671f98f9decd 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -9,6 +9,7 @@ import Tooltip from '@cypress/react-tooltip' import appState, { AppState } from '../lib/app-state' import events, { Events } from '../lib/events' import FlashOnClick from '../lib/flash-on-click' +import StateIcon from '../lib/state-icon' import Tag from '../lib/tag' import { TimeoutID } from '../lib/types' import runnablesStore, { RunnablesStore } from '../runnables/runnables-store' @@ -258,6 +259,80 @@ interface Props { groupId?: number } +const CommandDetails = observer(({ model, groupId, aliasesWithDuplicates }) => ( + + + + {model.event && model.type !== 'system' ? `(${displayName(model)})` : displayName(model)} + + + {!!groupId && model.type === 'system' && model.state === 'failed' && } + {model.referencesAlias ? + + : + } + +)) + +const CommandControls = observer(({ model, commandName, events }) => { + const displayNumOfElements = model.state !== 'pending' && model.numElements != null && model.numElements !== 1 + const isSystemEvent = model.type === 'system' && model.event + const isSessionCommand = commandName === 'session' + const displayNumOfChildren = !isSystemEvent && !isSessionCommand && model.hasChildren && !model.isOpen + + const _removeStudioCommand = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + events.emit('studio:remove:command', model.number) + } + + return ( + + + {model.type === 'parent' && model.isStudio && ( + + )} + {isSessionCommand && ( + + )} + {!model.visible && ( + + + + + + )} + {displayNumOfElements && ( + + )} + + + + {displayNumOfChildren && ( + + )} + + + ) +}) + @observer class Command extends Component { @observable isOpen: boolean|null = null @@ -276,119 +351,60 @@ class Command extends Component { return null } - if (model.showError) { - // this error is rendered if an error occurs in session validation executed by cy.session - return - } - const commandName = model.name ? nameClassName(model.name) : '' - const displayNumOfElements = model.state !== 'pending' && model.numElements != null && model.numElements !== 1 - const isSystemEvent = model.type === 'system' && model.event - const isSessionCommand = commandName === 'session' - const displayNumOfChildren = !isSystemEvent && !isSessionCommand && model.hasChildren && !model.isOpen - const groupPlaceholder: Array = [] + let groupLevel = 0 + if (model.groupLevel !== undefined) { // cap the group nesting to 5 levels to keep the log text legible - const level = model.groupLevel < 6 ? model.groupLevel : 5 + groupLevel = model.groupLevel < 6 ? model.groupLevel : 5 - for (let i = 1; i < level; i++) { + for (let i = 1; i < groupLevel; i++) { groupPlaceholder.push() } } return ( -
  • -
    +
  • +
    - - -
    this._snapshot(true)} - onMouseLeave={() => this._snapshot(false)} + + - {groupPlaceholder} - - - - {model.event && model.type !== 'system' ? `(${displayName(model)})` : displayName(model)} - - - {model.referencesAlias ? - - : - } - - - {model.type === 'parent' && model.isStudio && ( - - )} - {isSessionCommand && ( - - )} - {!model.visible && ( - - - - - - )} - {displayNumOfElements && ( - - )} - - - - {displayNumOfChildren && ( - - )} - - -
    -
    -
    - - {this._children()} -
  • +
    this._snapshot(true)} + onMouseLeave={() => this._snapshot(false)} + > + {groupPlaceholder} + + +
    + +
    + + {this._children()} + + {model.err?.isRecovered && ( +
  • + )} + ) } @@ -481,15 +497,6 @@ class Command extends Component { }, 50) } } - - _removeStudioCommand = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - const { model, events } = this.props - - events.emit('studio:remove:command', model.number) - } } export { Aliases, AliasesReferences, Message, Progress } diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 0633227df7ed..ecc29616a416 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -131,20 +131,22 @@ .command-group-block { @include nested-command-dashes($gray-600); - width: 11px; + width: 12px; min-height: 28px; - min-width: 11px; + min-width: 12px; } } .command-info { + display: -webkit-box; + font-weight: 600; margin-left: 0; overflow: hidden; - width: 100%; - font-weight: 600; + padding-top: 4px; + padding-bottom: 4px; -webkit-line-clamp: 50; -webkit-box-orient: vertical; - display: -webkit-box; + width: 100%; .command-aliases, .command-message { @@ -254,9 +256,9 @@ color: $err-header-text; &:not(.command-type-system) { - border-left: 2px solid $fail; + border-left: $err-border; background-color: $err-header-background; - + &.command-is-interactive:hover { background: rgba($red-400, 0.3); } @@ -268,6 +270,10 @@ color: $err-header-text; } + .failed-indicator { + vertical-align: middle; + } + .command-group { border-color: $err-header-text; @include nested-command-dashes($err-header-text); @@ -279,13 +285,6 @@ } } - .command .runnable-err-wrapper { - padding: 0; - border: 0; - margin: 0; - margin-bottom: 5px; - } - // Custom Styles for Specific Commands .command-name-assert { .command-method { diff --git a/packages/reporter/src/errors/err-model.ts b/packages/reporter/src/errors/err-model.ts index 29aff35b2948..461a05e2c316 100644 --- a/packages/reporter/src/errors/err-model.ts +++ b/packages/reporter/src/errors/err-model.ts @@ -30,6 +30,7 @@ export interface ErrProps { docsUrl: string | string[] templateType: string codeFrame: CodeFrame + isRecovered: boolean } export default class Err { @@ -41,6 +42,7 @@ export default class Err { @observable templateType = '' // @ts-ignore @observable.ref codeFrame: CodeFrame + @observable isRecovered: boolean = false constructor (props?: Partial) { this.update(props) @@ -64,5 +66,6 @@ export default class Err { if (props.parsedStack) this.parsedStack = props.parsedStack if (props.templateType) this.templateType = props.templateType if (props.codeFrame) this.codeFrame = props.codeFrame + this.isRecovered = !!props.isRecovered } } diff --git a/packages/reporter/src/errors/errors.scss b/packages/reporter/src/errors/errors.scss index 2184e69d41f6..369a5ad2cfd1 100644 --- a/packages/reporter/src/errors/errors.scss +++ b/packages/reporter/src/errors/errors.scss @@ -51,8 +51,34 @@ $code-border-radius: 4px; } } - .runnable-err-wrapper { - cursor: default; + .show-recovered-test-err { + .runnable-err-header, + .runnable-err-body { + padding-left: 49px; + display: flex; + + .err-group-block { + border-left: 1px dotted $err-header-text; + border-image-slice: 0 0 0 1; + border-image-source: repeating-linear-gradient(0deg, transparent, $err-header-text, $err-header-text 4px); + width: 13px; + min-width: 13px; + } + } + + .runnable-err-header > .runnable-err-name { + padding: 5px 4px 5px 15px; + } + + .runnable-err-content { + padding: 0 12px 0 0; + } + } + + .runnable-err-content { + width: 100%; + overflow: scroll; + padding: 0 18px; } .studio-err-wrapper { @@ -60,13 +86,14 @@ $code-border-radius: 4px; } .runnable-err { - border-left: 2px solid $fail; background-color: $err-background; + border-left: $err-border; clear: both; color: $err-text; font-family: $monospace; + font-style: normal; margin-bottom: 0; - margin-top: 5px; + margin-top: 2px; white-space: pre-wrap; word-break: break-word; user-select: initial; @@ -74,39 +101,45 @@ $code-border-radius: 4px; } .runnable-err-header { - background-color: rgba($red-400, 0.05); + background-color: $err-header-background; display: flex; - justify-content: space-between; - padding: 5px 10px; font-weight: bold; + justify-content: space-between; + padding-left: 18px; + + svg { + color: $red-400; + align-self: center + } .runnable-err-name { - flex-grow: 2; - font-size: 13px; - line-height: 22px; color: $err-header-text; - - svg { - margin-right: 10px; - } + flex: auto; + font-size: 12px; + font-weight: 600; + line-height: 20px; + padding: 5px 4px 5px 24px; } } .runnable-err-docs-url { margin-left: 0.5em; cursor: pointer; - font-family: $font-sans; + font-family: $font-system; } .runnable-err-message { - font-family: $monospace; - font-size: 1em; - padding: 10px; + font-family: $font-system; + font-size: 14px; + font-weight: 400; + padding: 10px 0; code { background-color: rgba($black, 0.2); border-radius: 4px; color: $err-code-text; + font-size: 12px; + font-family: $monospace; padding: 2px 5px; } @@ -118,9 +151,10 @@ $code-border-radius: 4px; .runnable-err-stack-expander { align-items: center; - border-top: 1px solid rgba($red-400, 0.1); + border-top: 1px dashed rgba($red-400, 0.1); display: flex; - + padding: 10px 0; + flex-wrap: wrap-reverse; .collapsible-header { flex-grow: 1; @@ -147,7 +181,7 @@ $code-border-radius: 4px; div { cursor: pointer; outline: none; - padding: 14px 10px; + padding: 6px 0; width: 100%; .collapsible-header-text { @@ -184,10 +218,10 @@ $code-border-radius: 4px; div { color: $red-300; cursor: pointer; + font-family: $font-system; font-size: 14px; font-weight: 500; height: 100%; - padding: 14px 10px; width: 100%; &:focus { @@ -224,9 +258,9 @@ $code-border-radius: 4px; .test-err-code-frame { background-color: $gray-1000; - border: 1px solid rgba($red-400, 0.25); + border: 1px dashed rgba(245, 154, 169, 0.1); border-radius: $code-border-radius; - margin: 0 10px 10px; + margin: 0 0 10px; .runnable-err-file-path { background: rgba($gray-900, 0.5); @@ -234,7 +268,7 @@ $code-border-radius: 4px; border-top-right-radius: $code-border-radius; display: block; font-size: 14px; - line-height: 16px; + line-height: 20px; padding: 8px; word-break: break-all; diff --git a/packages/reporter/src/errors/test-error.tsx b/packages/reporter/src/errors/test-error.tsx index 58adae81a397..6c93517f1f19 100644 --- a/packages/reporter/src/errors/test-error.tsx +++ b/packages/reporter/src/errors/test-error.tsx @@ -1,5 +1,6 @@ import _ from 'lodash' import React, { MouseEvent } from 'react' +import cs from 'classnames' import { observer } from 'mobx-react' import Markdown from 'markdown-it' @@ -10,8 +11,7 @@ import ErrorStack from '../errors/error-stack' import events from '../lib/events' import FlashOnClick from '../lib/flash-on-click' import { onEnterOrSpace } from '../lib/util' -import Attempt from '../attempts/attempt-model' -import Command from '../commands/command-model' +import Err from './err-model' import { formattedMessage } from '../commands/command' import WarningIcon from '-!react-svg-loader!@packages/frontend-shared/src/assets/icons/warning_x8.svg' @@ -26,28 +26,33 @@ const DocsUrl = ({ url }: DocsUrlProps) => { const urlArray = _.castArray(url) - return (<> - {_.map(urlArray, (url) => ( - - Learn more - - ))} - ) + return _.map(urlArray, (url) => ( + + Learn more + + )) } interface TestErrorProps { - model: Attempt | Command - onPrintToConsole?: () => void + err: Err + testId?: string + commandId?: number + // the command group level to nest the recovered in-test error + groupLevel: number } -const TestError = observer((props: TestErrorProps) => { +const TestError = (props: TestErrorProps) => { + const { err } = props + + if (!err || !err.displayMessage) return null + const md = new Markdown('zero') md.enable(['backticks', 'emphasis', 'escape']) - const onPrint = props.onPrintToConsole || (() => { - events.emit('show:error', props.model) - }) + const onPrint = () => { + events.emit('show:error', props) + } const _onPrintClick = (e: MouseEvent) => { e.stopPropagation() @@ -55,26 +60,35 @@ const TestError = observer((props: TestErrorProps) => { onPrint() } - const { err } = props.model const { codeFrame } = err - if (!err.displayMessage) return null + const groupPlaceholder: Array = [] + + if (err.isRecovered) { + // cap the group nesting to 5 levels to keep the log text legible + for (let i = 0; i < props.groupLevel; i++) { + groupPlaceholder.push() + } + } return ( -
    -
    -
    -
    - - {err.name} -
    -
    -
    - - +
    +
    + {groupPlaceholder} + +
    + {err.name}
    - {codeFrame && } - {err.stack && +
    +
    + {groupPlaceholder} +
    +
    + + +
    + {codeFrame && } + {err.stack && { > - } + } +
    ) -}) +} + +TestError.defaultProps = { + groupLevel: 0, +} -export default TestError +export default observer(TestError) diff --git a/packages/reporter/src/hooks/hook-model.ts b/packages/reporter/src/hooks/hook-model.ts index 8477d6c1c639..a745c2a1a762 100644 --- a/packages/reporter/src/hooks/hook-model.ts +++ b/packages/reporter/src/hooks/hook-model.ts @@ -117,8 +117,8 @@ export default class Hook implements HookProps { this.commands.splice(commandIndex, 1) } - commandMatchingErr (errToMatch: Err) { - return _(this.commands) + commandMatchingErr (errToMatch: Err): CommandModel | undefined { + return _(this.commands) // @ts-ignore .filter(({ err }) => { return err && err.message === errToMatch.message && err.message !== undefined }) diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 31ccfc959503..9339d036f951 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -4,7 +4,8 @@ import appState, { AppState } from './app-state' import runnablesStore, { RunnablesStore, RootRunnable, LogProps } from '../runnables/runnables-store' import statsStore, { StatsStore } from '../header/stats-store' import scroller, { Scroller } from './scroller' -import TestModel, { UpdatableTestProps, UpdateTestCallback, TestProps } from '../test/test-model' +import { UpdatableTestProps, UpdateTestCallback, TestProps } from '../test/test-model' +import Err from '../errors/err-model' import type { ReporterStartInfo, ReporterRunState } from '@packages/types' @@ -151,13 +152,11 @@ const events: Events = { runner.emit('runner:console:log', testId, logId) }) - localBus.on('show:error', (test: TestModel) => { - const command = test.err.isCommandErr ? test.commandMatchingErr() : null - + localBus.on('show:error', ({ err, testId, commandId }: { err: Err, testId?: string, commandId?: number }) => { runner.emit('runner:console:error', { - err: test.err, - testId: command?.testId, - logId: command?.id, + err, + testId, + logId: commandId, }) }) diff --git a/packages/reporter/src/lib/tag.scss b/packages/reporter/src/lib/tag.scss index 7105b0af79e7..0951e7f94896 100644 --- a/packages/reporter/src/lib/tag.scss +++ b/packages/reporter/src/lib/tag.scss @@ -6,7 +6,7 @@ font-size: 12px; font-style: normal; font-weight: 500; - line-height: initial; + line-height: 18px; height: 18px; max-width: 200px; overflow: hidden; diff --git a/packages/reporter/src/lib/variables.scss b/packages/reporter/src/lib/variables.scss index 6201b66f5df2..5dc085420b5d 100644 --- a/packages/reporter/src/lib/variables.scss +++ b/packages/reporter/src/lib/variables.scss @@ -108,10 +108,11 @@ $yellow-medium: $orange-800; $link-text: $indigo-600; -$err-background: #2F2434; +$err-background: #2C2036; +$err-border: 2px solid $red-300; $err-code-background: rgba($red-400, 0.18); $err-code-text: $red-300; -$err-header-background: #3D2839; +$err-header-background: #3A243B; $err-header-text: $red-300; $err-text: $red-400; @@ -127,5 +128,5 @@ $reporter-contents-min-width: 170px; $font-sans: 'Fira Mono', 'Helvetica Neue', 'Arial', sans-serif; $open-sans: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; -$monospace: Consolas, Monaco, 'Andale Mono', monospace; +$monospace: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace; $font-system: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; diff --git a/packages/runner/injection/cross-origin.js b/packages/runner/injection/cross-origin.js index fe8c062133cf..297402360c32 100644 --- a/packages/runner/injection/cross-origin.js +++ b/packages/runner/injection/cross-origin.js @@ -13,6 +13,8 @@ import { createTimers } from './timers' import { patchDocumentCookie } from './patches/cookies' import { patchElementIntegrity } from './patches/setAttribute' +import { patchFetch } from './patches/fetch' +import { patchXmlHttpRequest } from './patches/xmlHttpRequest' const findCypress = () => { for (let index = 0; index < window.parent.frames.length; index++) { @@ -50,28 +52,23 @@ window.addEventListener('beforeunload', () => { // This error could also be handled by creating and attaching a spec bridge and re-throwing the error. // If this approach proves to be an issue we could try the new solution. const handleErrorEvent = (event) => { - if (window.Cypress) { - // A spec bridge has attached so we don't need to forward errors to top anymore. - window.removeEventListener('error', handleErrorEvent) - } else { - const { error } = event - const data = { href: window.location.href } - - if (error && error.stack && error.message) { - data.message = error.message - data.stack = error.stack - } else { - data.message = error - } + const { error } = event + const data = { href: window.location.href } - window.top.postMessage({ event: 'cross:origin:aut:throw:error', data }, '*') + if (error && error.stack && error.message) { + data.message = error.message + data.stack = error.stack + } else { + data.message = error } + + window.top.postMessage({ event: 'cross:origin:aut:throw:error', data }, '*') } window.addEventListener('error', handleErrorEvent) // Apply Patches -patchDocumentCookie(window) +const documentCookiePatch = patchDocumentCookie(cypressConfig.simulatedCookies) // return null to trick contentWindow into thinking // its not been iFramed if modifyObstructiveCode is true @@ -92,13 +89,32 @@ const timers = createTimers() timers.wrap() -const Cypress = findCypress() - // Attach these to window so cypress can call them when it attaches. -window.cypressTimersReset = timers.reset -window.cypressTimersPause = timers.pause +// Patched to track credentials use. +patchFetch(window) +patchXmlHttpRequest(window) + +// Add a function to window for the spec bridge to call after it has attached. +window.__attachToCypress = (Cypress) => { + // A spec bridge has attached so we don't need to forward errors to top anymore. + window.removeEventListener('error', handleErrorEvent) + + documentCookiePatch.onCypress(Cypress) + + Cypress.removeAllListeners('app:timers:reset') + Cypress.removeAllListeners('app:timers:pause') + + Cypress.on('app:timers:reset', timers.reset) + Cypress.on('app:timers:pause', timers.pause) + + // This function will self destruct + delete window.__attachToCypress +} + +const Cypress = findCypress() // Check for cy too to prevent a race condition for attaching. if (Cypress && Cypress.cy) { + window.__attachToCypress(Cypress) Cypress.action('app:window:before:load', window) } diff --git a/packages/runner/injection/patches/cookies.js b/packages/runner/injection/patches/cookies.js deleted file mode 100644 index 2ef82a88c165..000000000000 --- a/packages/runner/injection/patches/cookies.js +++ /dev/null @@ -1,125 +0,0 @@ -import { Cookie } from 'tough-cookie' - -// document.cookie monkey-patching -// ------------------------------- -// We monkey-patch document.cookie when in a cross-origin injection, because -// document.cookie runs into cross-origin restrictions when the AUT is on -// a different origin than top. The goal is to make it act like it would -// if the user's app was run in top. -// -// The general strategy is: -// - Keep the document.cookie value (`documentCookieValue`) available so -// the document.cookie getter can synchronously return it. -// - Optimistically update that value when document.cookie is set, so that -// subsequent synchronous calls to get the value will work. -// - On an interval, get the browser's cookies for the given domain, so that -// updates to the cookie jar (via http requests, cy.setCookie, etc) are -// reflected in the document.cookie value. -export const patchDocumentCookie = (win) => { - const getCookiesFromCypress = () => { - return new Promise((resolve, reject) => { - const handler = (event) => { - if (event.data.event === 'cross:origin:aut:get:cookie') { - window.removeEventListener('message', handler) - resolve(event.data.cookies) - } - } - - setTimeout(() => { - window.removeEventListener('message', handler) - reject() - }, 1000) - - window.addEventListener('message', handler) - - window.top.postMessage({ event: 'cross:origin:aut:get:cookie', data: { href: window.location.href } }, '*') - }) - } - - // The interval value is arbitrary; it shouldn't be too often, but needs to - // be fairly frequent so that the local value is kept as up-to-date as - // possible. It's possible there could be a race condition where - // document.cookie returns an out-of-date value, but there's not really a - // way around that since it's a synchronous API and we can only get the - // browser's true cookie values asynchronously. - const syncCookieValues = () => { - return setInterval(async () => { - try { - // If Cypress is defined on the window, that means we have a spec bridge and we should use that to set cookies. If not we have to delegate to the primary cypress instance. - const cookies = window.Cypress ? await window.Cypress.automation('get:cookies', { domain: window.Cypress.Location.create(win.location.href).domain }) : await getCookiesFromCypress() - - const cookiesString = (cookies || []).map((c) => `${c.name}=${c.value}`).join('; ') - - documentCookieValue = cookiesString - } catch (err) { - // unlikely there will be errors, but ignore them in any case, since - // they're not user-actionable - } - }, 250) - } - - let cookieSyncIntervalId = syncCookieValues() - const setAutomationCookie = (cookie) => { - // If Cypress is defined on the window, that means we have a spec bridge and we should use that to set cookies. If not we have to delegate to the primary cypress instance. - if (window.Cypress) { - const { superDomain } = window.Cypress.Location.create(win.location.href) - const automationCookie = window.Cypress.Cookies.toughCookieToAutomationCookie(window.Cypress.Cookies.parse(cookie), superDomain) - - window.Cypress.automation('set:cookie', automationCookie) - .then(() => { - // Resume syncing once we've gotten confirmation that cookies have been set. - cookieSyncIntervalId = syncCookieValues() - }) - .catch(() => { - // unlikely there will be errors, but ignore them in any case, since - // they're not user-actionable - }) - } else { - const handler = (event) => { - if (event.data.event === 'cross:origin:aut:set:cookie') { - window.removeEventListener('message', handler) - // Resume syncing once we've gotten confirmation that cookies have been set. - cookieSyncIntervalId = syncCookieValues() - } - } - - window.addEventListener('message', handler) - - window.top.postMessage({ event: 'cross:origin:aut:set:cookie', data: { cookie, href: window.location.href } }, '*') - } - } - let documentCookieValue = '' - - Object.defineProperty(win.document, 'cookie', { - get () { - return documentCookieValue - }, - - set (newValue) { - const cookie = Cookie.parse(newValue) - - // If cookie is undefined, it was invalid and couldn't be parsed - if (!cookie) return documentCookieValue - - const cookieString = `${cookie.key}=${cookie.value}` - - clearInterval(cookieSyncIntervalId) - - // New cookies get prepended to existing cookies - documentCookieValue = documentCookieValue.length - ? `${cookieString}; ${documentCookieValue}` - : cookieString - - setAutomationCookie(newValue) - - return documentCookieValue - }, - }) - - const onUnload = () => { - win.removeEventListener('unload', onUnload) - clearInterval(cookieSyncIntervalId) - } - - win.addEventListener('unload', onUnload) -} diff --git a/packages/runner/injection/patches/cookies.ts b/packages/runner/injection/patches/cookies.ts new file mode 100644 index 000000000000..83f720d21bb1 --- /dev/null +++ b/packages/runner/injection/patches/cookies.ts @@ -0,0 +1,127 @@ +import { + CookieJar, + toughCookieToAutomationCookie, + automationCookieToToughCookie, +} from '@packages/server/lib/util/cookies' +import { Cookie as ToughCookie } from 'tough-cookie' +import type { AutomationCookie } from '@packages/server/lib/automation/cookies' + +const parseDocumentCookieString = (documentCookieString: string): AutomationCookie[] => { + if (!documentCookieString || !documentCookieString.trim().length) return [] + + return documentCookieString.split(';').map((cookieString) => { + const [name, value] = cookieString.split('=') + + return { + name: name.trim(), + value: value.trim(), + domain: location.hostname, + expiry: null, + httpOnly: false, + maxAge: null, + path: null, + sameSite: 'lax', + secure: false, + } + }) +} + +const sendCookieToServer = (cookie: AutomationCookie) => { + window.top!.postMessage({ + event: 'cross:origin:aut:set:cookie', + data: { + cookie, + url: location.href, + // url will always match the cookie domain, so strict context tells + // tough-cookie to allow it to be set + sameSiteContext: 'strict', + }, + }, '*') +} + +// document.cookie monkey-patching +// ------------------------------- +// We monkey-patch document.cookie when in a cross-origin injection, because +// document.cookie runs into cross-origin restrictions when the AUT is on +// a different origin than top. The goal is to make it act like it would +// if the user's app was run in top. +export const patchDocumentCookie = (requestCookies: AutomationCookie[]) => { + const url = location.href + const domain = location.hostname + const cookieJar = new CookieJar() + const existingCookies = parseDocumentCookieString(document.cookie) + + const getDocumentCookieValue = () => { + return cookieJar.getCookies(url, undefined).map((cookie: ToughCookie) => { + return `${cookie.key}=${cookie.value}` + }).join('; ') + } + + const addCookies = (cookies: AutomationCookie[]) => { + cookies.forEach((cookie) => { + cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, undefined) + }) + } + + // requestCookies are ones included with the page request that's now being + // injected into. they're captured by the proxy and included statically in + // the injection so they can be added here and available before page load + addCookies(existingCookies.concat(requestCookies)) + + Object.defineProperty(window.document, 'cookie', { + get () { + return getDocumentCookieValue() + }, + + set (newValue: any) { + const stringValue = `${newValue}` + const parsedCookie = CookieJar.parse(stringValue) + + // if result is undefined, it was invalid and couldn't be parsed + if (!parsedCookie) return getDocumentCookieValue() + + // we should be able to pass in parsedCookie here instead of the string + // value, but tough-cookie doesn't recognize it using an instanceof + // check and throws an error. because we can't, we have to massage + // some of the properties below to be correct + const cookie = cookieJar.setCookie(stringValue, url, undefined)! + + cookie.sameSite = parsedCookie.sameSite + + if (!parsedCookie.path) { + cookie.path = '/' + } + + // send the cookie to the server so it can be set in the browser via + // automation and in our server-side cookie jar so it's available + // to subsequent injections + sendCookieToServer(toughCookieToAutomationCookie(cookie, domain)) + + return getDocumentCookieValue() + }, + }) + + const reset = () => { + cookieJar.removeAllCookies() + } + + const bindCypressListeners = (Cypress: Cypress.Cypress) => { + Cypress.on('test:before:run', reset) + + // the following listeners are called from Cypress cookie commands, so that + // the document.cookie value is updated optimistically + Cypress.on('set:cookie', (cookie: AutomationCookie) => { + cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, undefined) + }) + + Cypress.on('clear:cookie', (name: string) => { + cookieJar.removeCookie({ name, domain }) + }) + + Cypress.on('clear:cookies', reset) + } + + return { + onCypress: bindCypressListeners, + } +} diff --git a/packages/driver/src/cross-origin/patches/fetch.ts b/packages/runner/injection/patches/fetch.ts similarity index 71% rename from packages/driver/src/cross-origin/patches/fetch.ts rename to packages/runner/injection/patches/fetch.ts index 28596d35d420..fd7fbdf3e0ac 100644 --- a/packages/driver/src/cross-origin/patches/fetch.ts +++ b/packages/runner/injection/patches/fetch.ts @@ -1,16 +1,16 @@ -import { captureFullRequestUrl } from './utils' +import { captureFullRequestUrl, requestSentWithCredentials } from './utils' -export const patchFetch = (Cypress: Cypress.Cypress, window) => { +export const patchFetch = (window) => { // if fetch is available in the browser, or is polyfilled by whatwg fetch // intercept method calls and add cypress headers to determine cookie applications in the proxy // for simulated top. @see https://github.github.io/fetch/ for default options - if (!Cypress.config('experimentalSessionAndOrigin') || !window.fetch) { + if (!window.fetch) { return } const originalFetch = window.fetch - window.fetch = function (...args) { + window.fetch = async function (...args) { try { let url: string | undefined = undefined let credentials: string | undefined = undefined @@ -25,7 +25,7 @@ export const patchFetch = (Cypress: Cypress.Cypress, window) => { url = resource.toString() ;({ credentials } = args[1] || {}) - } else if (Cypress._.isString(resource)) { + } else if (typeof resource === 'string') { url = captureFullRequestUrl(resource, window) ;({ credentials } = args[1] || {}) @@ -34,15 +34,11 @@ export const patchFetch = (Cypress: Cypress.Cypress, window) => { credentials = credentials || 'same-origin' // if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies // if the option isn't set, we can imply the default as we know the resource type in the proxy - if (url) { - // @ts-expect-error - Cypress.backend('request:sent:with:credentials', { - // TODO: might need to go off more information here or at least make collisions less likely - url, - resourceType: 'fetch', - credentialStatus: credentials, - }) - } + await requestSentWithCredentials({ + url, + resourceType: 'fetch', + credentialStatus: credentials, + }) } finally { // if our internal logic errors for whatever reason, do NOT block the end user and continue the request return originalFetch.apply(this, args) diff --git a/packages/runner/injection/patches/utils/index.ts b/packages/runner/injection/patches/utils/index.ts new file mode 100644 index 000000000000..c9cc50942a0f --- /dev/null +++ b/packages/runner/injection/patches/utils/index.ts @@ -0,0 +1,82 @@ +export const captureFullRequestUrl = (relativeOrAbsoluteUrlString: string, window: Window) => { + // need to pass the window here by reference to generate the correct absolute URL if needed. Spec Bridge does NOT contain sub domain + let url + + try { + url = new URL(relativeOrAbsoluteUrlString).toString() + } catch (err1) { + try { + // likely a relative path, construct the full url + url = new URL(relativeOrAbsoluteUrlString, window.location.origin).toString() + } catch (err2) { + return undefined + } + } + + return url +} + +const CROSS_ORIGIN_PREFIX = 'cross:origin:' + +/** + * Sets up a promisified post message + * @param data - the data to send + * @param event - the name of the event to be promisified. + * @param timeout - in ms, if the promise does not complete during this timeout, fail the promise. + * @returns the data to send + */ +export const postMessagePromise = ({ event, data = {}, timeout }: {event: string, data: any, timeout: number}): Promise => { + return new Promise((resolve, reject) => { + const eventName = `${CROSS_ORIGIN_PREFIX}${event}` + let timeoutId + + const responseEvent = `${eventName}:${Date.now()}` + + const handler = (event) => { + if (event.data.event === responseEvent) { + window.removeEventListener('message', handler) + clearTimeout(timeoutId) + resolve(event.data.data) + } + } + + timeoutId = setTimeout(() => { + window.removeEventListener('message', handler) + reject(new Error(`${eventName} failed to receive a response from the primary cypress instance within ${timeout / 1000} second.`)) + }, timeout) + + window.addEventListener('message', handler) + + window.top?.postMessage({ + event: eventName, + data, + responseEvent, + }, '*') + }) +} + +/** + * Returns a promise from the backend request for the 'request:sent:with:credentials' event. + * @param args - an object containing a url, resourceType and Credential status. + * @returns A Promise or null depending on the url parameter. + */ +export const requestSentWithCredentials = (args: {url?: string, resourceType: 'xhr' | 'fetch', credentialStatus: string | boolean}): Promise | undefined => { + if (args.url) { + // If cypress is enabled on the window use that, otherwise use post message to call out to the primary cypress instance. + // cypress may be found on the window if this is either the primary cypress instance or if a spec bridge has already been created for this spec bridge. + if (window.Cypress) { + //@ts-expect-error + return Cypress.backend('request:sent:with:credentials', args) + } + + return postMessagePromise({ + event: 'backend:request', + data: { + args: ['request:sent:with:credentials', args], + }, + timeout: 2000, + }) + } + + return +} diff --git a/packages/driver/src/cross-origin/patches/xmlHttpRequest.ts b/packages/runner/injection/patches/xmlHttpRequest.ts similarity index 65% rename from packages/driver/src/cross-origin/patches/xmlHttpRequest.ts rename to packages/runner/injection/patches/xmlHttpRequest.ts index 92cb07b204b5..14a02a6f55d4 100644 --- a/packages/driver/src/cross-origin/patches/xmlHttpRequest.ts +++ b/packages/runner/injection/patches/xmlHttpRequest.ts @@ -1,13 +1,9 @@ -import { captureFullRequestUrl } from './utils' +import { captureFullRequestUrl, requestSentWithCredentials } from './utils' -export const patchXmlHttpRequest = (Cypress: Cypress.Cypress, window: Window) => { +export const patchXmlHttpRequest = (window: Window) => { // intercept method calls and add cypress headers to determine cookie applications in the proxy // for simulated top - if (!Cypress.config('experimentalSessionAndOrigin')) { - return - } - const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send @@ -21,19 +17,15 @@ export const patchXmlHttpRequest = (Cypress: Cypress.Cypress, window: Window) => } } - window.XMLHttpRequest.prototype.send = function (...args) { + window.XMLHttpRequest.prototype.send = async function (...args) { try { // if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies // if the option isn't set, we can imply the default as we know the resource type in the proxy - if (this._url) { - // @ts-expect-error - Cypress.backend('request:sent:with:credentials', { - // TODO: might need to go off more information here or at least make collisions less likely - url: this._url, - resourceType: 'xhr', - credentialStatus: this.withCredentials, - }) - } + await requestSentWithCredentials({ + url: this._url, + resourceType: 'xhr', + credentialStatus: this.withCredentials, + }) } finally { // if our internal logic errors for whatever reason, do NOT block the end user and continue the request return originalXmlHttpRequestSend.apply(this, args) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index e3308b88aa0e..b8c5cf2a04cd 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -15,6 +15,7 @@ import { fs } from '../util/fs' import { CdpAutomation, screencastOpts } from './cdp_automation' import * as protocol from './protocol' import utils from './utils' +import * as errors from '../errors' import type { Browser, BrowserInstance } from './types' import { BrowserCriClient } from './browser-cri-client' import type { CriClient } from './cri-client' @@ -561,6 +562,17 @@ export = { return args }, + /** + * Clear instance state for the chrome instance, this is normally called in on kill or on exit. + */ + clearInstanceState () { + debug('closing remote interface client') + + // Do nothing on failure here since we're shutting down anyway + browserCriClient?.close().catch() + browserCriClient = undefined + }, + async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { debug('connecting to new chrome tab in existing instance with url and debugging port', { url: options.url }) @@ -595,6 +607,18 @@ export = { async attachListeners (url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) { const browserCriClient = this._getBrowserCriClient() + // Handle chrome tab crashes. + pageCriClient.on('Inspector.targetCrashed', () => { + const err = errors.get('RENDERER_CRASHED') + + if (!options.onError) { + errors.log(err) + throw new Error('Missing onError in attachListeners') + } + + options.onError(err) + }) + if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') debug('attaching listeners to chrome %o', { url, options }) @@ -700,11 +724,7 @@ export = { launchedBrowser.browserCriClient = browserCriClient launchedBrowser.kill = (...args) => { - debug('closing remote interface client') - - // Do nothing on failure here since we're shutting down anyway - browserCriClient?.close().catch() - browserCriClient = undefined + this.clearInstanceState() debug('closing chrome') diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 9d420d92c5da..73fd63dce9b5 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -146,9 +146,10 @@ export = { onCrashed () { const err = errors.get('RENDERER_CRASHED') - errors.log(err) - - if (!options.onError) throw new Error('Missing onError in onCrashed') + if (!options.onError) { + errors.log(err) + throw new Error('Missing onError in onCrashed') + } options.onError(err) }, @@ -471,6 +472,11 @@ export = { }) }, + /** + * Clear instance state for the electron instance, this is normally called in on kill or on exit for electron there isn't state to clear. + */ + clearInstanceState () {}, + async connectToNewSpec (browser: Browser, options: ElectronOpts, automation: Automation) { if (!options.url) throw new Error('Missing url in connectToNewSpec') diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 6bbc42ef4088..0c844fcc1cd5 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -371,6 +371,17 @@ export function _createDetachedInstance (browserInstance: BrowserInstance, brows return detachedInstance } +/** +* Clear instance state for the chrome instance, this is normally called in on kill or on exit. +*/ +export function clearInstanceState () { + debug('closing remote interface client') + if (browserCriClient) { + browserCriClient.close().catch() + browserCriClient = undefined + } +} + export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { await firefoxUtil.connectToNewSpec(options, automation, browserCriClient) } @@ -552,13 +563,8 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc const originalBrowserKill = browserInstance.kill browserInstance.kill = (...args) => { - debug('closing remote interface client') - // Do nothing on failure here since we're shutting down anyway - if (browserCriClient) { - browserCriClient.close().catch() - browserCriClient = undefined - } + clearInstanceState() debug('closing firefox') diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index 441dde6a8814..427a187a9c3a 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import Bluebird from 'bluebird' import Debug from 'debug' import utils from './utils' +import * as errors from '../errors' import check from 'check-more-types' import { exec } from 'child_process' import util from 'util' @@ -155,13 +156,36 @@ export = { // TODO: normalizing opening and closing / exiting // so that there is a default for each browser but // enable the browser to configure the interface - instance.once('exit', () => { + instance.once('exit', async (code, signal) => { ctx.browser.setBrowserStatus('closed') // TODO: make this a required property if (!options.onBrowserClose) throw new Error('onBrowserClose did not exist in interactive mode') + const browserDisplayName = instance?.browser?.displayName || 'unknown' + options.onBrowserClose() + browserLauncher.clearInstanceState() instance = null + + // We are being very narrow on when to restart the browser here. The only case we can reliably test the 'SIGTRAP' signal. + // We want to avoid adding signals in here that may intentionally be caused by a user. + // For example exiting firefox through either force quitting or quitting via cypress will fire a 'SIGTERM' event which + // would result in constantly relaunching the browser when the user actively wants to quit. + // On windows the crash produces 2147483651 as an exit code. We should add to the list of crashes we handle as we see them. + // In the future we may consider delegating to the browsers to determine if an exit is a crash since it might be different + // depending on what browser has crashed. + if (code === null && ['SIGTRAP', 'SIGABRT'].includes(signal) || code === 2147483651 && signal === null) { + const err = errors.get('BROWSER_CRASHED', browserDisplayName, code, signal) + + if (!options.onError) { + errors.log(err) + throw new Error('Missing onError in attachListeners') + } + + await options.onError(err) + + await options.relaunchBrowser!() + } }) // TODO: instead of waiting an arbitrary diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index 08dafccf42a3..f8e2b99e2a03 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -35,4 +35,8 @@ export type BrowserLauncher = { * Used in Cypress-in-Cypress tests to connect to the existing browser instance. */ connectToExisting: (browser: Browser, options: BrowserLaunchOpts, automation: Automation) => void | Promise + /** + * Used to clear instance state after the browser has been exited. + */ + clearInstanceState: () => void } diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index a7b1a80da3a5..493cd6dc9b1d 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -25,6 +25,13 @@ export async function connectToNewSpec (browser: Browser, options: BrowserNewTab }) } +/** + * Clear instance state for the webkit instance, this is normally called in on kill or on exit. + */ +export function clearInstanceState () { + wkAutomation = undefined +} + export function connectToExisting () { throw new Error('Cypress-in-Cypress is not supported for WebKit.') } @@ -120,7 +127,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc async kill () { debug('closing pwBrowser') await pwBrowser.close() - wkAutomation = undefined + clearInstanceState() } /** diff --git a/packages/server/lib/cloud/api.ts b/packages/server/lib/cloud/api.ts index 1b5ea61eead3..d7514ab3367a 100644 --- a/packages/server/lib/cloud/api.ts +++ b/packages/server/lib/cloud/api.ts @@ -58,7 +58,6 @@ const rp = request.defaults((params, callback) => { proxy: null, gzip: true, cacheable: false, - rejectUnauthorized: true, }) const headers = params.headers != null ? params.headers : (params.headers = {}) diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index b440a03a031f..f90790cb3093 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -186,6 +186,8 @@ export class OpenProject { return await browsers.connectToNewSpec(browser, { onInitializeNewBrowserTab, ...options }, automation) } + options.relaunchBrowser = this.relaunchBrowser + return await browsers.open(browser, options, automation, this._ctx) } diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index aa1b453c066f..291b0d775a82 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -103,7 +103,9 @@ export class ProjectBase extends EE { this.options = { report: false, onFocusTests () {}, - onError () {}, + onError (error) { + errors.log(error) + }, onWarning: this.ctx.onWarning, ...options, } @@ -162,7 +164,6 @@ export class ProjectBase extends EE { const [port, warning] = await this._server.open(cfg, { getCurrentBrowser: () => this.browser, - getAutomation: () => this.automation, getSpec: () => this.spec, exit: this.options.args?.exit, onError: this.options.onError, diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 527a23d18996..1b67c70b06ee 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -33,7 +33,6 @@ import type { FoundSpec } from '@packages/types' import type { Server as WebSocketServer } from 'ws' import { RemoteStates } from './remote_states' import { cookieJar } from './util/cookies' -import type { Automation } from './automation/automation' import type { AutomationCookie } from './automation/cookies' import { resourceTypeAndCredentialManager, ResourceTypeAndCredentialManager } from './util/resourceTypeAndCredentialManager' @@ -103,7 +102,6 @@ export interface OpenServerOptions { onWarning: any exit?: boolean getCurrentBrowser: () => Browser - getAutomation: () => Automation getSpec: () => FoundSpec | null shouldCorrelatePreRequests: () => boolean } @@ -178,12 +176,12 @@ export abstract class ServerBase { } setupCrossOriginRequestHandling () { - this._eventBus.on('cross:origin:automation:cookies', (cookies: AutomationCookie[]) => { - this.socket.localBus.once('cross:origin:automation:cookies:received', () => { - this._eventBus.emit('cross:origin:automation:cookies:received') + this._eventBus.on('cross:origin:cookies', (cookies: AutomationCookie[]) => { + this.socket.localBus.once('cross:origin:cookies:received', () => { + this._eventBus.emit('cross:origin:cookies:received') }) - this.socket.toDriver('cross:origin:automation:cookies', cookies) + this.socket.toDriver('cross:origin:cookies', cookies) }) this.socket.localBus.on('request:sent:with:credentials', this.resourceTypeAndCredentialManager.set) @@ -197,7 +195,6 @@ export abstract class ServerBase { open (config: Cfg, { getSpec, - getAutomation, getCurrentBrowser, onError, onWarning, @@ -225,7 +222,7 @@ export abstract class ServerBase { clientCertificates.loadClientCertificateConfig(config) this.createNetworkProxy({ - config, getAutomation, + config, remoteStates: this._remoteStates, resourceTypeAndCredentialManager: this.resourceTypeAndCredentialManager, shouldCorrelatePreRequests, @@ -321,7 +318,7 @@ export abstract class ServerBase { return e } - createNetworkProxy ({ config, getAutomation, remoteStates, resourceTypeAndCredentialManager, shouldCorrelatePreRequests }) { + createNetworkProxy ({ config, remoteStates, resourceTypeAndCredentialManager, shouldCorrelatePreRequests }) { const getFileServerToken = () => { return this._fileServer.token } @@ -331,7 +328,6 @@ export abstract class ServerBase { this._networkProxy = new NetworkProxy({ config, shouldCorrelatePreRequests, - getAutomation, remoteStates, getFileServerToken, getCookieJar: () => cookieJar, diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 590b309cf4e1..157007ae2815 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -20,13 +20,14 @@ import { openFile, OpenFileDetails } from './util/file-opener' import open from './util/open' import type { DestroyableHttpServer } from './util/server_destroy' import * as session from './session' -import { cookieJar } from './util/cookies' +import { AutomationCookie, cookieJar, SameSiteContext, automationCookieToToughCookie } from './util/cookies' import runEvents from './plugins/run_events' // eslint-disable-next-line no-duplicate-imports import type { Socket } from '@packages/socket' import type { RunState, CachedTestState } from '@packages/types' +import { cors } from '@packages/network' type StartListeningCallbacks = { onSocketConnection: (socket: any) => void @@ -48,8 +49,6 @@ const reporterEvents = [ // "go:to:file" 'runner:restart', 'runner:abort', - 'runner:console:log', - 'runner:console:error', 'runner:show:snapshot', 'runner:hide:snapshot', 'reporter:restarted', @@ -394,6 +393,12 @@ export class SocketBase { }) }) + const setCrossOriginCookie = ({ cookie, url, sameSiteContext }: { cookie: AutomationCookie, url: string, sameSiteContext: SameSiteContext }) => { + const domain = cors.getOrigin(url) + + cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, sameSiteContext) + } + socket.on('backend:request', (eventName: string, ...args) => { // cb is always the last argument const cb = args.pop() @@ -461,8 +466,10 @@ export class SocketBase { return options.getRenderedHTMLOrigins() case 'reset:rendered:html:origins': return resetRenderedHTMLOrigins() - case 'cross:origin:automation:cookies:received': - return this.localBus.emit('cross:origin:automation:cookies:received') + case 'cross:origin:cookies:received': + return this.localBus.emit('cross:origin:cookies:received') + case 'cross:origin:set:cookie': + return setCrossOriginCookie(args[0]) case 'request:sent:with:credentials': return this.localBus.emit('request:sent:with:credentials', args[0]) default: diff --git a/packages/server/lib/util/cookies.ts b/packages/server/lib/util/cookies.ts index 0dce02e47838..650c8c7b9277 100644 --- a/packages/server/lib/util/cookies.ts +++ b/packages/server/lib/util/cookies.ts @@ -9,6 +9,8 @@ interface CookieData { path?: string } +export type SameSiteContext = 'strict' | 'lax' | 'none' | undefined + export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain: string): AutomationCookie => { const expiry = toughCookie.expiryTime() @@ -25,6 +27,20 @@ export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain } } +export const automationCookieToToughCookie = (automationCookie: AutomationCookie, defaultDomain: string): Cookie => { + return new Cookie({ + domain: automationCookie.domain || defaultDomain, + expires: automationCookie.expiry != null && isFinite(automationCookie.expiry) ? new Date(automationCookie.expiry * 1000) : undefined, + httpOnly: automationCookie.httpOnly, + maxAge: automationCookie.maxAge || 'Infinity', + key: automationCookie.name, + path: automationCookie.path || undefined, + sameSite: automationCookie.sameSite === 'no_restriction' ? 'none' : automationCookie.sameSite, + secure: automationCookie.secure, + value: automationCookie.value, + }) +} + const sameSiteNoneRe = /; +samesite=(?:'none'|"none"|none)/i /** @@ -57,7 +73,7 @@ export class CookieJar { this._cookieJar = new ToughCookieJar(undefined, { allowSpecialUseDomain: true }) } - getCookies (url, sameSiteContext) { + getCookies (url: string, sameSiteContext: SameSiteContext = undefined) { // @ts-ignore return this._cookieJar.getCookiesSync(url, { sameSiteContext }) } @@ -75,9 +91,9 @@ export class CookieJar { return cookies } - setCookie (cookie: string | Cookie, url: string, sameSiteContext: 'strict' | 'lax' | 'none') { + setCookie (cookie: string | Cookie, url: string, sameSiteContext: SameSiteContext) { // @ts-ignore - this._cookieJar.setCookieSync(cookie, url, { sameSiteContext }) + return this._cookieJar.setCookieSync(cookie, url, { sameSiteContext }) } removeCookie (cookieData: CookieData) { diff --git a/packages/server/test/unit/cloud/api_spec.js b/packages/server/test/unit/cloud/api_spec.js index 360432802ea4..3beec2c3c6d3 100644 --- a/packages/server/test/unit/cloud/api_spec.js +++ b/packages/server/test/unit/cloud/api_spec.js @@ -69,20 +69,6 @@ describe('lib/cloud/api', () => { }) }) - it('sets rejectUnauthorized on the request', () => { - nock.cleanAll() - - return api.ping() - .thenThrow() - .catch(() => { - expect(agent.addRequest).to.be.calledOnce - - expect(agent.addRequest).to.be.calledWithMatch(sinon.match.any, { - rejectUnauthorized: true, - }) - }) - }) - context('with a proxy defined', () => { beforeEach(function () { nock.cleanAll() diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index 2fa7228e9892..dd381edb9a4d 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -20,6 +20,7 @@ export type BrowserLaunchOpts = { isTextTerminal: boolean onBrowserClose?: (...args: unknown[]) => void onBrowserOpen?: (...args: unknown[]) => void + relaunchBrowser?: () => Promise } & Partial // TODO: remove the `Partial` here by making it impossible for openProject.launch to be called w/o OpenProjectLaunchOpts & Pick diff --git a/system-tests/__snapshots__/browser_crash_handling_spec.js b/system-tests/__snapshots__/browser_crash_handling_spec.js new file mode 100644 index 000000000000..606e1ee0d4f5 --- /dev/null +++ b/system-tests/__snapshots__/browser_crash_handling_spec.js @@ -0,0 +1,331 @@ +exports['Browser Crash Handling / when the tab crashes in chrome / fails'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 2 found (chrome_tab_crash.cy.js, simple.cy.js) │ + │ Searched: cypress/e2e/chrome_tab_crash.cy.js, cypress/e2e/simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: chrome_tab_crash.cy.js (1 of 2) + + + +We detected that the Chromium Renderer process just crashed. + +This is the equivalent to seeing the 'sad face' when Chrome dies. + +This can happen for a number of different reasons: + +- You wrote an endless loop and you must fix your own code +- You are running Docker (there is an easy fix for this: see link below) +- You are running lots of tests on a memory intense application +- You are running in a memory starved VM environment +- There are problems with your GPU / GPU drivers +- There are browser bugs in Chromium + +You can learn more including how to fix Docker here: + +https://on.cypress.io/renderer-process-crashed + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 0 │ + │ Passing: 0 │ + │ Failing: 1 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: chrome_tab_crash.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/chrome_tab_crash.cy.js.mp4 (X second) + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: simple.cy.js (2 of 2) + + + ✓ is true + + 1 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 1 │ + │ Passing: 1 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/simple.cy.js.mp4 (X second) + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✖ chrome_tab_crash.cy.js XX:XX - - 1 - - │ + ├────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ ✔ simple.cy.js XX:XX 1 1 - - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✖ 1 of 2 failed (50%) XX:XX 1 1 1 - - + + +` + +exports['Browser Crash Handling / when the tab crashes in electron / fails'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 2 found (chrome_tab_crash.cy.js, simple.cy.js) │ + │ Searched: cypress/e2e/chrome_tab_crash.cy.js, cypress/e2e/simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: chrome_tab_crash.cy.js (1 of 2) + + + +We detected that the Chromium Renderer process just crashed. + +This is the equivalent to seeing the 'sad face' when Chrome dies. + +This can happen for a number of different reasons: + +- You wrote an endless loop and you must fix your own code +- You are running Docker (there is an easy fix for this: see link below) +- You are running lots of tests on a memory intense application +- You are running in a memory starved VM environment +- There are problems with your GPU / GPU drivers +- There are browser bugs in Chromium + +You can learn more including how to fix Docker here: + +https://on.cypress.io/renderer-process-crashed + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 0 │ + │ Passing: 0 │ + │ Failing: 1 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: chrome_tab_crash.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/chrome_tab_crash.cy.js.mp4 (X second) + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: simple.cy.js (2 of 2) + + + ✓ is true + + 1 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 1 │ + │ Passing: 1 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/simple.cy.js.mp4 (X second) + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✖ chrome_tab_crash.cy.js XX:XX - - 1 - - │ + ├────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ ✔ simple.cy.js XX:XX 1 1 - - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✖ 1 of 2 failed (50%) XX:XX 1 1 1 - - + + +` + +exports['Browser Crash Handling / when the browser process crashes in chrome / fails'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 2 found (chrome_process_crash.cy.js, simple.cy.js) │ + │ Searched: cypress/e2e/chrome_process_crash.cy.js, cypress/e2e/simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: chrome_process_crash.cy.js (1 of 2) + + + +We detected that the Chrome process just crashed with code 'null' and signal 'SIGTRAP'. + +We have failed the current test and have relaunched Chrome. + +This can happen for many different reasons: + +- You wrote an endless loop and you must fix your own code +- You are running lots of tests on a memory intense application +- You are running in a memory starved VM environment +- There are problems with your GPU / GPU drivers +- There are browser bugs + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 0 │ + │ Passing: 0 │ + │ Failing: 1 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: chrome_process_crash.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/chrome_process_crash.cy.js.mp4 (X second) + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: simple.cy.js (2 of 2) + + + ✓ is true + + 1 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 1 │ + │ Passing: 1 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/simple.cy.js.mp4 (X second) + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✖ chrome_process_crash.cy.js XX:XX - - 1 - - │ + ├────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ ✔ simple.cy.js XX:XX 1 1 - - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✖ 1 of 2 failed (50%) XX:XX 1 1 1 - - + + +` + +exports['Browser Crash Handling / when the browser process crashes in electron / fails'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 2 found (chrome_process_crash.cy.js, simple.cy.js) │ + │ Searched: cypress/e2e/chrome_process_crash.cy.js, cypress/e2e/simple.cy.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: chrome_process_crash.cy.js (1 of 2) + + + +` diff --git a/system-tests/__snapshots__/testConfigOverrides_spec.ts.js b/system-tests/__snapshots__/testConfigOverrides_spec.ts.js index 0468a0556b3d..f1a830a41cac 100644 --- a/system-tests/__snapshots__/testConfigOverrides_spec.ts.js +++ b/system-tests/__snapshots__/testConfigOverrides_spec.ts.js @@ -1195,10 +1195,11 @@ exports['testConfigOverrides / successfully runs valid suite-level-only override (Run Starting) ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ - │ Cypress: 1.2.3 │ - │ Browser: FooBrowser 88 │ - │ Specs: 1 found (valid-suite-only.js) │ - │ Searched: cypress/e2e/testConfigOverrides/valid-suite-only.js │ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (valid-suite-only.js) │ + │ Searched: cypress/e2e/testConfigOverrides/valid-suite-only.js │ + │ Experiments: experimentalSessionAndOrigin=true │ └────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/system-tests/projects/e2e/cypress/e2e/chrome_process_crash.cy.js b/system-tests/projects/e2e/cypress/e2e/chrome_process_crash.cy.js new file mode 100644 index 000000000000..7c70a2e0a6c3 --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/chrome_process_crash.cy.js @@ -0,0 +1,4 @@ +it('crashes the chrome process', () => { + Cypress.automation('remote:debugger:protocol', { command: 'Browser.crash', params: {} }) + cy.visit('localhost') +}) diff --git a/system-tests/projects/e2e/cypress/e2e/chrome_tab_crash.cy.js b/system-tests/projects/e2e/cypress/e2e/chrome_tab_crash.cy.js new file mode 100644 index 000000000000..77fc85a8a850 --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/chrome_tab_crash.cy.js @@ -0,0 +1,4 @@ +it('crashes the chrome tab', () => { + Cypress.automation('remote:debugger:protocol', { command: 'Page.navigate', params: { url: 'chrome://crash', transitionType: 'typed' } }) + cy.visit('localhost') +}) diff --git a/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/invalid.js b/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/invalid.js index 8d71a9c07787..00048f9916d5 100644 --- a/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/invalid.js +++ b/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/invalid.js @@ -54,18 +54,18 @@ describe('throws error correctly when beforeEach hook', () => { }) }) -it('throws error when invalid test-level override', { testIsolation: 'legacy' }, () => { +it('throws error when invalid test-level override', { testIsolation: 'off' }, () => { shouldNotExecute() }) it('throws error when invalid config opt in Cypress.config() in test', () => { - Cypress.config({ testIsolation: 'legacy' }) + Cypress.config({ testIsolation: 'off' }) shouldNotExecute() }) describe('throws error when invalid config opt in Cypress.config() in before hook', () => { before(() => { - Cypress.config({ testIsolation: 'legacy' }) + Cypress.config({ testIsolation: 'off' }) }) it('4', () => { @@ -75,7 +75,7 @@ describe('throws error when invalid config opt in Cypress.config() in before hoo describe('throws error when invalid config opt in Cypress.config() in beforeEach hook', () => { beforeEach(() => { - Cypress.config({ testIsolation: 'legacy' }) + Cypress.config({ testIsolation: 'off' }) }) it('5', () => { @@ -85,7 +85,7 @@ describe('throws error when invalid config opt in Cypress.config() in beforeEach describe('throws error when invalid config opt in Cypress.config() in after hook', () => { after(() => { - Cypress.config({ testIsolation: 'legacy' }) + Cypress.config({ testIsolation: 'off' }) }) it('5', () => { @@ -95,7 +95,7 @@ describe('throws error when invalid config opt in Cypress.config() in after hook describe('throws error when invalid config opt in Cypress.config() in afterEach hook', () => { afterEach(() => { - Cypress.config({ testIsolation: 'legacy' }) + Cypress.config({ testIsolation: 'off' }) }) it('5', () => { diff --git a/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/valid-suite-only.js b/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/valid-suite-only.js index 48f3310abe60..8c01f5880935 100644 --- a/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/valid-suite-only.js +++ b/system-tests/projects/e2e/cypress/e2e/testConfigOverrides/valid-suite-only.js @@ -1,4 +1,4 @@ -describe('suite-level-only overrides run as expected', { testIsolation: 'legacy' }, () => { +describe('suite-level-only overrides run as expected', { testIsolation: 'off' }, () => { it('1st test passes', () => { cy.visit('https://example.cypress.io') }) @@ -13,7 +13,7 @@ describe('suite-level-only overrides run as expected', { testIsolation: 'legacy' }) describe('nested contexts ', () => { - describe('nested suite-level-only overrides run as expected', { testIsolation: 'legacy' }, () => { + describe('nested suite-level-only overrides run as expected', { testIsolation: 'off' }, () => { it('1st test passes', () => { cy.visit('https://example.cypress.io') }) diff --git a/system-tests/projects/e2e/cypress/e2e/web_security.cy.js b/system-tests/projects/e2e/cypress/e2e/web_security.cy.js index 8963bb0a7a86..06b8bf57c650 100644 --- a/system-tests/projects/e2e/cypress/e2e/web_security.cy.js +++ b/system-tests/projects/e2e/cypress/e2e/web_security.cy.js @@ -25,7 +25,7 @@ describe('web security', function () { .contains('success!', { timeout: 500 }) }) - it('finds the correct spec bridge even if a previous spec bridge host is a subset of the current host', () => { + it('finds the correct spec bridge even if a previous spec bridge host is a subset of the current host', { defaultCommandTimeout: 4000 }, () => { // Establish a spec bridge with a 'bar.com' host prior to loading 'foobar.com' if (Cypress.config('experimentalSessionAndOrigin')) { cy.origin('http://www.bar.com:4466', () => undefined) diff --git a/system-tests/projects/passthru-preprocessor/cypress/e2e/cross_origin.cy.js b/system-tests/projects/passthru-preprocessor/cypress/e2e/cross_origin.cy.js index 2f1c2cdba6cb..72db2d8819c8 100644 --- a/system-tests/projects/passthru-preprocessor/cypress/e2e/cross_origin.cy.js +++ b/system-tests/projects/passthru-preprocessor/cypress/e2e/cross_origin.cy.js @@ -3,6 +3,6 @@ it('uses cy.origin() dependency handling', () => { cy.get('a[data-cy="cross_origin_secondary_link"]').click() cy.origin('foobar.com:4466', () => { - Cypress.require('lodash') + require('lodash') }) }) diff --git a/system-tests/projects/session-and-origin-e2e-specs/cypress/e2e/session/restores_saved_session.cy.js b/system-tests/projects/session-and-origin-e2e-specs/cypress/e2e/session/restores_saved_session.cy.js index 0fa989acb786..08c4936b9651 100644 --- a/system-tests/projects/session-and-origin-e2e-specs/cypress/e2e/session/restores_saved_session.cy.js +++ b/system-tests/projects/session-and-origin-e2e-specs/cypress/e2e/session/restores_saved_session.cy.js @@ -24,26 +24,3 @@ it('t2', () => { cy.log('after') }) - -// https://github.com/cypress-io/cypress/issues/22381 -describe('test sessionid integrity is maintained', () => { - it('use same session 2x and 2nd does not provide setup', () => { - cy.session('session-2', setupFn) - cy.session('session-2') - }) - - it('restore prev session 2x and 2nd does not provide setup', () => { - cy.session('session-2', setupFn) - cy.session('session-2') - }) - - it('restore prev session without setup', () => { - cy.session('session-2') - }) - - it('fails when trying to use existing sessionid with diff args', () => { - cy.session('session-2', () => { - // something else - }) - }) -}) diff --git a/system-tests/test/browser_crash_handling_spec.js b/system-tests/test/browser_crash_handling_spec.js new file mode 100644 index 000000000000..5852c5fc4b14 --- /dev/null +++ b/system-tests/test/browser_crash_handling_spec.js @@ -0,0 +1,49 @@ +const systemTests = require('../lib/system-tests').default + +describe('Browser Crash Handling', () => { + systemTests.setup({ + settings: { + e2e: {}, + }, + }) + + // It should fail the chrome_tab_crash spec, but the simple spec should run and succeed + context('when the tab crashes in chrome', () => { + systemTests.it('fails', { + browser: 'chrome', + spec: 'chrome_tab_crash.cy.js,simple.cy.js', + snapshot: true, + expectedExitCode: 1, + }) + }) + + // It should fail the chrome_tab_crash spec, but the simple spec should run and succeed + context('when the tab crashes in electron', () => { + systemTests.it('fails', { + browser: 'electron', + spec: 'chrome_tab_crash.cy.js,simple.cy.js', + snapshot: true, + expectedExitCode: 1, + }) + }) + + // It should fail the chrome_tab_crash spec, but the simple spec should run and succeed + context('when the browser process crashes in chrome', () => { + systemTests.it('fails', { + browser: 'chrome', + spec: 'chrome_process_crash.cy.js,simple.cy.js', + snapshot: true, + expectedExitCode: 1, + }) + }) + + // If chrome crashes, all of cypress crashes when in electron + context('when the browser process crashes in electron', () => { + systemTests.it('fails', { + browser: 'electron', + spec: 'chrome_process_crash.cy.js,simple.cy.js', + snapshot: true, + expectedExitCode: 1, + }) + }) +}) diff --git a/system-tests/test/cy_origin_error_spec.ts b/system-tests/test/cy_origin_error_spec.ts index 7955c376d397..6a28a9e04b00 100644 --- a/system-tests/test/cy_origin_error_spec.ts +++ b/system-tests/test/cy_origin_error_spec.ts @@ -55,7 +55,7 @@ describe('e2e cy.origin errors', () => { async onRun (exec) { const res = await exec() - expect(res.stdout).to.contain('CypressError: Importing dependencies with `Cypress.require()` requires using the latest version of `@cypress/webpack-preprocessor`') + expect(res.stdout).to.contain('Using `require()` or `import()` to include dependencies requires using the latest version of `@cypress/webpack-preprocessor`') }, }) }) diff --git a/system-tests/test/testConfigOverrides_spec.ts b/system-tests/test/testConfigOverrides_spec.ts index f5a1f08876b1..b5bafc733c5f 100644 --- a/system-tests/test/testConfigOverrides_spec.ts +++ b/system-tests/test/testConfigOverrides_spec.ts @@ -16,6 +16,7 @@ describe('testConfigOverrides', () => { expectedExitCode: 0, browser: 'electron', config: { + experimentalSessionAndOrigin: true, video: false, }, })