diff --git a/LICENSE b/LICENSE index 44e14bb50afd..58fe407efeff 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Cypress.io +Copyright (c) 2020 Cypress.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/cli/__snapshots__/cli_spec.js b/cli/__snapshots__/cli_spec.js index 2ff6b0b1e7ab..1a928151f95b 100644 --- a/cli/__snapshots__/cli_spec.js +++ b/cli/__snapshots__/cli_spec.js @@ -75,6 +75,7 @@ exports['shows help for run --foo 1'] = ` --parallel enables concurrent runs and automatic load balancing of specs across multiple machines or processes -p, --port runs Cypress on a specific port. overrides any value in cypress.json. -P, --project path to the project + -q, --quiet run quietly, using only the configured reporter --record [bool] records the run. sends test results, screenshots and videos to your Cypress Dashboard. -r, --reporter runs a specific mocha reporter. pass a path to use a custom reporter. defaults to "spec" -o, --reporter-options options for the mocha reporter. defaults to "null" diff --git a/cli/__snapshots__/errors_spec.js b/cli/__snapshots__/errors_spec.js index e04b953cee2e..f97265efbf98 100644 --- a/cli/__snapshots__/errors_spec.js +++ b/cli/__snapshots__/errors_spec.js @@ -35,6 +35,7 @@ exports['errors individual has the following errors 1'] = [ "incompatibleHeadlessFlags", "invalidCacheDirectory", "invalidCypressEnv", + "invalidRunProjectPath", "invalidSmokeTestDisplayError", "missingApp", "missingDependency", @@ -44,6 +45,7 @@ exports['errors individual has the following errors 1'] = [ "removed", "smokeTestFailure", "unexpected", + "unknownError", "versionMismatch" ] diff --git a/cli/lib/cli.js b/cli/lib/cli.js index 2875750b712c..adb6505a448b 100644 --- a/cli/lib/cli.js +++ b/cli/lib/cli.js @@ -109,6 +109,7 @@ const descriptions = { parallel: 'enables concurrent runs and automatic load balancing of specs across multiple machines or processes', port: 'runs Cypress on a specific port. overrides any value in cypress.json.', project: 'path to the project', + quiet: 'run quietly, using only the configured reporter', record: 'records the run. sends test results, screenshots and videos to your Cypress Dashboard.', reporter: 'runs a specific mocha reporter. pass a path to use a custom reporter. defaults to "spec"', reporterOptions: 'options for the mocha reporter. defaults to "null"', @@ -231,6 +232,7 @@ module.exports = { .option('--parallel', text('parallel')) .option('-p, --port ', text('port')) .option('-P, --project ', text('project')) + .option('-q, --quiet', text('quiet')) .option('--record [bool]', text('record'), coerceFalse) .option('-r, --reporter ', text('reporter')) .option('-o, --reporter-options ', text('reporterOptions')) diff --git a/cli/lib/cypress.js b/cli/lib/cypress.js index ad49ce60b143..25ffa5fe9c9f 100644 --- a/cli/lib/cypress.js +++ b/cli/lib/cypress.js @@ -9,13 +9,25 @@ const run = require('./exec/run') const util = require('./util') const cypressModuleApi = { + /** + * Opens Cypress GUI + * @see https://on.cypress.io/module-api#cypress-open + */ open (options = {}) { options = util.normalizeModuleOptions(options) return open.start(options) }, + /** + * Runs Cypress tests in the current project + * @see https://on.cypress.io/module-api#cypress-run + */ run (options = {}) { + if (!run.isValidProject(options.project)) { + return Promise.reject(new Error(`Invalid project path parameter: ${options.project}`)) + } + options = util.normalizeModuleOptions(options) return tmp.fileAsync() diff --git a/cli/lib/errors.js b/cli/lib/errors.js index 233b0ac855b3..d0e5334d6eee 100644 --- a/cli/lib/errors.js +++ b/cli/lib/errors.js @@ -9,13 +9,36 @@ const state = require('./tasks/state') const docsUrl = 'https://on.cypress.io' const requiredDependenciesUrl = `${docsUrl}/required-dependencies` +const runDocumentationUrl = `${docsUrl}/cypress-run` // TODO it would be nice if all error objects could be enforced via types // to only have description + solution properties const hr = '----------' +const genericErrorSolution = stripIndent` + Search for an existing issue or open a GitHub issue at + + ${chalk.blue(util.issuesUrl)} +` + // common errors Cypress application can encounter +const unknownError = { + description: 'Unknown Cypress CLI error', + solution: genericErrorSolution, +} + +const invalidRunProjectPath = { + description: 'Invalid --project path', + solution: stripIndent` + Please provide a valid project path. + + Learn more about ${chalk.cyan('cypress run')} at: + + ${chalk.blue(runDocumentationUrl)} + `, +} + const failedDownload = { description: 'The Cypress App could not be downloaded.', solution: stripIndent` @@ -26,11 +49,7 @@ const failedDownload = { const failedUnzip = { description: 'The Cypress App could not be unzipped.', - solution: stripIndent` - Search for an existing issue or open a GitHub issue at - - ${chalk.blue(util.issuesUrl)} - `, + solution: genericErrorSolution, } const missingApp = (binaryDir) => { @@ -390,6 +409,7 @@ module.exports = { getError, hr, errors: { + unknownError, nonZeroExitCodeXvfb, missingXvfb, missingApp, @@ -408,5 +428,6 @@ module.exports = { smokeTestFailure, childProcessKilled, incompatibleHeadlessFlags, + invalidRunProjectPath, }, } diff --git a/cli/lib/exec/run.js b/cli/lib/exec/run.js index 28fc4f352db7..f173465c3f63 100644 --- a/cli/lib/exec/run.js +++ b/cli/lib/exec/run.js @@ -6,11 +6,60 @@ const spawn = require('./spawn') const verify = require('../tasks/verify') const { exitWithError, errors } = require('../errors') -// maps options collected by the CLI -// and forms list of CLI arguments to the server +/** + * Throws an error with "details" property from + * "errors" object. + * @param {Object} details - Error details + */ +const throwInvalidOptionError = (details) => { + if (!details) { + details = errors.unknownError + } + + // throw this error synchronously, it will be caught later on and + // the details will be propagated to the promise chain + const err = new Error() + + err.details = details + throw err +} + +/** + * Typically a user passes a string path to the project. + * But "cypress open" allows using `false` to open in global mode, + * and the user can accidentally execute `cypress run --project false` + * which should be invalid. + */ +const isValidProject = (v) => { + if (typeof v === 'boolean') { + return false + } + + if (v === '' || v === 'false' || v === 'true') { + return false + } + + return true +} + +/** + * Maps options collected by the CLI + * and forms list of CLI arguments to the server. + * + * Note: there is lightweight validation, with errors + * thrown synchronously. + * + * @returns {string[]} list of CLI arguments + */ const processRunOptions = (options = {}) => { debug('processing run options %o', options) + if (!isValidProject(options.project)) { + debug('invalid project option %o', { project: options.project }) + + return throwInvalidOptionError(errors.invalidRunProjectPath) + } + const args = ['--run-project', options.project] if (options.browser) { @@ -55,12 +104,7 @@ const processRunOptions = (options = {}) => { if (options.headless) { if (options.headed) { - // throw this error synchronously, it will be caught later on and - // the details will be propagated to the promise chain - const err = new Error() - - err.details = errors.incompatibleHeadlessFlags - throw err + return throwInvalidOptionError(errors.incompatibleHeadlessFlags) } args.push('--headed', !options.headless) @@ -89,6 +133,10 @@ const processRunOptions = (options = {}) => { args.push('--port', options.port) } + if (options.quiet) { + args.push('--quiet') + } + // if record is defined and we're not // already in ci mode, then send it up if (options.record != null && !options.ci) { @@ -119,6 +167,7 @@ const processRunOptions = (options = {}) => { module.exports = { processRunOptions, + isValidProject, // resolves with the number of failed tests start (options = {}) { _.defaults(options, { diff --git a/cli/lib/util.js b/cli/lib/util.js index 4c4d1049b05d..7819adfaadc8 100644 --- a/cli/lib/util.js +++ b/cli/lib/util.js @@ -214,6 +214,7 @@ const parseOpts = (opts) => { 'parallel', 'port', 'project', + 'quiet', 'reporter', 'reporterOptions', 'record', diff --git a/cli/test/lib/cypress_spec.js b/cli/test/lib/cypress_spec.js index 506c5e3ab386..000575b60dc2 100644 --- a/cli/test/lib/cypress_spec.js +++ b/cli/test/lib/cypress_spec.js @@ -147,5 +147,29 @@ describe('cypress', function () { expect(args).to.deep.eq(opts) }) }) + + it('rejects if project is an empty string', () => { + return expect(cypress.run({ project: '' })).to.be.rejected + }) + + it('rejects if project is true', () => { + return expect(cypress.run({ project: true })).to.be.rejected + }) + + it('rejects if project is false', () => { + return expect(cypress.run({ project: false })).to.be.rejected + }) + + it('passes quiet: true', () => { + const opts = { + quiet: true, + } + + return cypress.run(opts) + .then(getStartArgs) + .then((args) => { + expect(args).to.deep.eq(opts) + }) + }) }) }) diff --git a/cli/test/lib/exec/run_spec.js b/cli/test/lib/exec/run_spec.js index 69df0f1fa71a..4142654d92a1 100644 --- a/cli/test/lib/exec/run_spec.js +++ b/cli/test/lib/exec/run_spec.js @@ -15,6 +15,28 @@ describe('exec run', function () { }) context('.processRunOptions', function () { + it('allows string --project option', () => { + const args = run.processRunOptions({ + project: '/path/to/project', + }) + + expect(args).to.deep.equal(['--run-project', '/path/to/project']) + }) + + it('throws an error for empty string --project', () => { + expect(() => run.processRunOptions({ project: '' })).to.throw() + }) + + it('throws an error for boolean --project', () => { + expect(() => run.processRunOptions({ project: false })).to.throw() + expect(() => run.processRunOptions({ project: true })).to.throw() + }) + + it('throws an error for --project "false" or "true"', () => { + expect(() => run.processRunOptions({ project: 'false' })).to.throw() + expect(() => run.processRunOptions({ project: 'true' })).to.throw() + }) + it('passes --browser option', () => { const args = run.processRunOptions({ browser: 'test browser', diff --git a/cli/types/cypress-npm-api.d.ts b/cli/types/cypress-npm-api.d.ts index 0ebaea604454..116d99c4e828 100644 --- a/cli/types/cypress-npm-api.d.ts +++ b/cli/types/cypress-npm-api.d.ts @@ -68,6 +68,10 @@ declare module 'cypress' { * Override default port */ port: number + /** + * Run quietly, using only the configured reporter + */ + quiet: boolean /** * Whether to record the test run */ diff --git a/packages/desktop-gui/cypress/integration/login_spec.js b/packages/desktop-gui/cypress/integration/login_spec.js index 5a9ec1865502..82ce041e6c4a 100644 --- a/packages/desktop-gui/cypress/integration/login_spec.js +++ b/packages/desktop-gui/cypress/integration/login_spec.js @@ -69,6 +69,12 @@ describe('Login', function () { }) }) + it('passes utm code when it triggers ipc \'begin:auth\'', function () { + cy.then(function () { + expect(this.ipc.beginAuth).to.be.calledWith('Nav Login Button') + }) + }) + it('disables login button', () => { cy.get('@loginBtn').should('be.disabled') }) diff --git a/packages/desktop-gui/cypress/integration/runs_list_spec.js b/packages/desktop-gui/cypress/integration/runs_list_spec.js index d62ee612b685..c47ceeade608 100644 --- a/packages/desktop-gui/cypress/integration/runs_list_spec.js +++ b/packages/desktop-gui/cypress/integration/runs_list_spec.js @@ -303,6 +303,18 @@ describe('Runs List', function () { it('does not fetch runs', function () { expect(this.ipc.getRuns).not.to.be.called }) + + it('clicking Log In to Dashboard opens login', () => { + cy.contains('button', 'Log In to Dashboard').click().then(function () { + expect(this.ipc.beginAuth).to.be.calledOnce + }) + }) + + it('clicking Log In to Dashboard passes utm code', () => { + cy.contains('button', 'Log In to Dashboard').click().then(function () { + expect(this.ipc.beginAuth).to.be.calledWith('Runs Tab Login Button') + }) + }) }) context('without a project id', function () { @@ -345,7 +357,7 @@ describe('Runs List', function () { it('clicking Log In to Dashboard opens login', () => { cy.contains('button', 'Log In to Dashboard').click().then(function () { - expect(this.ipc.beginAuth).to.be.called + expect(this.ipc.beginAuth).to.be.calledOnce }) }) }) diff --git a/packages/desktop-gui/src/auth/auth-api.js b/packages/desktop-gui/src/auth/auth-api.js index 0da2a4fba17a..44e1e1f4be2e 100644 --- a/packages/desktop-gui/src/auth/auth-api.js +++ b/packages/desktop-gui/src/auth/auth-api.js @@ -21,12 +21,12 @@ class AuthApi { }) } - login () { + login (utm) { ipc.onAuthMessage((__, message) => { authStore.setMessage(message) }) - return ipc.beginAuth() + return ipc.beginAuth(utm) .then((user) => { authStore.setUser(user) authStore.setMessage(null) diff --git a/packages/desktop-gui/src/auth/login-form.jsx b/packages/desktop-gui/src/auth/login-form.jsx index d24ec89cb738..4d159f29084e 100644 --- a/packages/desktop-gui/src/auth/login-form.jsx +++ b/packages/desktop-gui/src/auth/login-form.jsx @@ -115,7 +115,7 @@ class LoginForm extends Component { this.setState({ isLoggingIn: true }) - authApi.login() + authApi.login(this.props.utm) .then(() => { this.props.onSuccess() }) diff --git a/packages/desktop-gui/src/auth/login-modal.jsx b/packages/desktop-gui/src/auth/login-modal.jsx index 058fe388cb05..949213dd8639 100644 --- a/packages/desktop-gui/src/auth/login-modal.jsx +++ b/packages/desktop-gui/src/auth/login-modal.jsx @@ -70,7 +70,7 @@ class LoginContent extends Component { x

Log In

Logging in gives you access to the Cypress Dashboard Service. You can set up projects to be recorded and see test data from your project.

- this.setState({ succeeded: true })} /> + this.setState({ succeeded: true })} /> ) } diff --git a/packages/desktop-gui/src/runs/runs-list.jsx b/packages/desktop-gui/src/runs/runs-list.jsx index 2f8a0fb44c87..28633151f917 100644 --- a/packages/desktop-gui/src/runs/runs-list.jsx +++ b/packages/desktop-gui/src/runs/runs-list.jsx @@ -284,7 +284,7 @@ class RunsList extends Component { - + ) } diff --git a/packages/driver/cypress/fixtures/shadow-dom.html b/packages/driver/cypress/fixtures/shadow-dom.html index ae5e025f3452..672e6537fe14 100644 --- a/packages/driver/cypress/fixtures/shadow-dom.html +++ b/packages/driver/cypress/fixtures/shadow-dom.html @@ -35,6 +35,13 @@ } }) } + + if (window.location.search.includes('wrap-qsa')) { + const realQuerySelectorAll = document.querySelectorAll; + document.querySelectorAll = function (...args) { + return realQuerySelectorAll.apply(document, args); + }; + } diff --git a/packages/driver/cypress/integration/commands/querying_spec.js b/packages/driver/cypress/integration/commands/querying_spec.js index ca35a8144a51..8ed6b39fda61 100644 --- a/packages/driver/cypress/integration/commands/querying_spec.js +++ b/packages/driver/cypress/integration/commands/querying_spec.js @@ -946,6 +946,12 @@ describe('src/cy/commands/querying', () => { cy.get('.in-and-out', { includeShadowDom: true }) .should('have.length', 2) }) + + // https://github.com/cypress-io/cypress/issues/7676 + it('does not error when querySelectorAll is wrapped and snapshots are off', () => { + cy.visit('/fixtures/shadow-dom.html?wrap-qsa=true') + cy.get('.shadow-1', { includeShadowDom: true }) + }) }) describe('.log', () => { diff --git a/packages/driver/cypress/integration/commands/traversals_spec.js b/packages/driver/cypress/integration/commands/traversals_spec.js index 2cf53fa4daf5..48dbdbc4d6e9 100644 --- a/packages/driver/cypress/integration/commands/traversals_spec.js +++ b/packages/driver/cypress/integration/commands/traversals_spec.js @@ -407,6 +407,12 @@ describe('src/cy/commands/traversals', () => { expect($element[0]).to.eq(el) }) }) + + // https://github.com/cypress-io/cypress/issues/7676 + it('does not error when querySelectorAll is wrapped and snapshots are off', () => { + cy.visit('/fixtures/shadow-dom.html?wrap-qsa=true') + cy.get('#shadow-element-1').find('.shadow-1', { includeShadowDom: true }) + }) }) describe('closest', () => { diff --git a/packages/driver/cypress/integration/commands/xhr_spec.js b/packages/driver/cypress/integration/commands/xhr_spec.js index b720bebad347..1c7cd99a12ca 100644 --- a/packages/driver/cypress/integration/commands/xhr_spec.js +++ b/packages/driver/cypress/integration/commands/xhr_spec.js @@ -2296,6 +2296,34 @@ describe('src/cy/commands/xhr', () => { expect(resp).to.eq('{ \'bar\' }\n') }) }) + + // https://github.com/cypress-io/cypress/issues/7280 + it('ignores query params when whitelisting routes', () => { + cy.server() + cy.route(/url-with-query-param/, { foo: 'bar' }).as('getQueryParam') + cy.window().then((win) => { + win.$.get('/url-with-query-param?resource=foo.js') + + return null + }) + + cy.wait('@getQueryParam').its('response.body') + .should('deep.equal', { foo: 'bar' }) + }) + + // https://github.com/cypress-io/cypress/issues/7280 + it('ignores hashes when whitelisting routes', () => { + cy.server() + cy.route(/url-with-hash/, { foo: 'bar' }).as('getHash') + cy.window().then((win) => { + win.$.get('/url-with-hash#foo.js') + + return null + }) + + cy.wait('@getHash').its('response.body') + .should('deep.equal', { foo: 'bar' }) + }) }) describe('route setup', () => { diff --git a/packages/driver/cypress/integration/e2e/dom_hitbox.spec.js b/packages/driver/cypress/integration/e2e/dom_hitbox.spec.js index e0341a5a720f..a5d0fb2561c3 100644 --- a/packages/driver/cypress/integration/e2e/dom_hitbox.spec.js +++ b/packages/driver/cypress/integration/e2e/dom_hitbox.spec.js @@ -1,4 +1,5 @@ const { clickCommandLog } = require('../../support/utils') +const { _ } = Cypress // https://github.com/cypress-io/cypress/pull/5299/files describe('rect highlight', () => { @@ -61,6 +62,24 @@ describe('rect highlight', () => { ensureCorrectHighlightPositions('#button') ensureCorrectTargetPosition('#button') }) + + // https://github.com/cypress-io/cypress/issues/7762 + it('highlights above z-index elements', () => { + cy.$$('
').css({ + position: 'absolute', + zIndex: 1000, + top: 0, + left: 0, + height: 50, + width: 50, + padding: 20, + margin: 20, + backgroundColor: 'salmon', + }).appendTo(cy.$$('body')) + + getAndPin('#absolute-el') + ensureCorrectHighlightPositions('#absolute-el') + }) }) const ensureCorrectTargetPosition = (sel) => { @@ -82,12 +101,20 @@ const ensureCorrectTargetPosition = (sel) => { const ensureCorrectHighlightPositions = (sel) => { return cy.wrap(null, { timeout: 400 }).should(() => { - const dims = { - content: cy.$$('div[data-layer=Content]')[0].getBoundingClientRect(), - padding: cy.$$('div[data-layer=Padding]')[0].getBoundingClientRect(), - border: cy.$$('div[data-layer=Border]')[0].getBoundingClientRect(), + const els = { + content: cy.$$('div[data-layer=Content]'), + padding: cy.$$('div[data-layer=Padding]'), + border: cy.$$('div[data-layer=Border]'), } + const dims = _.mapValues(els, ($el) => $el[0].getBoundingClientRect()) + + const doc = els.content[0].ownerDocument + + const contentHighlightCenter = [dims.content.x + dims.content.width / 2, dims.content.y + dims.content.height / 2] + + expect(doc.elementFromPoint(...contentHighlightCenter)).eq(els.content[0]) + expectToBeInside(dims.content, dims.padding, 'content to be inside padding') expectToBeInside(dims.padding, dims.border, 'padding to be inside border') if (sel) { diff --git a/packages/driver/cypress/support/utils.js b/packages/driver/cypress/support/utils.js index 07a3346b6d1a..149ecfd51a88 100644 --- a/packages/driver/cypress/support/utils.js +++ b/packages/driver/cypress/support/utils.js @@ -1,15 +1,18 @@ -const { $, _ } = Cypress +const { $, _, Promise } = Cypress export const getCommandLogWithText = (text) => { + // Open current test if not already open, so we can find the command log + cy.$$('.runnable-active .runnable-wrapper:not(.is-open)', top.document).click() + return cy - .$$(`.runnable-active .command-wrapper:contains(${text}):visible`, top.document) + .$$(`.runnable-active .command-wrapper:contains(${text})`, top.document) .parentsUntil('li') .last() .parent() } export const findReactInstance = function (dom) { - let key = Object.keys(dom).find((key) => key.startsWith('__reactInternalInstance$')) + let key = _.keys(dom).find((key) => key.startsWith('__reactInternalInstance$')) let internalInstance = dom[key] if (internalInstance == null) return null @@ -22,7 +25,7 @@ export const findReactInstance = function (dom) { export const clickCommandLog = (sel) => { return cy.wait(10) .then(() => { - withMutableReporterState(() => { + return withMutableReporterState(() => { const commandLogEl = getCommandLogWithText(sel) const reactCommandInstance = findReactInstance(commandLogEl[0]) @@ -33,10 +36,12 @@ export const clickCommandLog = (sel) => { reactCommandInstance.props.appState.isRunning = false - $(commandLogEl).find('.command-wrapper').click() + $(commandLogEl).find('.command-wrapper') + .click() + .get(0).scrollIntoView() // make sure command was pinned, otherwise throw a better error message - expect(cy.$$('.command-pin:visible', top.document).length, 'command should be pinned').ok + expect(cy.$$('.runnable-active .command-pin', top.document).length, 'command should be pinned').ok }) }) } @@ -46,9 +51,9 @@ export const withMutableReporterState = (fn) => { const currentTestLog = findReactInstance(cy.$$('.runnable-active', top.document)[0]) - currentTestLog.props.model.isOpen = true + currentTestLog.props.model._isOpen = true - return Cypress.Promise.try(fn) + return Promise.try(fn) .then(() => { top.Runner.configureMobx({ enforceActions: 'always' }) }) @@ -79,7 +84,7 @@ const getAllFn = (...aliases) => { return getAllFn((_.isArray(aliases[1]) ? aliases[1] : aliases[1].split(' ')).map((alias) => `@${aliases[0]}:${alias}`).join(' ')) } - return Cypress.Promise.all( + return Promise.all( aliases[0].split(' ').map((alias) => { return cy.now('get', alias) }), diff --git a/packages/driver/package.json b/packages/driver/package.json index 5f5dfb1480b8..03fdc5f7beda 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -19,7 +19,7 @@ "@packages/network": "*", "@packages/runner": "*", "@packages/ts": "*", - "angular": "1.7.9", + "angular": "1.8.0", "backbone": "1.4.0", "basic-auth": "2.0.1", "blob-util": "1.3.0", diff --git a/packages/driver/src/cy/commands/navigation.js b/packages/driver/src/cy/commands/navigation.js index f1bd817148c3..7a234465a514 100644 --- a/packages/driver/src/cy/commands/navigation.js +++ b/packages/driver/src/cy/commands/navigation.js @@ -425,6 +425,23 @@ module.exports = (Commands, Cypress, cy, state, config) => { return } + // if a user-loaded script redefines document.querySelectorAll and + // numTestsKeptInMemory is 0 (no snapshotting), jQuery thinks + // that document.querySelectorAll is not available (it tests to see that + // it's the native definition for some reason) and doesn't use it, + // which can fail with a weird error if querying shadow dom. + // this ensures that jQuery determines support for document.querySelectorAll + // before user scripts are executed. + // (when snapshotting is enabled, it can achieve the same thing if an XHR + // causes it to snapshot before the user script is executed, but that's + // not guaranteed to happen.) + // https://github.com/cypress-io/cypress/issues/7676 + // this shouldn't error, but we wrap it to ignore potential errors + // out of an abundance of caution + try { + cy.$$('body', contentWindow.document) + } catch (e) {} // eslint-disable-line no-empty + const options = _.last(current.get('args')) return options?.onBeforeLoad?.call(runnable.ctx, contentWindow) diff --git a/packages/driver/src/cy/commands/querying.js b/packages/driver/src/cy/commands/querying.js index b916a4f656ab..f3bd41e1f2c7 100644 --- a/packages/driver/src/cy/commands/querying.js +++ b/packages/driver/src/cy/commands/querying.js @@ -276,25 +276,20 @@ module.exports = (Commands, Cypress, cy, state) => { } const getElements = () => { - // attempt to query for the elements by withinSubject context - // and catch any sizzle errors! let $el try { - // only support shadow traversal if we're not searching - // within a subject and have been explicitly told to ignore - // boundaries. - if (!options.includeShadowDom) { - $el = cy.$$(selector, options.withinSubject) - } else { + let scope = options.withinSubject + + if (options.includeShadowDom) { const root = options.withinSubject || cy.state('document') const elementsWithShadow = $dom.findAllShadowRoots(root) - elementsWithShadow.push(root) - - $el = cy.$$(selector, elementsWithShadow) + scope = elementsWithShadow.concat(root) } + $el = cy.$$(selector, scope) + // jQuery v3 has removed its deprecated properties like ".selector" // https://jquery.com/upgrade-guide/3.0/breaking-change-deprecated-context-and-selector-properties-removed // but our error messages use this property to actually show the missing element @@ -302,16 +297,17 @@ module.exports = (Commands, Cypress, cy, state) => { if ($el.selector == null) { $el.selector = selector } - } catch (e) { - e.onFail = () => { + } catch (err) { + // this is usually a sizzle error (invalid selector) + err.onFail = () => { if (options.log === false) { - return e + return err } - options._log.error(e) + options._log.error(err) } - throw e + throw err } // if that didnt find anything and we have a within subject diff --git a/packages/driver/src/cypress/server.js b/packages/driver/src/cypress/server.js index cada34fc1359..4610d8f91acf 100644 --- a/packages/driver/src/cypress/server.js +++ b/packages/driver/src/cypress/server.js @@ -66,8 +66,16 @@ const warnOnForce404Default = (obj) => { } const whitelist = (xhr) => { + const url = new URL(xhr.url) + + // https://github.com/cypress-io/cypress/issues/7280 + // we want to strip the xhr's URL of any hash and query params before + // checking the REGEX for matching file extensions + url.search = '' + url.hash = '' + // whitelist if we're GET + looks like we're fetching regular resources - return xhr.method === 'GET' && regularResourcesRe.test(xhr.url) + return xhr.method === 'GET' && regularResourcesRe.test(url.href) } const serverDefaults = { diff --git a/packages/electron/package.json b/packages/electron/package.json index 1f3d82074ae3..a359834160ea 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -24,7 +24,7 @@ "minimist": "1.2.5" }, "devDependencies": { - "electron": "8.3.1", + "electron": "9.0.5", "mocha": "3.5.3" }, "files": [ diff --git a/packages/runner/src/lib/dom.js b/packages/runner/src/lib/dom.js index a5474931423a..9530228040ad 100644 --- a/packages/runner/src/lib/dom.js +++ b/packages/runner/src/lib/dom.js @@ -73,6 +73,8 @@ function addElementBoxModelLayers ($el, $body) { const $container = $('
') .css({ opacity: 0.7, + position: 'absolute', + zIndex: 2147483647, }) const layers = { diff --git a/packages/server/__snapshots__/5_stdout_spec.js b/packages/server/__snapshots__/5_stdout_spec.js index 255569726d5c..bc8aa9edb69e 100644 --- a/packages/server/__snapshots__/5_stdout_spec.js +++ b/packages/server/__snapshots__/5_stdout_spec.js @@ -523,3 +523,26 @@ exports['e2e stdout / displays assertion errors'] = ` ` + +exports['e2e stdout respects quiet mode 1'] = ` + + + stdout_passing_spec + file + ✓ visits file + google + ✓ visits google + ✓ google2 + apple + ✓ apple1 + ✓ visits apple + subdomains + ✓ cypress1 + ✓ visits cypress + ✓ cypress3 + + + 8 passing + + +` diff --git a/packages/server/lib/gui/auth.js b/packages/server/lib/gui/auth.js index 70aeaaca51e8..c66062b59327 100644 --- a/packages/server/lib/gui/auth.js +++ b/packages/server/lib/gui/auth.js @@ -19,6 +19,7 @@ let authState let openExternalAttempted = false let authRedirectReached = false let server +let utm const _buildLoginRedirectUrl = (server) => { const { port } = server.address() @@ -26,7 +27,7 @@ const _buildLoginRedirectUrl = (server) => { return `http://127.0.0.1:${port}/redirect-to-auth` } -const _buildFullLoginUrl = (baseLoginUrl, server) => { +const _buildFullLoginUrl = (baseLoginUrl, server, utmCode) => { const { port } = server.address() if (!authState) { @@ -45,6 +46,16 @@ const _buildFullLoginUrl = (baseLoginUrl, server) => { platform: os.platform(), } + if (utmCode) { + authUrl.query = { + utm_source: 'Test Runner', + utm_medium: 'Login Button', + utm_campaign: 'TR-Dashboard', + utm_content: utmCode, + ...authUrl.query, + } + } + return authUrl.format() }) } @@ -58,7 +69,7 @@ const _getOriginFromUrl = (originalUrl) => { /** * @returns a promise that is resolved with a user when auth is complete or rejected when it fails */ -const start = (onMessage) => { +const start = (onMessage, utmCode) => { function sendMessage (type, name, arg1) { onMessage({ type, @@ -68,6 +79,7 @@ const start = (onMessage) => { }) } + utm = utmCode authRedirectReached = false return user.getBaseLoginUrl() @@ -110,7 +122,7 @@ const _launchServer = (baseLoginUrl, sendMessage) => { app.get('/redirect-to-auth', (req, res) => { authRedirectReached = true - _buildFullLoginUrl(baseLoginUrl, server) + _buildFullLoginUrl(baseLoginUrl, server, utm) .then((fullLoginUrl) => { debug('Received GET to /redirect-to-auth, redirecting: %o', { fullLoginUrl }) diff --git a/packages/server/lib/gui/events.js b/packages/server/lib/gui/events.js index f58ff0ac9851..e120ead901b9 100644 --- a/packages/server/lib/gui/events.js +++ b/packages/server/lib/gui/events.js @@ -155,7 +155,7 @@ const handleEvent = function (options, bus, event, id, type, arg) { return bus.emit('auth:message', msg) } - return auth.start(onMessage) + return auth.start(onMessage, arg) .then(send) .catch(sendErr) diff --git a/packages/server/lib/modes/run.js b/packages/server/lib/modes/run.js index d59c89c870eb..cc8bb57c46f6 100644 --- a/packages/server/lib/modes/run.js +++ b/packages/server/lib/modes/run.js @@ -813,7 +813,7 @@ module.exports = { console.log('') }, - async postProcessRecording (name, cname, videoCompression, shouldUploadVideo) { + async postProcessRecording (name, cname, videoCompression, shouldUploadVideo, quiet) { debug('ending the video recording %o', { name, videoCompression, shouldUploadVideo }) // once this ended promises resolves @@ -824,6 +824,14 @@ module.exports = { return } + function continueProcessing (onProgress = undefined) { + return videoCapture.process(name, cname, videoCompression, onProgress) + } + + if (quiet) { + return continueProcessing() + } + console.log('') terminal.header('Video', { @@ -897,7 +905,7 @@ module.exports = { } } - return videoCapture.process(name, cname, videoCompression, onProgress) + return continueProcessing(onProgress) }, launchBrowser (options = {}) { @@ -1061,7 +1069,7 @@ module.exports = { }, waitForTestsToFinishRunning (options = {}) { - const { project, screenshots, startedVideoCapture, endVideoCapture, videoName, compressedVideoName, videoCompression, videoUploadOnPasses, exit, spec, estimated } = options + const { project, screenshots, startedVideoCapture, endVideoCapture, videoName, compressedVideoName, videoCompression, videoUploadOnPasses, exit, spec, estimated, quiet } = options // https://github.com/cypress-io/cypress/issues/2370 // delay 1 second if we're recording a video to give @@ -1095,10 +1103,11 @@ module.exports = { return obj } - this.displayResults(obj, estimated) - - if (screenshots && screenshots.length) { - this.displayScreenshots(screenshots) + if (!quiet) { + this.displayResults(obj, estimated) + if (screenshots && screenshots.length) { + this.displayScreenshots(screenshots) + } } const { tests, stats } = obj @@ -1137,6 +1146,7 @@ module.exports = { compressedVideoName, videoCompression, suv, + quiet, ) .catch(warnVideoRecordingFailed) } @@ -1191,19 +1201,23 @@ module.exports = { config, } - displayRunStarting({ - config, - specs, - group, - tag, - runUrl, - browser, - parallel, - specPattern, - }) + if (!options.quiet) { + displayRunStarting({ + config, + specs, + group, + tag, + runUrl, + browser, + parallel, + specPattern, + }) + } const runEachSpec = (spec, index, length, estimated) => { - displaySpecHeader(spec.name, index + 1, length, estimated) + if (!options.quiet) { + displaySpecHeader(spec.name, index + 1, length, estimated) + } return this.runSpec(spec, options, estimated) .get('results') @@ -1282,6 +1296,7 @@ module.exports = { exit: options.exit, videoCompression: options.videoCompression, videoUploadOnPasses: options.videoUploadOnPasses, + quiet: options.quiet, }), connection: this.waitForBrowserToConnect({ @@ -1322,6 +1337,7 @@ module.exports = { _.defaults(options, { isTextTerminal: true, browser: 'electron', + quiet: false, }) const socketId = random.id() @@ -1410,9 +1426,14 @@ module.exports = { videoUploadOnPasses: config.videoUploadOnPasses, exit: options.exit, headed: options.headed, + quiet: options.quiet, outputPath: options.outputPath, }) - .tap(renderSummaryTable(runUrl)) + .tap((runSpecs) => { + if (!options.quiet) { + renderSummaryTable(runUrl)(runSpecs) + } + }) } if (record) { diff --git a/packages/server/lib/util/args.js b/packages/server/lib/util/args.js index 45393095bc39..43184b043ea6 100644 --- a/packages/server/lib/util/args.js +++ b/packages/server/lib/util/args.js @@ -13,7 +13,7 @@ const nestedObjectsInCurlyBracesRe = /\{(.+?)\}/g const nestedArraysInSquareBracketsRe = /\[(.+?)\]/g const everythingAfterFirstEqualRe = /=(.*)/ -const whitelist = 'appPath apiKey browser ci ciBuildId clearLogs config configFile cwd env execPath exit exitWithCode generateKey getKey group headed inspectBrk key logs mode outputPath parallel ping port project proxySource record reporter reporterOptions returnPkg runMode runProject smokeTest spec tag updating version'.split(' ') +const whitelist = 'appPath apiKey browser ci ciBuildId clearLogs config configFile cwd env execPath exit exitWithCode generateKey getKey group headed inspectBrk key logs mode outputPath parallel ping port project proxySource quiet record reporter reporterOptions returnPkg runMode runProject smokeTest spec tag updating version'.split(' ') // returns true if the given string has double quote character " // only at the last position. const hasStrayEndQuote = (s) => { @@ -34,17 +34,27 @@ const normalizeBackslash = (s) => { return s } +/** + * remove stray double quote from runProject and other path properties + * due to bug in NPM passing arguments with backslash at the end + * @see https://github.com/cypress-io/cypress/issues/535 + * + */ const normalizeBackslashes = (options) => { - // remove stray double quote from runProject and other path properties - // due to bug in NPM passing arguments with - // backslash at the end - // https://github.com/cypress-io/cypress/issues/535 // these properties are paths and likely to have backslash on Windows const pathProperties = ['runProject', 'project', 'appPath', 'execPath', 'configFile'] pathProperties.forEach((property) => { - if (options[property]) { + // sometimes a string parameter might get parsed into a boolean + // for example "--project ''" will be transformed in "project: true" + // which we should treat as undefined + if (typeof options[property] === 'string') { options[property] = normalizeBackslash(options[property]) + } else { + // configFile is a special case that can be set to false + if (property !== 'configFile') { + delete options[property] + } } }) @@ -148,6 +158,8 @@ const sanitizeAndConvertNestedArgs = (str, argname) => { } module.exports = { + normalizeBackslashes, + toObject (argv) { debug('argv array: %o', argv) @@ -210,7 +222,12 @@ module.exports = { let { spec } = options const { env, config, reporterOptions, outputPath, tag } = options - const project = options.project || options.runProject + let project = options.project || options.runProject + + // only accept project if it is a string + if (typeof project !== 'string') { + project = undefined + } if (spec) { const resolvePath = (p) => { diff --git a/packages/server/lib/util/node_options.ts b/packages/server/lib/util/node_options.ts index a948dc04ca85..0acc0246443b 100644 --- a/packages/server/lib/util/node_options.ts +++ b/packages/server/lib/util/node_options.ts @@ -3,7 +3,7 @@ import debugModule from 'debug' const debug = debugModule('cypress:server:util:node_options') -const NODE_OPTIONS = `--max-http-header-size=${1024 ** 2} --http-parser=legacy` +export const NODE_OPTIONS = `--max-http-header-size=${1024 ** 2} --http-parser=legacy` /** * If Cypress was not launched via CLI, it may be missing certain startup @@ -63,8 +63,9 @@ export function forkWithCorrectOptions (): void { { stdio: 'inherit' }, ) .on('error', () => {}) - .on('exit', (code) => { - process.exit(code) + .on('exit', (code, signal) => { + debug('child exited %o', { code, signal }) + process.exit(code === null ? 1 : code) }) } diff --git a/packages/server/package.json b/packages/server/package.json index 7520f5988095..8c392934785d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -100,7 +100,7 @@ "semver": "6.3.0", "send": "0.17.1", "shell-env": "3.0.0", - "signal-exit": "3.0.2", + "signal-exit": "3.0.3", "squirrelly": "7.9.2", "strip-ansi": "6.0.0", "syntax-error": "1.4.0", diff --git a/packages/server/test/e2e/5_stdout_spec.js b/packages/server/test/e2e/5_stdout_spec.js index 412761cde255..969521025711 100644 --- a/packages/server/test/e2e/5_stdout_spec.js +++ b/packages/server/test/e2e/5_stdout_spec.js @@ -28,6 +28,15 @@ describe('e2e stdout', () => { }) }) + it('respects quiet mode', function () { + return e2e.exec(this, { + spec: 'stdout_passing_spec.coffee', + timeout: 120000, + snapshot: true, + quiet: true, + }) + }) + it('displays fullname of nested specfile', function () { return e2e.exec(this, { port: 2020, diff --git a/packages/server/test/scripts/run.js b/packages/server/test/scripts/run.js index f30e816605ff..0a1ab38e1c52 100644 --- a/packages/server/test/scripts/run.js +++ b/packages/server/test/scripts/run.js @@ -164,4 +164,10 @@ console.log(cmd) const child = execa.shell(cmd, { env, stdio: 'inherit' }) -child.on('exit', process.exit) +child.on('exit', (code, signal) => { + if (signal) { + console.error(`tests exited with signal ${signal}`) + } + + process.exit(code === null ? 1 : code) +}) diff --git a/packages/server/test/support/helpers/e2e.ts b/packages/server/test/support/helpers/e2e.ts index 1ed61d76b408..1f4dfbe25c62 100644 --- a/packages/server/test/support/helpers/e2e.ts +++ b/packages/server/test/support/helpers/e2e.ts @@ -509,6 +509,10 @@ const e2e = { args.push('--record') } + if (options.quiet) { + args.push('--quiet') + } + if (options.parallel) { args.push('--parallel') } diff --git a/packages/server/test/unit/args_spec.js b/packages/server/test/unit/args_spec.js index a09082c159bf..d7be0988e085 100644 --- a/packages/server/test/unit/args_spec.js +++ b/packages/server/test/unit/args_spec.js @@ -24,6 +24,37 @@ describe('lib/util/args', () => { }) }) + context('normalizeBackslashes', () => { + it('sets non-string properties to undefined', () => { + const input = { + // string properties + project: true, + appPath: '/foo/bar', + // this option can be string or false + configFile: false, + // unknown properties will be preserved + somethingElse: 42, + } + const output = argsUtil.normalizeBackslashes(input) + + expect(output).to.deep.equal({ + appPath: '/foo/bar', + configFile: false, + somethingElse: 42, + }) + }) + + it('handles empty project path string', () => { + const input = { + project: '', + } + const output = argsUtil.normalizeBackslashes(input) + + // empty project path remains + expect(output).to.deep.equal(input) + }) + }) + context('--project', () => { it('sets projectRoot', function () { const projectRoot = path.resolve(cwd, './foo/bar') @@ -31,6 +62,18 @@ describe('lib/util/args', () => { expect(options.projectRoot).to.eq(projectRoot) }) + + it('is undefined if not specified', function () { + const options = this.setup() + + expect(options.projectRoot).to.eq(undefined) + }) + + it('handles bool project parameter', function () { + const options = this.setup('--project', true) + + expect(options.projectRoot).to.eq(undefined) + }) }) context('--run-project', () => { diff --git a/packages/server/test/unit/gui/auth_spec.js b/packages/server/test/unit/gui/auth_spec.js index 50ea9a93dda6..646b13b31b14 100644 --- a/packages/server/test/unit/gui/auth_spec.js +++ b/packages/server/test/unit/gui/auth_spec.js @@ -13,6 +13,7 @@ const RANDOM_STRING = 'a'.repeat(32) const PORT = 9001 const REDIRECT_URL = `http://127.0.0.1:${PORT}/redirect-to-auth` const FULL_LOGIN_URL = `https://foo.invalid/login.html?port=${PORT}&state=${RANDOM_STRING}&machineId=abc123&cypressVersion=${pkg.version}&platform=linux` +const FULL_LOGIN_URL_UTM = `https://foo.invalid/login.html?utm_source=Test%20Runner&utm_medium=Login%20Button&utm_campaign=TR-Dashboard&utm_content=Login%20Button&port=${PORT}&state=${RANDOM_STRING}&machineId=abc123&cypressVersion=${pkg.version}&platform=linux` describe('lib/gui/auth', function () { beforeEach(() => { @@ -66,6 +67,13 @@ describe('lib/gui/auth', function () { expect(random.id).to.be.calledOnce }) }) + + it('uses utm code to form a trackable URL', function () { + return auth._buildFullLoginUrl(BASE_URL, this.server, 'Login Button') + .then((url) => { + expect(url).to.eq(FULL_LOGIN_URL_UTM) + }) + }) }) context('._launchNativeAuth', function () { diff --git a/packages/server/test/unit/node_options_spec.ts b/packages/server/test/unit/node_options_spec.ts new file mode 100644 index 000000000000..130f372d035c --- /dev/null +++ b/packages/server/test/unit/node_options_spec.ts @@ -0,0 +1,63 @@ +import '../spec_helper' +import sinon from 'sinon' +import { expect } from 'chai' +import cp, { ChildProcess } from 'child_process' +import { EventEmitter } from 'events' +import * as nodeOptions from '../../lib/util/node_options' +import mockedEnv from 'mocked-env' + +describe('NODE_OPTIONS lib', function () { + context('.forkWithCorrectOptions', function () { + let fakeProc: EventEmitter + let restoreEnv + + beforeEach(() => { + restoreEnv = mockedEnv({ + NODE_OPTIONS: '', + ORIGINAL_NODE_OPTIONS: '', + }) + }) + + afterEach(() => { + restoreEnv() + }) + + it('modifies NODE_OPTIONS', function () { + process.env.NODE_OPTIONS = 'foo' + expect(process.env.NODE_OPTIONS).to.eq('foo') + sinon.stub(cp, 'spawn').callsFake(() => { + expect(process.env).to.include({ + NODE_OPTIONS: `${nodeOptions.NODE_OPTIONS} foo`, + ORIGINAL_NODE_OPTIONS: 'foo', + }) + + return null as ChildProcess // types + }) + }) + + context('when exiting', function () { + beforeEach(() => { + fakeProc = new EventEmitter() + + sinon.stub(cp, 'spawn') + .withArgs(process.execPath, sinon.match.any, { stdio: 'inherit' }) + .returns(fakeProc as ChildProcess) + + sinon.stub(process, 'exit') + }) + + it('propagates exit codes correctly', function () { + nodeOptions.forkWithCorrectOptions() + fakeProc.emit('exit', 123) + expect(process.exit).to.be.calledWith(123) + }) + + // @see https://github.com/cypress-io/cypress/issues/7722 + it('propagates signals via a non-zero exit code', function () { + nodeOptions.forkWithCorrectOptions() + fakeProc.emit('exit', null, 'SIGKILL') + expect(process.exit).to.be.calledWith(1) + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index a0ed2fe132cb..8b51e1480ed4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4857,10 +4857,10 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= -angular@1.7.9: - version "1.7.9" - resolved "https://registry.yarnpkg.com/angular/-/angular-1.7.9.tgz#e52616e8701c17724c3c238cfe4f9446fd570bc4" - integrity sha512-5se7ZpcOtu0MBFlzGv5dsM1quQDoDeUTwZrWjGtTNA7O88cD8TEk5IEKCTDa3uECV9XnvKREVUr7du1ACiWGFQ== +angular@1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/angular/-/angular-1.8.0.tgz#b1ec179887869215cab6dfd0df2e42caa65b1b51" + integrity sha512-VdaMx+Qk0Skla7B5gw77a8hzlcOakwF8mjlW13DpIWIDlfqwAbSSLfd8N/qZnzEmQF4jC4iofInd3gE7vL8ZZg== ansi-align@^2.0.0: version "2.0.0" @@ -10452,10 +10452,10 @@ electron-to-chromium@^1.3.413: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.441.tgz#094f71b992dca5bc96b798cfbaf37dc76302015a" integrity sha512-leBfJwLuyGs1jEei2QioI+PjVMavmUIvPYidE8dCCYWLAq0uefhN3NYgDNb8WxD3uiUNnJ3ScMXg0upSlwySzQ== -electron@8.3.1: - version "8.3.1" - resolved "https://registry.yarnpkg.com/electron/-/electron-8.3.1.tgz#79e98c4d5b8e7c09a8a811f1aa78903f0c692721" - integrity sha512-VZpgLVFyD2SwFDkO9rwUcNgrAMah+g38FEtALGxli8bRVTbcHl8bt21szfa0YUWpc6hWcaf6JdZjqDS5q73Bsg== +electron@9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/electron/-/electron-9.0.5.tgz#189ee117cc2a2777cccf40fae0766acec5faae57" + integrity sha512-bnL9H48LuQ250DML8xUscsKiuSu+xv5umXbpBXYJ0BfvYVmFfNbG3jCfhrsH7aP6UcQKVxOG1R/oQExd0EFneQ== dependencies: "@electron/get" "^1.0.1" "@types/node" "^12.0.12" @@ -22420,12 +22420,7 @@ sigmund@~1.0.0: resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA= -signal-exit@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= - -signal-exit@^3.0.0, signal-exit@^3.0.2: +signal-exit@3.0.3, signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==