diff --git a/.vscode/settings.json b/.vscode/settings.json index ae8c20a1d0d1..3f6006f21b08 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,4 +28,14 @@ }, ], "eslint.enable": true, + // this project does not use Prettier + // thus set all settings to disable accidentally running Prettier + "prettier.requireConfig": true, + "prettier.disableLanguages": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "json" + ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a8324b82304..2015908c150b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -366,6 +366,8 @@ npm run lint-changed-fix When committing files, we run a Git pre-commit hook to lint the staged JS files. See the [`lint-staged` project](https://github.com/okonet/lint-staged). If this command fails, you may need to run `npm run lint-changed-fix` and commit those changes. +We **DO NOT** use Prettier to format code. You can find [.prettierignore](.prettierignore) file that ignores all files in this repository. To ensure this file is loaded, please always open _the root repository folder_ in your text editor, otherwise your code formatter might execute, reformatting lots of source files. + ### Tests For most packages there are typically unit and some integration tests. diff --git a/packages/driver/README.md b/packages/driver/README.md index a4c98732d71b..eb01a68d9347 100644 --- a/packages/driver/README.md +++ b/packages/driver/README.md @@ -55,6 +55,28 @@ cd packages/driver npm start ``` +For working with a single spec file, use `testFiles` configuration option. For example, to only show the `e2e/focus_blur_spec.js` spec file when Cypress is opened use (path is relative to `test/cypress/integration` folder) + +```bash +npm run cypress:open -- --config testFiles=e2e/focus_blur_spec.js +``` + +Or to run that single spec headlessly + +```bash +npm run cypress:run -- --config testFiles=e2e/focus_blur_spec.js +``` + +Alternative: use `--spec`, but pass the path from the current folder, for example + +```bash +npm run cypress:run -- --spec test/cypress/integration/issues/1939_1940_2190_spec.js --browser chrome +``` + +If you want to run tests in Chrome and keep it open after the spec finishes, you can do + +```bash +npm run cypress:run -- --config testFiles=e2e/focus_blur_spec.js --browser chrome --no-exit If you would like to run a particular integration test, see the GUI and poke around during the test, you can an exclusive test like: ```bash diff --git a/packages/server/.gitignore b/packages/server/.gitignore index 47fb47fd690c..5e1717fe3e47 100644 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -1,3 +1,5 @@ -lib/util/ensure-url.js -lib/util/proxy.js +# we do not explicitly ignore JavaScript files in "lib/browsers" folder +# because when we add TS files we do not transpile them as a build step +# instead always use require hooks to transpile TS files on the fly + .http-mitm-proxy diff --git a/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js b/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js index 9b2d59df4a33..605b1a5e23e4 100644 --- a/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js +++ b/packages/server/__snapshots__/1_commands_outside_of_test_spec.coffee.js @@ -73,12 +73,6 @@ exports['e2e commands outside of test [chrome] fails on cy commands 1'] = ` Running: commands_outside_of_test_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - 1) An uncaught error was detected outside of a test @@ -123,7 +117,7 @@ We dynamically generated a new test to display this failure. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 1 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: commands_outside_of_test_spec.coffee │ └────────────────────────────────────────────────────┘ @@ -134,6 +128,12 @@ We dynamically generated a new test to display this failure. - /foo/bar/.projects/e2e/cypress/screenshots/commands_outside_of_test_spec.coffee/An uncaught error was detected outside of a test (failed).png (YYYYxZZZZ) + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) @@ -166,12 +166,6 @@ exports['e2e commands outside of test [chrome] fails on failing assertions 1'] = Running: assertions_failing_outside_of_test_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - 1) An uncaught error was detected outside of a test @@ -202,7 +196,7 @@ We dynamically generated a new test to display this failure. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 1 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: assertions_failing_outside_of_test_spec.coffee │ └──────────────────────────────────────────────────────────────┘ @@ -213,6 +207,12 @@ We dynamically generated a new test to display this failure. - /foo/bar/.projects/e2e/cypress/screenshots/assertions_failing_outside_of_test_spec.coffee/An uncaught error was detected outside of a test (failed).png (YYYYxZZZZ) + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/2_browser_path_spec.coffee.js b/packages/server/__snapshots__/2_browser_path_spec.coffee.js index e755aa8ccef9..d8bb1de7366c 100644 --- a/packages/server/__snapshots__/2_browser_path_spec.coffee.js +++ b/packages/server/__snapshots__/2_browser_path_spec.coffee.js @@ -16,12 +16,6 @@ exports['e2e launching browsers by path works with an installed browser path 1'] Running: simple_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - ✓ is true @@ -37,12 +31,18 @@ A video will not be recorded when using this browser. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: simple_spec.coffee │ └──────────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/2_cdp_spec.ts.js b/packages/server/__snapshots__/2_cdp_spec.ts.js new file mode 100644 index 000000000000..9d4d4ccb30c1 --- /dev/null +++ b/packages/server/__snapshots__/2_cdp_spec.ts.js @@ -0,0 +1,30 @@ +exports['e2e cdp fails when remote debugging port cannot be connected to 1'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (spec.ts) │ + │ Searched: cypress/integration/spec.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: spec.ts... (1 of 1) +Cypress failed to make a connection to the Chrome DevTools Protocol after retrying for 5 seconds. + +This usually indicates there was a problem opening the Chrome browser. + +The CDP port requested was 7777. + +Error details: + +Error: connect ECONNREFUSED 127.0.0.1:7777 + at stack trace line + + +` diff --git a/packages/server/__snapshots__/2_cookies_spec.coffee.js b/packages/server/__snapshots__/2_cookies_spec.coffee.js index 7c8873d24898..6a4546431d17 100644 --- a/packages/server/__snapshots__/2_cookies_spec.coffee.js +++ b/packages/server/__snapshots__/2_cookies_spec.coffee.js @@ -16,12 +16,6 @@ exports['e2e cookies passes in chrome 1'] = ` Running: cookies_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - cookies with whitelist @@ -49,12 +43,18 @@ A video will not be recorded when using this browser. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: cookies_spec.coffee │ └───────────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/3_plugins_spec.coffee.js b/packages/server/__snapshots__/3_plugins_spec.coffee.js index 170dc941d580..dc967c4f1bb1 100644 --- a/packages/server/__snapshots__/3_plugins_spec.coffee.js +++ b/packages/server/__snapshots__/3_plugins_spec.coffee.js @@ -197,12 +197,6 @@ exports['e2e plugins works with user extensions 1'] = ` Running: app_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - ✓ can inject text from an extension @@ -218,12 +212,18 @@ A video will not be recorded when using this browser. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: app_spec.coffee │ └───────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/plugin-extension/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/3_user_agent_spec.coffee.js b/packages/server/__snapshots__/3_user_agent_spec.coffee.js index 94e6b822c56a..683c1c390102 100644 --- a/packages/server/__snapshots__/3_user_agent_spec.coffee.js +++ b/packages/server/__snapshots__/3_user_agent_spec.coffee.js @@ -16,12 +16,6 @@ exports['e2e user agent passes on chrome 1'] = ` Running: user_agent_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - user agent ✓ is set on visits @@ -40,12 +34,18 @@ A video will not be recorded when using this browser. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: user_agent_spec.coffee │ └──────────────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/4_request_spec.coffee.js b/packages/server/__snapshots__/4_request_spec.coffee.js index d4938f054877..1c85b3a3854b 100644 --- a/packages/server/__snapshots__/4_request_spec.coffee.js +++ b/packages/server/__snapshots__/4_request_spec.coffee.js @@ -88,12 +88,6 @@ exports['e2e requests passes in chrome 1'] = ` Running: request_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - redirects + requests ✓ gets and sets cookies from cy.request @@ -122,12 +116,18 @@ A video will not be recorded when using this browser. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: request_spec.coffee │ └───────────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/5_stdout_spec.coffee.js b/packages/server/__snapshots__/5_stdout_spec.coffee.js index 40e9f3daf735..5e47b0976cd7 100644 --- a/packages/server/__snapshots__/5_stdout_spec.coffee.js +++ b/packages/server/__snapshots__/5_stdout_spec.coffee.js @@ -355,12 +355,6 @@ exports['e2e stdout logs that chrome cannot be recorded 1'] = ` Running: simple_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - ✓ is true @@ -376,12 +370,18 @@ A video will not be recorded when using this browser. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: simple_spec.coffee │ └──────────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/5_subdomain_spec.coffee.js b/packages/server/__snapshots__/5_subdomain_spec.coffee.js index d6b9e0fd261e..5818ed1b3a39 100644 --- a/packages/server/__snapshots__/5_subdomain_spec.coffee.js +++ b/packages/server/__snapshots__/5_subdomain_spec.coffee.js @@ -86,12 +86,6 @@ exports['e2e subdomain passes in chrome 1'] = ` Running: subdomain_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - subdomains ✓ can swap to help.foobar.com:2292 @@ -118,12 +112,18 @@ A video will not be recorded when using this browser. │ Pending: 2 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: subdomain_spec.coffee │ └─────────────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/6_web_security_spec.coffee.js b/packages/server/__snapshots__/6_web_security_spec.coffee.js index 14787f0f5404..5ae3d6225304 100644 --- a/packages/server/__snapshots__/6_web_security_spec.coffee.js +++ b/packages/server/__snapshots__/6_web_security_spec.coffee.js @@ -185,12 +185,6 @@ exports['e2e web security when disabled passes 1'] = ` Running: web_security_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - web security ✓ fails when clicking to another origin @@ -210,12 +204,18 @@ A video will not be recorded when using this browser. │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: false │ + │ Video: true │ │ Duration: X seconds │ │ Spec Ran: web_security_spec.coffee │ └────────────────────────────────────────┘ + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) + + ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/8_reporters_spec.coffee.js b/packages/server/__snapshots__/8_reporters_spec.coffee.js index 65b84dfe8b43..129f4bd9b4bd 100644 --- a/packages/server/__snapshots__/8_reporters_spec.coffee.js +++ b/packages/server/__snapshots__/8_reporters_spec.coffee.js @@ -698,6 +698,7 @@ Error: this reporter threw an error at stack trace line at stack trace line at stack trace line + at stack trace line Learn more at https://on.cypress.io/reporters diff --git a/packages/server/__snapshots__/cy_visit_performance_spec.js b/packages/server/__snapshots__/cy_visit_performance_spec.js index 9e26e951b923..0482df8f2f56 100644 --- a/packages/server/__snapshots__/cy_visit_performance_spec.js +++ b/packages/server/__snapshots__/cy_visit_performance_spec.js @@ -16,12 +16,6 @@ exports['cy.visit performance tests pass in chrome 1'] = ` Running: fast_visit_spec.coffee... (1 of 1) -Warning: Cypress can only record videos when using the built in 'electron' browser. - -You have set the browser to: 'chrome' - -A video will not be recorded when using this browser. - on localhost 95% of visits are faster than XX:XX, 80% are faster than XX:XX histogram line @@ -156,18 +150,12 @@ histogram line │ Pending: 0 │ │ Skipped: 0 │ │ Screenshots: 0 │ - │ Video: true │ + │ Video: false │ │ Duration: X seconds │ │ Spec Ran: fast_visit_spec.coffee │ └──────────────────────────────────────┘ - (Video) - - - Started processing: Compressing to 32 CRF - - Finished processing: /foo/bar/.projects/e2e/cypress/videos/abc123.mp4 (X seconds) - - ==================================================================================================== (Run Finished) diff --git a/packages/server/__snapshots__/protocol_spec.ts.js b/packages/server/__snapshots__/protocol_spec.ts.js new file mode 100644 index 000000000000..c970d204ca9f --- /dev/null +++ b/packages/server/__snapshots__/protocol_spec.ts.js @@ -0,0 +1,20 @@ +exports['lib/browsers/protocol ._getDelayMsForRetry retries as expected for up to 5 seconds 1'] = [ + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 500, + 500, + 500, + 500, + 500, + 500, + 500, + 500 +] diff --git a/packages/server/lib/browsers/chrome.coffee b/packages/server/lib/browsers/chrome.coffee index 22f227e548b2..f3f3e57f5cce 100644 --- a/packages/server/lib/browsers/chrome.coffee +++ b/packages/server/lib/browsers/chrome.coffee @@ -4,12 +4,16 @@ _ = require("lodash") os = require("os") path = require("path") Promise = require("bluebird") +la = require('lazy-ass') +check = require('check-more-types') extension = require("@packages/extension") debug = require("debug")("cypress:server:browsers") plugins = require("../plugins") fs = require("../util/fs") appData = require("../util/app_data") utils = require("./utils") +protocol = require("./protocol") +CriClient = require("./cri-client") LOAD_EXTENSION = "--load-extension=" CHROME_VERSIONS_WITH_BUGGY_ROOT_LAYER_SCROLLING = "66 67".split(" ") @@ -88,6 +92,11 @@ defaultArgs = [ "--use-mock-keychain" ] +getRemoteDebuggingPort = Promise.method () -> + if port = Number(process.env.CYPRESS_REMOTE_DEBUGGING_PORT) + return port + utils.getPort() + pluginsBeforeBrowserLaunch = (browser, args) -> ## bail if we're not registered to this event return args if not plugins.has("before:browser:launch") @@ -137,11 +146,67 @@ _disableRestorePagesPrompt = (userDir) -> fs.writeJson(prefsPath, preferences) .catch -> +## After the browser has been opened, we can connect to +## its remote interface via a websocket. +_connectToChromeRemoteInterface = (port) -> + la(check.userPort(port), "expected port number to connect CRI to", port) + + debug("connecting to Chrome remote interface at random port %d", port) + + protocol.getWsTargetFor(port) + .then (wsUrl) -> + debug("received wsUrl %s for port %d", wsUrl, port) + + CriClient.create(wsUrl) + +_maybeRecordVideo = (options) -> + return (client) -> + if not options.screencastFrame + debug("screencastFrame is false") + return client + + debug('starting screencast') + client.on('Page.screencastFrame', options.screencastFrame) + + client.send('Page.startScreencast', { + format: 'jpeg' + }) + .then -> + return client + +## a utility function that navigates to the given URL +## once Chrome remote interface client is passed to it. +_navigateUsingCRI = (url) -> + la(check.url(url), "missing url to navigate to", url) + + return (client) -> + la(client, "could not get CRI client") + debug("received CRI client") + debug('navigating to page %s', url) + + ## when opening the blank page and trying to navigate + ## the focus gets lost. Restore it and then navigate. + client.send("Page.bringToFront") + .then -> + client.send("Page.navigate", { url }) + module.exports = { + ## + ## tip: + ## by adding utility functions that start with "_" + ## as methods here we can easily stub them from our unit tests + ## + _normalizeArgExtensions _removeRootExtension + _connectToChromeRemoteInterface + + _maybeRecordVideo + + _navigateUsingCRI + _writeExtension: (browser, isTextTerminal, proxyUrl, socketIoRoute) -> ## get the string bytes for the final extension file extension.setHostAndPath(proxyUrl, socketIoRoute) @@ -183,7 +248,7 @@ module.exports = { ## https://github.com/cypress-io/cypress/issues/2223 { majorVersion } = options.browser if majorVersion in CHROME_VERSIONS_WITH_BUGGY_ROOT_LAYER_SCROLLING - args.push("--disable-blink-features=RootLayerScrolling") + args.push("--disable-blink-features=RootLayerScrolling") ## https://chromium.googlesource.com/chromium/src/+/da790f920bbc169a6805a4fb83b4c2ab09532d91 ## https://github.com/cypress-io/cypress/issues/1872 @@ -201,14 +266,18 @@ module.exports = { .try => args = @_getArgs(options) - Promise.all([ - ## ensure that we have a clean cache dir - ## before launching the browser every time - utils.ensureCleanCache(browser, isTextTerminal), - - pluginsBeforeBrowserLaunch(options.browser, args) - ]) - .spread (cacheDir, args) => + getRemoteDebuggingPort() + .then (port) -> + args.push("--remote-debugging-port=#{port}") + + Promise.all([ + ## ensure that we have a clean cache dir + ## before launching the browser every time + utils.ensureCleanCache(browser, isTextTerminal), + pluginsBeforeBrowserLaunch(options.browser, args), + port + ]) + .spread (cacheDir, args, port) => Promise.all([ @_writeExtension( browser, @@ -229,7 +298,38 @@ module.exports = { args.push("--user-data-dir=#{userDir}") args.push("--disk-cache-dir=#{cacheDir}") - debug("launch in chrome: %s, %s", url, args) - - utils.launch(browser, url, args) + debug("launching in chrome with debugging port", { url, args, port }) + + ## FIRST load the blank page + ## first allows us to connect the remote interface, + ## start video recording and then + ## we will load the actual page + utils.launch(browser, "about:blank", args) + .then (launchedBrowser) => + la(launchedBrowser, "did not get launched browser instance") + + ## SECOND connect to the Chrome remote interface + ## and when the connection is ready + ## navigate to the actual url + @_connectToChromeRemoteInterface(port) + .then (criClient) => + la(criClient, "expected Chrome remote interface reference", criClient) + + ## monkey-patch the .kill method to that the CDP connection is closed + originalBrowserKill = launchedBrowser.kill + + launchedBrowser.kill = (args...) => + debug("closing remote interface client") + + criClient.close() + .then => + debug("closing chrome") + originalBrowserKill.call(launchedBrowser, args...) + + return criClient + .then @_maybeRecordVideo(options) + .then @_navigateUsingCRI(url) + ## return the launched browser process + ## with additional method to close the remote connection + .return(launchedBrowser) } diff --git a/packages/server/lib/browsers/cri-client.ts b/packages/server/lib/browsers/cri-client.ts new file mode 100644 index 000000000000..a9a2635970df --- /dev/null +++ b/packages/server/lib/browsers/cri-client.ts @@ -0,0 +1,134 @@ +import debugModule from 'debug' +import _ from 'lodash' + +const chromeRemoteInterface = require('chrome-remote-interface') +const debugVerbose = debugModule('cypress-verbose:server:browsers:cri-client') +const debugVerboseSend = debugModule('cypress-verbose:server:browsers:cri-client:[-->]') +const debugVerboseReceive = debugModule('cypress-verbose:server:browsers:cri-client:[<--]') + +/** + * Url returned by the Chrome Remote Interface +*/ +type websocketUrl = string + +/** + * Enumerations to make programming CDP slightly simpler - provides + * IntelliSense whenever you use named types. + */ +namespace CRI { + export enum Command { + 'Page.bringToFront', + 'Page.navigate', + 'Page.startScreencast' + } + + export enum EventNames { + 'Page.screencastFrame' + } +} + +/** + * Wrapper for Chrome Remote Interface client. Only allows "send" method. + * @see https://github.com/cyrus-and/chrome-remote-interface#clientsendmethod-params-callback +*/ +interface CRIWrapper { + /** + * Sends a command to the Chrome remote interface. + * @example client.send('Page.navigate', { url }) + */ + send (command: CRI.Command, params: object):Promise + /** + * Exposes Chrome remote interface Page domain, + * buton only for certain actions that are hard to do using "send" + * + * @example client.Page.screencastFrame(cb) + */ + + /** + * Registers callback for particular event. + * @see https://github.com/cyrus-and/chrome-remote-interface#class-cdp + */ + on (eventName: CRI.EventNames, cb: Function): void + + /** + * Calls underlying remote interface client close + */ + close ():Promise +} + +const maybeDebugCdpMessages = (cri) => { + if (debugVerboseReceive.enabled) { + cri._ws.on('message', (data) => { + data = _ + .chain(JSON.parse(data)) + .tap((data) => { + const str = _.get(data, 'params.data') + + if (!_.isString(str)) { + return + } + + data.params.data = _.truncate(str, { + length: 100, + omission: `... [truncated string of total bytes: ${str.length}]`, + }) + + return data + }) + .value() + + debugVerboseReceive('received CDP message %o', data) + }) + + } + + if (debugVerboseSend.enabled) { + const send = cri._ws.send + + cri._ws.send = (data, callback) => { + debugVerboseSend('sending CDP command %o', JSON.parse(data)) + + return send.call(cri._ws, data, callback) + } + } +} + +/** + * Creates a wrapper for Chrome remote interface client + * that only allows to use low-level "send" method + * and not via domain objects and commands. + * + * @example create('ws://localhost:...').send('Page.bringToFront') + */ +export { chromeRemoteInterface } + +export const create = async (debuggerUrl: websocketUrl): Promise => { + const cri = await chromeRemoteInterface({ + target: debuggerUrl, + local: true, + }) + + maybeDebugCdpMessages(cri) + + /** + * Wrapper around Chrome remote interface client + * that logs every command sent. + */ + const client: CRIWrapper = { + send (command: CRI.Command, params: object):Promise { + return cri.send(command, params) + }, + + on (eventName: CRI.EventNames, cb: Function) { + debugVerbose('registering CDP on event %o', { eventName }) + + return cri.on(eventName, cb) + }, + + close ():Promise { + return cri.close() + }, + } + + return client +} diff --git a/packages/server/lib/browsers/index.coffee b/packages/server/lib/browsers/index.coffee index 9151a2039cca..9c6dead6b81c 100644 --- a/packages/server/lib/browsers/index.coffee +++ b/packages/server/lib/browsers/index.coffee @@ -5,6 +5,11 @@ debug = require("debug")("cypress:server:browsers") utils = require("./utils") errors = require("../errors") fs = require("../util/fs") +la = require("lazy-ass") +check = require("check-more-types") + +# returns true if the passed string is a known browser family name +isBrowserFamily = check.oneOf(["electron", "chrome"]) instance = null @@ -31,6 +36,9 @@ cleanup = -> instance = null getBrowserLauncherByFamily = (family) -> + if not isBrowserFamily(family) + debug("unknown browser family", family) + switch family when "electron" require("./electron") @@ -74,6 +82,8 @@ process.once "exit", kill module.exports = { ensureAndGetByNameOrPath + isBrowserFamily + removeOldProfiles: utils.removeOldProfiles get: utils.getBrowsers diff --git a/packages/server/lib/browsers/protocol.js b/packages/server/lib/browsers/protocol.js new file mode 100644 index 000000000000..08ced634e491 --- /dev/null +++ b/packages/server/lib/browsers/protocol.js @@ -0,0 +1,75 @@ +const _ = require('lodash') +const CRI = require('chrome-remote-interface') +const { connect } = require('@packages/network') +const errors = require('../errors') +const Promise = require('bluebird') +const la = require('lazy-ass') +const is = require('check-more-types') +const debug = require('debug')('cypress:server:protocol') + +function _getDelayMsForRetry (i) { + if (i < 10) { + return 100 + } + + if (i < 18) { + return 500 + } +} + +function connectAsync (opts) { + return Promise.fromCallback((cb) => { + connect.createRetryingSocket({ + getDelayMsForRetry: _getDelayMsForRetry, + ...opts, + }, cb) + }) + .catch((err) => { + errors.throw('CDP_COULD_NOT_CONNECT', opts.port, err) + }) +} + +/** + * Waits for the port to respond with connection to Chrome Remote Interface + * @param {number} port Port number to connect to + */ +const getWsTargetFor = (port) => { + debug('Getting WS connection to CRI on port %d', port) + la(is.port(port), 'expected port number', port) + + return connectAsync({ port }) + .tapCatch((err) => { + debug('failed to connect to CDP %o', { port, err }) + }) + .then(() => { + debug('CRI.List on port %d', port) + + // what happens if the next call throws an error? + // it seems to leave the browser instance open + return CRI.List({ port }) + }) + .then((targets) => { + debug('CRI List %o', { numTargets: targets.length, targets }) + // activate the first available id + // find the first target page that's a real tab + // and not the dev tools or background page. + // since we open a blank page first, it has a special url + const newTabTargetFields = { + type: 'page', + url: 'about:blank', + } + + const target = _.find(targets, newTabTargetFields) + + la(target, 'could not find CRI target') + + debug('found CRI target %o', target) + + return target.webSocketDebuggerUrl + }) +} + +module.exports = { + _getDelayMsForRetry, + getWsTargetFor, +} diff --git a/packages/server/lib/browsers/utils.coffee b/packages/server/lib/browsers/utils.coffee index fb0404c6874c..13115290987f 100644 --- a/packages/server/lib/browsers/utils.coffee +++ b/packages/server/lib/browsers/utils.coffee @@ -1,5 +1,6 @@ path = require("path") Promise = require("bluebird") +getPort = require("get-port") launcher = require("@packages/launcher") fs = require("../util/fs") appData = require("../util/app_data") @@ -65,6 +66,8 @@ removeOldProfiles = -> ]) module.exports = { + getPort + copyExtension getProfileDir diff --git a/packages/server/lib/errors.coffee b/packages/server/lib/errors.coffee index 53bc818a72fa..a91aa94a9950 100644 --- a/packages/server/lib/errors.coffee +++ b/packages/server/lib/errors.coffee @@ -842,6 +842,18 @@ getMsgByType = (type, arg1 = {}, arg2) -> Please do not modify CYPRESS_ENV value. """ + when "CDP_COULD_NOT_CONNECT" + """ + Cypress failed to make a connection to the Chrome DevTools Protocol after retrying for 5 seconds. + + This usually indicates there was a problem opening the Chrome browser. + + The CDP port requested was #{chalk.yellow(arg1)}. + + Error details: + + #{arg2.stack} + """ get = (type, arg1, arg2) -> msg = getMsgByType(type, arg1, arg2) diff --git a/packages/server/lib/modes/record.coffee b/packages/server/lib/modes/record.coffee index f616b996d7c9..03d5ccfb02e9 100644 --- a/packages/server/lib/modes/record.coffee +++ b/packages/server/lib/modes/record.coffee @@ -544,7 +544,9 @@ createRunAndRecordSpecs = (options = {}) -> if not resp ## if a forked run, can't record and can't be parallel ## because the necessary env variables aren't present - runAllSpecs({}, false) + runAllSpecs({ + parallel: false + }) else { runUrl, runId, machineId, groupId } = resp @@ -625,7 +627,12 @@ createRunAndRecordSpecs = (options = {}) -> instanceId }) - runAllSpecs({ beforeSpecRun, afterSpecRun, runUrl }) + runAllSpecs({ + runUrl, + parallel, + beforeSpecRun, + afterSpecRun, + }) module.exports = { createRun diff --git a/packages/server/lib/modes/run.js b/packages/server/lib/modes/run.js index b6a030e07a79..e6257b14d02e 100644 --- a/packages/server/lib/modes/run.js +++ b/packages/server/lib/modes/run.js @@ -1,5 +1,6 @@ /* eslint-disable no-console */ const _ = require('lodash') +const la = require('lazy-ass') const pkg = require('@packages/root') const path = require('path') const chalk = require('chalk') @@ -228,7 +229,7 @@ const collectTestResults = (obj = {}, estimated) => { } const renderSummaryTable = (runUrl) => { - return (function (results) { + return function (results) { const { runs } = results console.log('') @@ -309,7 +310,7 @@ const renderSummaryTable = (runUrl) => { console.log('') } } - }) + } } const iterateThroughSpecs = function (options = {}) { @@ -325,7 +326,8 @@ const iterateThroughSpecs = function (options = {}) { return beforeSpecRun(spec) .then(({ estimated }) => { return runEachSpec(spec, index, length, estimated) - }).tap((results) => { + }) + .tap((results) => { return afterSpecRun(spec, results, config) }) }) @@ -345,12 +347,18 @@ const iterateThroughSpecs = function (options = {}) { // the relative name spec = _.find(specs, { relative: spec }) - return runEachSpec(spec, claimedInstances - 1, totalInstances, estimated) + return runEachSpec( + spec, + claimedInstances - 1, + totalInstances, + estimated + ) .tap((results) => { runs.push(results) return afterSpecRun(spec, results, config) - }).then(() => { + }) + .then(() => { // recurse return parallelWithRecord(runs) }) @@ -383,19 +391,79 @@ const getProjectId = Promise.method((project, id) => { return id } - return project - .getProjectId() + return project.getProjectId() .catch(() => { // no id no problem return null }) }) -const reduceRuns = (runs, prop) => { - return _.reduce(runs, (memo, run) => { - return memo += _.get(run, prop) +const getDefaultBrowserOptsByFamily = (browser, project, writeVideoFrame) => { + la(browsers.isBrowserFamily(browser.family), 'invalid browser family in', browser) + + if (browser.family === 'electron') { + return getElectronProps(browser.isHeaded, project, writeVideoFrame) } - , 0) + + if (browser.family === 'chrome') { + return getChromeProps(browser.isHeaded, project, writeVideoFrame) + } + + return {} +} + +const getChromeProps = (isHeaded, project, writeVideoFrame) => { + const shouldWriteVideo = Boolean(writeVideoFrame) + + debug('setting Chrome properties %o', { isHeaded, shouldWriteVideo }) + + return _ + .chain({}) + .tap((props) => { + if (isHeaded && writeVideoFrame) { + props.screencastFrame = (e) => { + // https://chromedevtools.github.io/devtools-protocol/tot/Page#event-screencastFrame + writeVideoFrame(new Buffer(e.data, 'base64')) + } + } + }) + .value() +} + +const getElectronProps = (isHeaded, project, writeVideoFrame) => { + return _ + .chain({ + width: 1280, + height: 720, + show: isHeaded, + onCrashed () { + const err = errors.get('RENDERER_CRASHED') + + errors.log(err) + + return project.emit('exitEarlyWithErr', err.message) + }, + onNewWindow (e, url, frameName, disposition, options) { + // force new windows to automatically open with show: false + // this prevents window.open inside of javascript client code + // to cause a new BrowserWindow instance to open + // https://github.com/cypress-io/cypress/issues/123 + options.show = false + }, + }) + .tap((props) => { + if (writeVideoFrame) { + props.recordFrameRate = 20 + props.onPaint = (event, dirty, image) => { + return writeVideoFrame(image.toJPEG(100)) + } + } + }) + .value() +} + +const sumByProp = (runs, prop) => { + return _.sumBy(runs, prop) || 0 } const getRun = (run, prop) => { @@ -408,7 +476,7 @@ const writeOutput = (outputPath, results) => { return } - debug('saving output results as %s', outputPath) + debug('saving output results %o', { outputPath }) return fs.outputJsonAsync(outputPath, results) }) @@ -423,7 +491,8 @@ const openProjectCreate = (projectRoot, socketId, options) => { // putting our web client app in headless mode // - NO display server logs (via morgan) // - YES display reporter results (via mocha reporter) - return openProject.create(projectRoot, options, { + return openProject + .create(projectRoot, options, { socketId, morgan: false, report: true, @@ -458,8 +527,9 @@ const createAndOpenProject = function (socketId, options) { // open this project without // adding it to the global cache return openProjectCreate(projectRoot, socketId, options) - .call('getProject') - }).then((project) => { + }) + .call('getProject') + .then((project) => { return Promise.props({ project, config: project.getConfig(), @@ -476,9 +546,9 @@ const removeOldProfiles = () => { }) } -const trashAssets = function (config = {}) { +const trashAssets = Promise.method((config = {}) => { if (config.trashAssetsBeforeRuns !== true) { - return Promise.resolve() + return } return Promise.join( @@ -489,11 +559,19 @@ const trashAssets = function (config = {}) { // dont make trashing assets fail the build return errors.warning('CANNOT_TRASH_ASSETS', err.stack) }) -} +}) // if we've been told to record and we're not spawning a headed browser const browserCanBeRecorded = (browser) => { - return (browser.name === 'electron') && browser.isHeadless + if (browser.family === 'electron' && browser.isHeadless) { + return true + } + + if (browser.family === 'chrome' && browser.isHeaded) { + return true + } + + return false } const createVideoRecording = function (videoName) { @@ -535,7 +613,8 @@ const maybeStartVideoRecording = Promise.method(function (options = {}) { if (!browserCanBeRecorded(browser)) { console.log('') - if ((browser.name === 'electron') && browser.isHeaded) { + // TODO update error messages and included browser name and headed mode + if (browser.family === 'electron' && browser.isHeaded) { errors.warning('CANNOT_RECORD_VIDEO_HEADED') } else { errors.warning('CANNOT_RECORD_VIDEO_FOR_THIS_BROWSER', browser.name) @@ -583,32 +662,9 @@ module.exports = { maybeStartVideoRecording, - getElectronProps (isHeaded, project, writeVideoFrame) { - const electronProps = { - width: 1280, - height: 720, - show: isHeaded, - onCrashed () { - const err = errors.get('RENDERER_CRASHED') - - errors.log(err) + getChromeProps, - return project.emit('exitEarlyWithErr', err.message) - }, - onNewWindow (e, url, frameName, disposition, options) { - options.show = false - }, - } - - if (writeVideoFrame) { - electronProps.recordFrameRate = 20 - electronProps.onPaint = (event, dirty, image) => { - return writeVideoFrame(image.toJPEG(100)) - } - } - - return electronProps - }, + getElectronProps, displayResults (obj = {}, estimated) { const results = collectTestResults(obj, estimated) @@ -682,7 +738,7 @@ module.exports = { .then(() => { // dont process anything if videoCompress is off // or we've been told not to upload the video - if ((videoCompression === false) || (shouldUploadVideo === false)) { + if (videoCompression === false || shouldUploadVideo === false) { return } @@ -699,13 +755,13 @@ module.exports = { chalk.cyan(`Compressing to ${videoCompression} CRF`) ) - const started = new Date + const started = Date.now() let progress = Date.now() const throttle = env.get('VIDEO_COMPRESSION_THROTTLE') || human('10 seconds') const onProgress = function (float) { if (float === 1) { - const finished = new Date - started + const finished = Date.now() - started const dur = `(${humanTime.long(finished)})` console.log( @@ -717,7 +773,7 @@ module.exports = { return console.log('') } - if ((new Date - progress) > throttle) { + if (Date.now() - progress > throttle) { // bump up the progress so we dont // continuously get notifications progress += throttle @@ -730,31 +786,22 @@ module.exports = { // bar.tickTotal(float) return videoCapture.process(name, cname, videoCompression, onProgress) - }).catch({ recordingVideoFailed: true }, () => { - // dont do anything if this error occured because - // recording the video had already failed - - }).catch((err) => { + }) + .catch((err) => { // log that post processing was attempted // but failed and dont let this change the run exit code - return errors.warning('VIDEO_POST_PROCESSING_FAILED', err.stack) + errors.warning('VIDEO_POST_PROCESSING_FAILED', err.stack) }) }, launchBrowser (options = {}) { const { browser, spec, writeVideoFrame, project, screenshots, projectRoot } = options - const browserOpts = (() => { - if (browser.name === 'electron') { - return this.getElectronProps(browser.isHeaded, project, writeVideoFrame) - } - - return {} - })() + const browserOpts = getDefaultBrowserOptsByFamily(browser, project, writeVideoFrame) browserOpts.automationMiddleware = { onAfterResponse: (message, data, resp) => { - if ((message === 'take:screenshot') && resp) { + if (message === 'take:screenshot' && resp) { screenshots.push(this.screenshotMetadata(data, resp)) } @@ -789,8 +836,8 @@ module.exports = { suites: 0, skipped: 0, wallClockDuration: 0, - wallClockStartedAt: (new Date()).toJSON(), - wallClockEndedAt: (new Date()).toJSON(), + wallClockStartedAt: new Date().toJSON(), + wallClockEndedAt: new Date().toJSON(), }, } @@ -810,12 +857,11 @@ module.exports = { }, waitForBrowserToConnect (options = {}) { - let waitForBrowserToConnect const { project, socketId, timeout } = options let attempts = 0 - return (waitForBrowserToConnect = () => { + const wait = () => { return Promise.join( this.waitForSocketConnection(project, socketId), this.launchBrowser(options) @@ -836,7 +882,7 @@ module.exports = { errors.warning('TESTS_DID_NOT_START_RETRYING', word) - return waitForBrowserToConnect() + return wait() } err = errors.get('TESTS_DID_NOT_START_FAILED') @@ -845,7 +891,9 @@ module.exports = { return project.emit('exitEarlyWithErr', err.message) }) }) - })() + } + + return wait() }, waitForSocketConnection (project, id) { @@ -923,7 +971,7 @@ module.exports = { // we should upload the video if we upload on passes (by default) // or if we have any failures and have started the video - const suv = Boolean((videoUploadOnPasses === true) || (startedVideoCapture && hasFailingTests)) + const suv = Boolean(videoUploadOnPasses === true || (startedVideoCapture && hasFailingTests)) obj.shouldUploadVideo = suv @@ -932,11 +980,17 @@ module.exports = { // always close the browser now as opposed to letting // it exit naturally with the parent process due to // electron bug in windows - return openProject.closeBrowser() + return openProject + .closeBrowser() .then(() => { if (endVideoCapture) { - return this.postProcessRecording(endVideoCapture, videoName, compressedVideoName, videoCompression, suv) - .then(finish) + return this.postProcessRecording( + endVideoCapture, + videoName, + compressedVideoName, + videoCompression, + suv + ).then(finish) // TODO: add a catch here } @@ -960,7 +1014,7 @@ module.exports = { runSpecs (options = {}) { const { config, browser, sys, headed, outputPath, specs, specPattern, beforeSpecRun, afterSpecRun, runUrl, parallel, group } = options - const isHeadless = (browser.name === 'electron') && !headed + const isHeadless = browser.family === 'electron' && !headed browser.isHeadless = isHeadless browser.isHeaded = !isHeadless @@ -1015,21 +1069,20 @@ module.exports = { beforeSpecRun, }) .then((runs = []) => { - results.startedTestsAt = (getRun(_.first(runs), 'stats.wallClockStartedAt')) - results.endedTestsAt = (getRun(_.last(runs), 'stats.wallClockEndedAt')) - results.totalDuration = reduceRuns(runs, 'stats.wallClockDuration') - results.totalSuites = reduceRuns(runs, 'stats.suites') - results.totalTests = reduceRuns(runs, 'stats.tests') - results.totalPassed = reduceRuns(runs, 'stats.passes') - results.totalPending = reduceRuns(runs, 'stats.pending') - results.totalFailed = reduceRuns(runs, 'stats.failures') - results.totalSkipped = reduceRuns(runs, 'stats.skipped') + results.startedTestsAt = getRun(_.first(runs), 'stats.wallClockStartedAt') + results.endedTestsAt = getRun(_.last(runs), 'stats.wallClockEndedAt') + results.totalDuration = sumByProp(runs, 'stats.wallClockDuration') + results.totalSuites = sumByProp(runs, 'stats.suites') + results.totalTests = sumByProp(runs, 'stats.tests') + results.totalPassed = sumByProp(runs, 'stats.passes') + results.totalPending = sumByProp(runs, 'stats.pending') + results.totalFailed = sumByProp(runs, 'stats.failures') + results.totalSkipped = sumByProp(runs, 'stats.skipped') results.runs = runs debug('final results of all runs: %o', results) - return writeOutput(outputPath, results) - .return(results) + return writeOutput(outputPath, results).return(results) }) }, @@ -1089,7 +1142,8 @@ module.exports = { }, findSpecs (config, specPattern) { - return specsUtil.find(config, specPattern) + return specsUtil + .find(config, specPattern) .tap((specs = []) => { if (debug.enabled) { const names = _.map(specs, 'name') @@ -1161,14 +1215,14 @@ module.exports = { chromePolicyCheck.run(onWarning) } - const runAllSpecs = ({ beforeSpecRun, afterSpecRun, runUrl }, parallelOverride = parallel) => { + const runAllSpecs = ({ beforeSpecRun, afterSpecRun, runUrl, parallel }) => { return this.runSpecs({ beforeSpecRun, afterSpecRun, projectRoot, specPattern, socketId, - parallel: parallelOverride, + parallel, browser, project, runUrl, @@ -1207,8 +1261,9 @@ module.exports = { } // not recording, can't be parallel - return runAllSpecs({}, false) - + return runAllSpecs({ + parallel: false, + }) }) }) }, diff --git a/packages/server/lib/video_capture.coffee b/packages/server/lib/video_capture.coffee index 3d3a9f7dfbed..9773a51ee5e8 100644 --- a/packages/server/lib/video_capture.coffee +++ b/packages/server/lib/video_capture.coffee @@ -1,18 +1,45 @@ _ = require("lodash") +la = require("lazy-ass") +os = require("os") +path = require("path") utils = require("fluent-ffmpeg/lib/utils") debug = require("debug")("cypress:server:video") -# extra verbose logs for logging individual frames -debugFrames = require("debug")("cypress:server:video:frames") ffmpeg = require("fluent-ffmpeg") stream = require("stream") Promise = require("bluebird") ffmpegPath = require("@ffmpeg-installer/ffmpeg").path +BlackHoleStream = require("black-hole-stream") fs = require("./util/fs") +## extra verbose logs for logging individual frames +debugFrames = require("debug")("cypress:server:video:frames") + debug("using ffmpeg from %s", ffmpegPath) + ffmpeg.setFfmpegPath(ffmpegPath) module.exports = { + getMsFromDuration: (duration) -> + utils.timemarkToSeconds(duration) * 1000 + + getCodecData: (src) -> + new Promise (resolve, reject) -> + ffmpeg() + .on "stderr", (stderr) -> + debug("get codecData stderr log %o", { message: stderr }) + .on("codecData", resolve) + .input(src) + .format("null") + .output(new BlackHoleStream()) + .run() + .tap (data) -> + debug('codecData %o', { + src, + data, + }) + .tapCatch (err) -> + debug("getting codecData failed", { err }) + copy: (src, dest) -> debug("copying from %s to %s", src, dest) fs @@ -66,7 +93,7 @@ module.exports = { if not wantsWrite = pt.write(data) pt.once "drain", -> debugFrames("video stream drained") - + wantsWrite = true else skipped += 1 @@ -105,8 +132,6 @@ module.exports = { if logErrors options.onError(err, stdout, stderr) - err.recordingVideoFailed = true - ## reject the ended promise ended.reject(err) @@ -115,14 +140,14 @@ module.exports = { ended.resolve() .save(name) - + startCapturing() .then ({ cmd, startedVideoCapture }) -> return { - cmd, + cmd, endVideoCapture, writeVideoFrame, - startedVideoCapture, + startedVideoCapture, } process: (name, cname, videoCompression, onProgress = ->) -> diff --git a/packages/server/package.json b/packages/server/package.json index 4a748540f815..e65a68a97f15 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -46,13 +46,15 @@ "@cypress/icons": "0.7.0", "@ffmpeg-installer/ffmpeg": "1.0.19", "ansi_up": "1.3.0", - "bluebird": "3.4.7", + "black-hole-stream": "0.0.1", + "bluebird": "3.7.0", "browserify": "16.3.0", "chai": "1.10.0", "chalk": "2.4.2", "charset": "1.0.1", "check-more-types": "2.24.0", "chokidar": "3.0.2", + "chrome-remote-interface": "0.28.0", "cjsxify": "0.3.0", "cli-table3": "0.5.1", "color-string": "1.5.3", @@ -75,6 +77,7 @@ "fix-path": "2.1.0", "fluent-ffmpeg": "2.1.2", "fs-extra": "8.1.0", + "get-port": "5.0.0", "getos": "3.1.1", "glob": "7.1.3", "graceful-fs": "4.2.0", @@ -109,6 +112,7 @@ "ospath": "1.2.2", "p-queue": "6.1.0", "parse-domain": "2.0.0", + "pluralize": "8.0.0", "pumpify": "1.5.1", "ramda": "0.24.1", "randomstring": "1.1.5", @@ -147,10 +151,12 @@ "@cypress/debugging-proxy": "2.0.1", "@cypress/json-schemas": "5.32.2", "@cypress/sinon-chai": "1.1.0", + "@types/chai-as-promised": "7.1.2", "babel-plugin-add-module-exports": "1.0.2", "babelify": "10.0.0", "bin-up": "1.2.2", "body-parser": "1.19.0", + "chai-as-promised": "7.1.1", "chai-uuid": "1.0.6", "chokidar-cli": "1.2.2", "chrome-har-capturer": "0.13.4", diff --git a/packages/server/test/e2e/2_cdp_spec.ts b/packages/server/test/e2e/2_cdp_spec.ts new file mode 100644 index 000000000000..2c5c5f0a54fd --- /dev/null +++ b/packages/server/test/e2e/2_cdp_spec.ts @@ -0,0 +1,29 @@ +import mockedEnv from 'mocked-env' + +const e2e = require('../support/helpers/e2e') +const Fixtures = require('../support/helpers/fixtures') + +describe('e2e cdp', function () { + e2e.setup() + let restoreEnv : Function + + beforeEach(() => { + restoreEnv = mockedEnv({ + CYPRESS_REMOTE_DEBUGGING_PORT: '7777', + }) + }) + + afterEach(() => { + restoreEnv() + }) + + it('fails when remote debugging port cannot be connected to', function () { + return e2e.exec(this, { + project: Fixtures.projectPath('remote-debugging-port-removed'), + spec: 'spec.ts', + browser: 'chrome', + expectedExitCode: 1, + snapshot: true, + }) + }) +}) diff --git a/packages/server/test/e2e/6_video_compression_spec.coffee b/packages/server/test/e2e/6_video_compression_spec.coffee index 8955fda34d44..55ae8d2e3243 100644 --- a/packages/server/test/e2e/6_video_compression_spec.coffee +++ b/packages/server/test/e2e/6_video_compression_spec.coffee @@ -1,16 +1,48 @@ +path = require("path") +humanInterval = require("human-interval") e2e = require("../support/helpers/e2e") +glob = require("../../lib/util/glob") +videoCapture = require("../../lib/video_capture") +Fixtures = require("../support/helpers/fixtures") + +NUM_TESTS = 40 +MS_PER_TEST = 500 +EXPECTED_DURATION_MS = NUM_TESTS * MS_PER_TEST describe "e2e video compression", -> e2e.setup() - it "passes", -> - process.env.VIDEO_COMPRESSION_THROTTLE = 10 + [ + 'chrome', + 'electron' + ].map (browser) -> + it "passes in #{browser}", -> + process.env.VIDEO_COMPRESSION_THROTTLE = 10 + + e2e.exec(@, { + spec: "video_compression_spec.coffee" + snapshot: false + config: { + env: { + NUM_TESTS + MS_PER_TEST + } + } + expectedExitCode: 0 + }) + .tap -> + videosPath = Fixtures.projectPath("e2e/cypress/videos/*") + + glob(videosPath) + .then (files) -> + expect(files).to.have.length(1, "globbed for videos and found: #{files.length}. Expected to find 1 video. Search in videosPath: #{videosPath}.") + + videoCapture.getCodecData(files[0]) + .then ({ duration }) -> + durationMs = videoCapture.getMsFromDuration(duration) + expect(durationMs).to.be.ok + expect(durationMs).to.be.closeTo(EXPECTED_DURATION_MS, humanInterval('10 seconds')) - e2e.exec(@, { - spec: "video_compression_spec.coffee" - snapshot: false - expectedExitCode: 0 - }) - .get("stdout") - .then (stdout) -> - expect(stdout).to.match(/Compression progress:\s+\d{1,3}%/) + .get("stdout") + .then (stdout) -> + expect(stdout).to.match(/Compression progress:\s+\d{1,3}%/) diff --git a/packages/server/test/integration/cypress_spec.coffee b/packages/server/test/integration/cypress_spec.coffee index 359a280ce380..5b80a594caf2 100644 --- a/packages/server/test/integration/cypress_spec.coffee +++ b/packages/server/test/integration/cypress_spec.coffee @@ -39,6 +39,7 @@ Watchers = require("#{root}lib/watchers") browsers = require("#{root}lib/browsers") videoCapture = require("#{root}lib/video_capture") browserUtils = require("#{root}lib/browsers/utils") +chromeBrowser = require("#{root}lib/browsers/chrome") openProject = require("#{root}lib/open_project") env = require("#{root}lib/util/env") system = require("#{root}lib/util/system") @@ -764,6 +765,10 @@ describe "lib/cypress", -> ee = new EE() ee.kill = -> + # ughh, would be nice to test logic inside the launcher + # that cleans up after the browser exit + # like calling client.close() if available to let the + # browser free any resources ee.emit("exit") ee.close = -> ee.emit("closed") @@ -789,6 +794,18 @@ describe "lib/cypress", -> context "before:browser:launch", -> it "chrome", -> + # during testing, do not try to connect to the remote interface or + # use the Chrome remote interface client + criClient = { + close: sinon.stub().resolves() + } + sinon.stub(chromeBrowser, "_connectToChromeRemoteInterface").resolves(criClient) + # the "returns(resolves)" stub is due to curried method + # it accepts URL to visit and then waits for actual CRI client reference + # and only then navigates to that URL + visitPage = sinon.stub().resolves() + sinon.stub(chromeBrowser, "_navigateUsingCRI").returns(visitPage) + cypress.start([ "--run-project=#{@pluginBrowser}" "--browser=chrome" @@ -808,6 +825,8 @@ describe "lib/cypress", -> @expectExitWith(0) + expect(visitPage).to.have.been.calledOnce + it "electron", -> writeVideoFrame = sinon.stub() videoCapture.start.returns({ writeVideoFrame }) diff --git a/packages/server/test/integration/http_requests_spec.coffee b/packages/server/test/integration/http_requests_spec.coffee index e5c900ad96ee..3f6d26b75c4a 100644 --- a/packages/server/test/integration/http_requests_spec.coffee +++ b/packages/server/test/integration/http_requests_spec.coffee @@ -55,6 +55,8 @@ browserifyFile = (filePath) -> ) describe "Routes", -> + require("mocha-banner").register() + beforeEach -> process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" diff --git a/packages/server/test/integration/server_spec.coffee b/packages/server/test/integration/server_spec.coffee index 567d2c57892d..277aa42cee11 100644 --- a/packages/server/test/integration/server_spec.coffee +++ b/packages/server/test/integration/server_spec.coffee @@ -19,6 +19,8 @@ expectToEqDetails = (actual, expected) -> expect(actual).to.deep.eq(expected) describe "Server", -> + require("mocha-banner").register() + beforeEach -> sinon.stub(Server.prototype, "reset") diff --git a/packages/server/test/integration/websockets_spec.coffee b/packages/server/test/integration/websockets_spec.coffee index 27fbfbf3ab87..aef90d0174cc 100644 --- a/packages/server/test/integration/websockets_spec.coffee +++ b/packages/server/test/integration/websockets_spec.coffee @@ -19,6 +19,8 @@ wsPort = 20000 wssPort = 8443 describe "Web Sockets", -> + require("mocha-banner").register() + beforeEach -> Fixtures.scaffold() diff --git a/packages/server/test/performance/cy_visit_performance_spec.js b/packages/server/test/performance/cy_visit_performance_spec.js index 30e19fe61798..40049f676a2c 100644 --- a/packages/server/test/performance/cy_visit_performance_spec.js +++ b/packages/server/test/performance/cy_visit_performance_spec.js @@ -19,6 +19,7 @@ context('cy.visit performance tests', function () { }, settings: { baseUrl: 'http://localhost:3434', + video: false, }, }) @@ -37,6 +38,7 @@ context('cy.visit performance tests', function () { snapshot: true, expectedExitCode: 0, config: { + video: false, env: { currentRetry: this.test._currentRetry, }, diff --git a/packages/server/test/spec_helper.coffee b/packages/server/test/spec_helper.coffee index da4f4fa5d6c5..355aa89257c5 100644 --- a/packages/server/test/spec_helper.coffee +++ b/packages/server/test/spec_helper.coffee @@ -16,6 +16,7 @@ appData = require("../lib/util/app_data") require("chai") .use(require("@cypress/sinon-chai")) .use(require("chai-uuid")) +.use(require("chai-as-promised")) if process.env.UPDATE throw new Error("You're using UPDATE=1 which is the old way of updating snapshots.\n\nThe correct environment variable is SNAPSHOT_UPDATE=1") diff --git a/packages/server/test/support/fixtures/projects/e2e/cypress/integration/video_compression_spec.coffee b/packages/server/test/support/fixtures/projects/e2e/cypress/integration/video_compression_spec.coffee index ec36529dc8bb..37261de69448 100644 --- a/packages/server/test/support/fixtures/projects/e2e/cypress/integration/video_compression_spec.coffee +++ b/packages/server/test/support/fixtures/projects/e2e/cypress/integration/video_compression_spec.coffee @@ -1,3 +1,3 @@ -Cypress._.times 40, (i) -> +Cypress._.times Cypress.env('NUM_TESTS'), (i) -> it "num: #{i+1} makes some long tests", -> - cy.wait(500) + cy.wait(Cypress.env('MS_PER_TEST')) diff --git a/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress.json b/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress.json @@ -0,0 +1 @@ +{} diff --git a/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress/integration/spec.ts b/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress/integration/spec.ts new file mode 100644 index 000000000000..60bc2f960218 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress/integration/spec.ts @@ -0,0 +1,3 @@ +describe('passes', () => { + it('passes', () => {}) +}) diff --git a/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress/plugins.js b/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress/plugins.js new file mode 100644 index 000000000000..60dd08b421ac --- /dev/null +++ b/packages/server/test/support/fixtures/projects/remote-debugging-port-removed/cypress/plugins.js @@ -0,0 +1,14 @@ +const la = require('lazy-ass') + +module.exports = (on) => { + on('before:browser:launch', (browser = {}, args) => { + la(browser.family === 'chrome', 'this test can only be run with a chrome-family browser') + + // remove debugging port so that the browser connection fails + const newArgs = args.filter((arg) => !arg.startsWith('--remote-debugging-port=')) + + la(newArgs.length === args.length - 1, 'exactly one argument should have been removed') + + return newArgs + }) +} diff --git a/packages/server/test/unit/browsers/browsers_spec.coffee b/packages/server/test/unit/browsers/browsers_spec.coffee index 308bf12bbc8a..295a2e07e84b 100644 --- a/packages/server/test/unit/browsers/browsers_spec.coffee +++ b/packages/server/test/unit/browsers/browsers_spec.coffee @@ -6,6 +6,12 @@ browsers = require("#{root}../lib/browsers") utils = require("#{root}../lib/browsers/utils") describe "lib/browsers/index", -> + context ".isBrowserFamily", -> + it "allows only known browsers", -> + expect(browsers.isBrowserFamily("chrome")).to.be.true + expect(browsers.isBrowserFamily("electron")).to.be.true + expect(browsers.isBrowserFamily("my-favorite-browser")).to.be.false + context ".ensureAndGetByNameOrPath", -> it "returns browser by name", -> sinon.stub(utils, "getBrowsers").resolves([ @@ -32,11 +38,17 @@ describe "lib/browsers/index", -> family: 'foo-bad' }, { browsers: [] - }).then -> + }) + .then (e) -> + console.error(e) throw new Error("should've failed") - .catch (err) -> - expect(err.type).to.eq("BROWSER_NOT_FOUND_BY_NAME") - expect(err.message).to.contain("'foo-bad-bang' was not found on your system") + , (err) -> + # by being explicit with assertions, if something is unexpected + # we will get good error message that includes the "err" object + expect(err).to.have.property("type").to.eq("BROWSER_NOT_FOUND_BY_NAME") + expect(err).to.have.property("message").to.contain("'foo-bad-bang' was not found on your system") + + # Ooo, browser clean up tests are disabled?!! # it "calls onBrowserClose callback on close", -> # onBrowserClose = sinon.stub() diff --git a/packages/server/test/unit/browsers/chrome_spec.coffee b/packages/server/test/unit/browsers/chrome_spec.coffee index 233a95eb6f63..76a957fa8468 100644 --- a/packages/server/test/unit/browsers/chrome_spec.coffee +++ b/packages/server/test/unit/browsers/chrome_spec.coffee @@ -12,14 +12,37 @@ describe "lib/browsers/chrome", -> context "#open", -> beforeEach -> @args = [] + # mock CRI client during testing + @criClient = { + send: sinon.stub().resolves() + Page: { + screencastFrame: sinon.stub().returns() + }, + close: sinon.stub().resolves() + } + # mock launched browser child process object + @launchedBrowser = { + kill: sinon.stub().returns() + } sinon.stub(chrome, "_getArgs").returns(@args) sinon.stub(chrome, "_writeExtension").resolves("/path/to/ext") + sinon.stub(chrome, "_connectToChromeRemoteInterface").resolves(@criClient) sinon.stub(plugins, "has") sinon.stub(plugins, "execute") - sinon.stub(utils, "launch") + sinon.stub(utils, "launch").resolves(@launchedBrowser) sinon.stub(utils, "getProfileDir").returns("/profile/dir") sinon.stub(utils, "ensureCleanCache").resolves("/profile/dir/CypressCache") + # port for Chrome remote interface communication + sinon.stub(utils, "getPort").resolves(50505) + + it "focuses on the page and calls CRI Page.visit", -> + chrome.open("chrome", "http://", {}, {}) + .then => + expect(utils.getPort).to.have.been.calledOnce # to get remote interface port + expect(@criClient.send).to.have.been.calledTwice + expect(@criClient.send).to.have.been.calledWith("Page.bringToFront") + expect(@criClient.send).to.have.been.calledWith("Page.navigate") it "is noop without before:browser:launch", -> plugins.has.returns(false) @@ -34,7 +57,9 @@ describe "lib/browsers/chrome", -> chrome.open("chrome", "http://", {}, {}) .then => - expect(utils.launch).to.be.calledWith("chrome", "http://", @args) + # to initialize remote interface client and prepare for true tests + # we load the browser with blank page first + expect(utils.launch).to.be.calledWith("chrome", "about:blank", @args) it "normalizes --load-extension if provided in plugin", -> plugins.has.returns(true) @@ -98,6 +123,18 @@ describe "lib/browsers/chrome", -> } }) + it "calls cri client close on kill", -> + ## need a reference here since the stub will be monkey-patched + kill = @launchedBrowser.kill + + chrome.open("chrome", "http://", {}, {}) + .then => + expect(@launchedBrowser.kill).to.be.a("function") + @launchedBrowser.kill() + .then => + expect(@criClient.close).to.be.calledOnce + expect(kill).to.be.calledOnce + context "#_getArgs", -> it "disables gpu when linux", -> sinon.stub(os, "platform").returns("linux") @@ -178,4 +215,3 @@ describe "lib/browsers/chrome", -> chromeVersionHasLoopback("71", false) chromeVersionHasLoopback("72", true) chromeVersionHasLoopback("73", true) - diff --git a/packages/server/test/unit/browsers/protocol_spec.ts b/packages/server/test/unit/browsers/protocol_spec.ts new file mode 100644 index 000000000000..c9e29fae09f0 --- /dev/null +++ b/packages/server/test/unit/browsers/protocol_spec.ts @@ -0,0 +1,76 @@ +import '../../spec_helper' +import _ from 'lodash' +import 'chai-as-promised' // for the types! +import chalk from 'chalk' +import { connect } from '@packages/network' +import CRI from 'chrome-remote-interface' +import { expect } from 'chai' +import humanInterval from 'human-interval' +import protocol from '../../../lib/browsers/protocol' +import sinon from 'sinon' +import snapshot from 'snap-shot-it' +import { stripIndents } from 'common-tags' + +describe('lib/browsers/protocol', function () { + context('._getDelayMsForRetry', function () { + it('retries as expected for up to 5 seconds', function () { + let delays = [] + let delay : number + let i = 0 + + while ((delay = protocol._getDelayMsForRetry(i))) { + delays.push(delay) + i++ + } + + expect(_.sum(delays)).to.eq(humanInterval('5 seconds')) + + snapshot(delays) + }) + }) + + context('.getWsTargetFor', function () { + it('rejects if CDP connection fails', function () { + const innerErr = new Error('cdp connection failure') + + sinon.stub(connect, 'createRetryingSocket').callsArgWith(1, innerErr) + const p = protocol.getWsTargetFor(12345) + + const expectedError = stripIndents` + Cypress failed to make a connection to the Chrome DevTools Protocol after retrying for 5 seconds. + + This usually indicates there was a problem opening the Chrome browser. + + The CDP port requested was ${chalk.yellow('12345')}. + + Error details: + ` + + return expect(p).to.eventually.be.rejected + .and.property('message').include(expectedError) + .and.include(innerErr.message) + }) + + it('returns the debugger URL of the first about:blank tab', function () { + const targets = [ + { + type: 'page', + url: 'chrome://newtab', + webSocketDebuggerUrl: 'foo', + }, + { + type: 'page', + url: 'about:blank', + webSocketDebuggerUrl: 'bar', + }, + ] + + sinon.stub(CRI, 'List').withArgs({ port: 12345 }).resolves(targets) + sinon.stub(connect, 'createRetryingSocket').callsArg(1) + + const p = protocol.getWsTargetFor(12345) + + return expect(p).to.eventually.equal('bar') + }) + }) +}) diff --git a/packages/server/test/unit/modes/record_spec.coffee b/packages/server/test/unit/modes/record_spec.coffee index 0d8de98714c9..8e66ad5cf85d 100644 --- a/packages/server/test/unit/modes/record_spec.coffee +++ b/packages/server/test/unit/modes/record_spec.coffee @@ -126,7 +126,7 @@ describe "lib/modes/record", -> runAllSpecs }) .then -> - expect(runAllSpecs).to.have.been.calledWith({}, false) + expect(runAllSpecs).to.have.been.calledWith({ parallel: false }) expect(createRun).to.have.been.calledOnce expect(createRun.firstCall.args).to.have.length(1) { commit } = createRun.firstCall.args[0] @@ -177,7 +177,7 @@ describe "lib/modes/record", -> runAllSpecs }) .then -> - expect(runAllSpecs).to.have.been.calledWith({}, false) + expect(runAllSpecs).to.have.been.calledWith({ parallel: false }) expect(createRun).to.have.been.calledOnce expect(createRun.firstCall.args).to.have.length(1) { commit } = createRun.firstCall.args[0] diff --git a/packages/server/test/unit/modes/run_spec.coffee b/packages/server/test/unit/modes/run_spec.coffee index 584d8535a3a4..aebc3df774ad 100644 --- a/packages/server/test/unit/modes/run_spec.coffee +++ b/packages/server/test/unit/modes/run_spec.coffee @@ -133,7 +133,6 @@ describe "lib/modes/run", -> context ".launchBrowser", -> beforeEach -> @launch = sinon.stub(openProject, "launch") - sinon.stub(runMode, "getElectronProps").returns({foo: "bar"}) sinon.stub(runMode, "screenshotMetadata").returns({a: "a"}) it "can launch electron", -> @@ -143,7 +142,11 @@ describe "lib/modes/run", -> absolute: "/path/to/spec" } - browser = { name: "electron", isHeaded: false } + browser = { + name: "electron", + family: "electron", + isHeaded: false + } runMode.launchBrowser({ spec @@ -153,9 +156,7 @@ describe "lib/modes/run", -> screenshots: screenshots }) - expect(runMode.getElectronProps).to.be.calledWith(false, @projectInstance, "write") - - expect(@launch).to.be.calledWithMatch(browser, spec, { foo: "bar" }) + expect(@launch).to.be.calledWithMatch(browser, spec) browserOpts = @launch.firstCall.args[2] @@ -173,15 +174,17 @@ describe "lib/modes/run", -> absolute: "/path/to/spec" } - browser = { name: "chrome", isHeaded: true } + browser = { + name: "chrome", + family: "chrome", + isHeaded: true + } runMode.launchBrowser({ spec browser }) - expect(runMode.getElectronProps).not.to.be.called - expect(@launch).to.be.calledWithMatch(browser, spec, {}) context ".postProcessRecording", -> @@ -216,6 +219,15 @@ describe "lib/modes/run", -> .then -> expect(videoCapture.process).not.to.be.called + it "logs a warning on failure and resolves", -> + sinon.stub(errors, 'warning') + end = sinon.stub().rejects() + + runMode.postProcessRecording(end) + .then -> + expect(end).to.be.calledOnce + expect(errors.warning).to.be.calledWith('VIDEO_POST_PROCESSING_FAILED') + context ".waitForBrowserToConnect", -> it "throws TESTS_DID_NOT_START_FAILED after 3 connection attempts", -> sinon.spy(errors, "warning") @@ -527,10 +539,18 @@ describe "lib/modes/run", -> .then -> expect(errors.warning).to.be.calledWith("CANNOT_RECORD_VIDEO_HEADED") - it "disables video recording for non-electron browser", -> + it "throws an error if invalid browser family supplied", -> + browser = { name: "opera", family: "opera - btw when is Opera support coming?" } + + sinon.stub(browsers, "ensureAndGetByNameOrPath").resolves(browser) + + expect(runMode.run({browser: "opera"})) + .to.be.rejectedWith(/invalid browser family in/) + + it "shows no warnings for chrome browser", -> runMode.run({browser: "chrome"}) .then -> - expect(errors.warning).to.be.calledWith("CANNOT_RECORD_VIDEO_FOR_THIS_BROWSER") + expect(errors.warning).to.not.be.called it "names video file with spec name", -> runMode.run() @@ -554,7 +574,8 @@ describe "lib/modes/run", -> sinon.stub(browsers, "ensureAndGetByNameOrPath").resolves({ name: "fooBrowser", path: "path/to/browser" - version: "777" + version: "777", + family: "electron" }) sinon.stub(runMode, "waitForSocketConnection").resolves() sinon.stub(runMode, "waitForTestsToFinishRunning").resolves({ @@ -606,7 +627,7 @@ describe "lib/modes/run", -> }) it "passes headed to openProject.launch", -> - browser = { name: "electron" } + browser = { name: "electron", family: "electron" } browsers.ensureAndGetByNameOrPath.resolves(browser)