From 297e41b125ef86c6e12d4345d73e834ba3c7cbac Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 11:21:20 -0400 Subject: [PATCH 01/15] factor console logging out of run.ts --- packages/server/lib/modes/run.ts | 626 ++------------------------ packages/server/lib/util/print-run.ts | 536 ++++++++++++++++++++++ 2 files changed, 580 insertions(+), 582 deletions(-) create mode 100644 packages/server/lib/util/print-run.ts diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 6d2f7ab0b464..b285e377e530 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -1,13 +1,11 @@ -/* eslint-disable no-console, @cypress/dev/arrow-body-multiline-braces */ +/* eslint-disable no-console */ import _ from 'lodash' import la from 'lazy-ass' import pkg from '@packages/root' import path from 'path' import chalk from 'chalk' -import human from 'human-interval' import Debug from 'debug' import Bluebird from 'bluebird' -import logSymbols from 'log-symbols' import assert from 'assert' import recordMode from './record' @@ -22,25 +20,16 @@ import env from '../util/env' import trash from '../util/trash' import random from '../util/random' import system from '../util/system' -import duration from '../util/duration' -import newlines from '../util/newlines' -import terminal from '../util/terminal' -import humanTime from '../util/human_time' import chromePolicyCheck from '../util/chrome_policy_check' -import * as experiments from '../experiments' import * as objUtils from '../util/obj_utils' import type { SpecWithRelativeRoot, LaunchOpts, SpecFile, TestingType } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' +import * as printResults from '../util/print-run' -type Screenshot = { - width: number - height: number - path: string - specName: string -} -type SetScreenshotMetadata = (data: Screenshot) => void +type SetScreenshotMetadata = (data: TakeScreenshotProps) => void type ScreenshotMetadata = ReturnType +type TakeScreenshotProps = any type RunEachSpec = any type BeforeSpecRun = any type AfterSpecRun = any @@ -59,43 +48,6 @@ const debug = Debug('cypress:server:run') const DELAY_TO_LET_VIDEO_FINISH_MS = 1000 -const color = (val, c) => { - return chalk[c](val) -} - -const gray = (val) => { - return color(val, 'gray') -} - -const colorIf = function (val, c) { - if (val === 0 || val == null) { - val = '-' - c = 'gray' - } - - return color(val, c) -} - -const getSymbol = function (num?: number) { - if (num) { - return logSymbols.error - } - - return logSymbols.success -} - -const getWidth = (table, index) => { - // get the true width of a table's column, - // based off of calculated table options for that column - const columnWidth = table.options.colWidths[index] - - if (columnWidth) { - return columnWidth - (table.options.style['padding-left'] + table.options.style['padding-right']) - } - - throw new Error('Unable to get width for column') -} - const relativeSpecPattern = (projectRoot, pattern) => { if (typeof pattern === 'string') { return pattern.replace(`${projectRoot}/`, '') @@ -104,353 +56,6 @@ const relativeSpecPattern = (projectRoot, pattern) => { return pattern.map((x) => x.replace(`${projectRoot}/`, '')) } -const formatBrowser = (browser) => { - // TODO: finish browser - return _.compact([ - browser.displayName, - browser.majorVersion, - browser.isHeadless && gray('(headless)'), - ]).join(' ') -} - -const formatFooterSummary = (results) => { - const { totalFailed, runs } = results - - const isCanceled = _.some(results.runs, { skippedSpec: true }) - - // pass or fail color - const c = isCanceled ? 'magenta' : totalFailed ? 'red' : 'green' - - const phrase = (() => { - if (isCanceled) { - return 'The run was canceled' - } - - // if we have any specs failing... - if (!totalFailed) { - return 'All specs passed!' - } - - // number of specs - const total = runs.length - const failingRuns = _.filter(runs, 'stats.failures').length - const percent = Math.round((failingRuns / total) * 100) - - return `${failingRuns} of ${total} failed (${percent}%)` - })() - - return [ - isCanceled ? '-' : formatSymbolSummary(totalFailed), - color(phrase, c), - gray(duration.format(results.totalDuration)), - colorIf(results.totalTests, 'reset'), - colorIf(results.totalPassed, 'green'), - colorIf(totalFailed, 'red'), - colorIf(results.totalPending, 'cyan'), - colorIf(results.totalSkipped, 'blue'), - ] -} - -const formatSymbolSummary = (failures) => { - return getSymbol(failures) -} - -const macOSRemovePrivate = (str: string): string => { - // consistent snapshots when running system tests on macOS - if (process.platform === 'darwin' && str.startsWith('/private')) { - return str.slice(8) - } - - return str -} - -const formatPath = (name, n, colour = 'reset', caller?) => { - if (!name) return '' - - const fakeCwdPath = env.get('FAKE_CWD_PATH') - - if (fakeCwdPath && env.get('CYPRESS_INTERNAL_ENV') === 'test') { - // if we're testing within Cypress, we want to strip out - // the current working directory before calculating the stdout tables - // this will keep our snapshots consistent everytime we run - const cwdPath = process.cwd() - - name = name - .split(cwdPath) - .join(fakeCwdPath) - - name = macOSRemovePrivate(name) - } - - // add newLines at each n char and colorize the path - if (n) { - let nameWithNewLines = newlines.addNewlineAtEveryNChar(name, n) - - return `${color(nameWithNewLines, colour)}` - } - - return `${color(name, colour)}` -} - -const formatNodeVersion = ({ resolvedNodeVersion, resolvedNodePath }: Pick, width) => { - debug('formatting Node version. %o', { version: resolvedNodeVersion, path: resolvedNodePath }) - - if (resolvedNodePath) return formatPath(`v${resolvedNodeVersion} ${gray(`(${resolvedNodePath})`)}`, width) - - return -} - -const formatRecordParams = function (runUrl, parallel, group, tag) { - if (runUrl) { - if (!group) { - group = false - } - - if (!tag) { - tag = false - } - - return `Tag: ${tag}, Group: ${group}, Parallel: ${Boolean(parallel)}` - } - - return -} - -const displayRunStarting = function (options: { browser: Browser, config: Cfg, group: string | undefined, parallel?: boolean, runUrl?: string, specPattern: string | RegExp | string[], specs: SpecFile[], tag: string | undefined }) { - const { browser, config, group, parallel, runUrl, specPattern, specs, tag } = options - - console.log('') - - terminal.divider('=') - - console.log('') - - terminal.header('Run Starting', { - color: ['reset'], - }) - - console.log('') - - const experimental = experiments.getExperimentsFromResolved(config.resolved) - const enabledExperiments = _.pickBy(experimental, _.property('enabled')) - const hasExperiments = !_.isEmpty(enabledExperiments) - - // if we show Node Version, then increase 1st column width - // to include wider 'Node Version:'. - // Without Node version, need to account for possible "Experiments" label - const colWidths = config.resolvedNodePath ? [16, 84] : ( - hasExperiments ? [14, 86] : [12, 88] - ) - - const table = terminal.table({ - colWidths, - type: 'outsideBorder', - }) - - const formatSpecPattern = (projectRoot, specPattern) => { - // foo.spec.js, bar.spec.js, baz.spec.js - // also inserts newlines at col width - if (typeof specPattern === 'string') { - specPattern = [specPattern] - } - - specPattern = relativeSpecPattern(projectRoot, specPattern) - - if (specPattern) { - return formatPath(specPattern.join(', '), getWidth(table, 1)) - } - - throw new Error('No specPattern in formatSpecPattern') - } - - const formatSpecs = (specs) => { - // 25 found: (foo.spec.js, bar.spec.js, baz.spec.js) - const names = _.map(specs, 'baseName') - const specsTruncated = _.truncate(names.join(', '), { length: 250 }) - - const stringifiedSpecs = [ - `${names.length} found `, - '(', - specsTruncated, - ')', - ] - .join('') - - return formatPath(stringifiedSpecs, getWidth(table, 1)) - } - - const data = _ - .chain([ - [gray('Cypress:'), pkg.version], - [gray('Browser:'), formatBrowser(browser)], - [gray('Node Version:'), formatNodeVersion(config, getWidth(table, 1))], - [gray('Specs:'), formatSpecs(specs)], - [gray('Searched:'), formatSpecPattern(config.projectRoot, specPattern)], - [gray('Params:'), formatRecordParams(runUrl, parallel, group, tag)], - [gray('Run URL:'), runUrl ? formatPath(runUrl, getWidth(table, 1)) : ''], - [gray('Experiments:'), hasExperiments ? experiments.formatExperiments(enabledExperiments) : ''], - ]) - .filter(_.property(1)) - .value() - - table.push(...data) - - const heading = table.toString() - - console.log(heading) - - console.log('') - - return heading -} - -const displaySpecHeader = function (name, curr, total, estimated) { - console.log('') - - const PADDING = 2 - - const table = terminal.table({ - colWidths: [10, 70, 20], - colAligns: ['left', 'left', 'right'], - type: 'pageDivider', - style: { - 'padding-left': PADDING, - 'padding-right': 0, - }, - }) - - table.push(['', '']) - table.push([ - 'Running:', - `${formatPath(name, getWidth(table, 1), 'gray')}`, - gray(`(${curr} of ${total})`), - ]) - - console.log(table.toString()) - - if (estimated) { - const estimatedLabel = `${' '.repeat(PADDING)}Estimated:` - - return console.log(estimatedLabel, gray(humanTime.long(estimated))) - } -} - -const collectTestResults = (obj: { video?: boolean, screenshots?: Screenshot[] }, estimated) => { - return { - name: _.get(obj, 'spec.name'), - baseName: _.get(obj, 'spec.baseName'), - tests: _.get(obj, 'stats.tests'), - passes: _.get(obj, 'stats.passes'), - pending: _.get(obj, 'stats.pending'), - failures: _.get(obj, 'stats.failures'), - skipped: _.get(obj, 'stats.skipped'), - duration: humanTime.long(_.get(obj, 'stats.wallClockDuration')), - estimated: estimated && humanTime.long(estimated), - screenshots: obj.screenshots && obj.screenshots.length, - video: Boolean(obj.video), - } -} - -const renderSummaryTable = (runUrl) => { - return function (results) { - const { runs } = results - - console.log('') - - terminal.divider('=') - - console.log('') - - terminal.header('Run Finished', { - color: ['reset'], - }) - - if (runs && runs.length) { - const colAligns = ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'] - const colWidths = [3, 41, 11, 9, 9, 9, 9, 9] - - const table1 = terminal.table({ - colAligns, - colWidths, - type: 'noBorder', - head: [ - '', - gray('Spec'), - '', - gray('Tests'), - gray('Passing'), - gray('Failing'), - gray('Pending'), - gray('Skipped'), - ], - }) - - const table2 = terminal.table({ - colAligns, - colWidths, - type: 'border', - }) - - const table3 = terminal.table({ - colAligns, - colWidths, - type: 'noBorder', - head: formatFooterSummary(results), - }) - - _.each(runs, (run) => { - const { spec, stats } = run - - const ms = duration.format(stats.wallClockDuration || 0) - - const formattedSpec = formatPath(spec.baseName, getWidth(table2, 1)) - - if (run.skippedSpec) { - return table2.push([ - '-', - formattedSpec, color('SKIPPED', 'gray'), - '-', '-', '-', '-', '-', - ]) - } - - return table2.push([ - formatSymbolSummary(stats.failures), - formattedSpec, - color(ms, 'gray'), - colorIf(stats.tests, 'reset'), - colorIf(stats.passes, 'green'), - colorIf(stats.failures, 'red'), - colorIf(stats.pending, 'cyan'), - colorIf(stats.skipped, 'blue'), - ]) - }) - - console.log('') - console.log('') - console.log(terminal.renderTables(table1, table2, table3)) - console.log('') - - if (runUrl) { - console.log('') - - const table4 = terminal.table({ - colWidths: [100], - type: 'pageDivider', - style: { - 'padding-left': 2, - }, - }) - - table4.push(['', '']) - console.log(terminal.renderTables(table4)) - - console.log(` Recorded Run: ${formatPath(runUrl, undefined, 'gray')}`) - console.log('') - } - } - } -} - const iterateThroughSpecs = function (options: { specs: SpecFile[], runEachSpec: RunEachSpec, beforeSpecRun?: BeforeSpecRun, afterSpecRun?: AfterSpecRun, config: Cfg }) { const { specs, runEachSpec, beforeSpecRun, afterSpecRun, config } = options @@ -766,84 +371,6 @@ function navigateToNextSpec (spec) { return openProject.changeUrlToSpec(spec) } -function displayResults (obj = {}, estimated) { - const results = collectTestResults(obj, estimated) - - const c = results.failures ? 'red' : 'green' - - console.log('') - - terminal.header('Results', { - color: [c], - }) - - const table = terminal.table({ - colWidths: [14, 86], - type: 'outsideBorder', - }) - - const data = _.chain([ - ['Tests:', results.tests], - ['Passing:', results.passes], - ['Failing:', results.failures], - ['Pending:', results.pending], - ['Skipped:', results.skipped], - ['Screenshots:', results.screenshots], - ['Video:', results.video], - ['Duration:', results.duration], - estimated ? ['Estimated:', results.estimated] : undefined, - ['Spec Ran:', formatPath(results.baseName, getWidth(table, 1), c)], - ]) - .compact() - .map((arr) => { - const [key, val] = arr - - return [color(key, 'gray'), color(val, c)] - }) - .value() - - table.push(...data) - - console.log('') - console.log(table.toString()) - console.log('') -} - -function displayScreenshots (screenshots: Screenshot[] = []) { - console.log('') - - terminal.header('Screenshots', { color: ['yellow'] }) - - console.log('') - - const table = terminal.table({ - colWidths: [3, 82, 15], - colAligns: ['left', 'left', 'right'], - type: 'noBorder', - style: { - 'padding-right': 0, - }, - chars: { - 'left': ' ', - 'right': '', - }, - }) - - screenshots.forEach((screenshot) => { - const dimensions = gray(`(${screenshot.width}x${screenshot.height})`) - - table.push([ - '-', - formatPath(`${screenshot.path}`, getWidth(table, 1)), - gray(dimensions), - ]) - }) - - console.log(table.toString()) - - console.log('') -} - async function postProcessRecording (name, cname, videoCompression, shouldUploadVideo, quiet, ffmpegChaptersConfig) { debug('ending the video recording %o', { name, videoCompression, shouldUploadVideo }) @@ -863,78 +390,7 @@ async function postProcessRecording (name, cname, videoCompression, shouldUpload return continueProcessing() } - console.log('') - - terminal.header('Video', { - color: ['cyan'], - }) - - console.log('') - - const table = terminal.table({ - colWidths: [3, 21, 76], - colAligns: ['left', 'left', 'left'], - type: 'noBorder', - style: { - 'padding-right': 0, - }, - chars: { - 'left': ' ', - 'right': '', - }, - }) - - table.push([ - gray('-'), - gray('Started processing:'), - chalk.cyan(`Compressing to ${videoCompression} CRF`), - ]) - - console.log(table.toString()) - - 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 = Date.now() - started - const dur = `(${humanTime.long(finished)})` - - const table = terminal.table({ - colWidths: [3, 21, 61, 15], - colAligns: ['left', 'left', 'left', 'right'], - type: 'noBorder', - style: { - 'padding-right': 0, - }, - chars: { - 'left': ' ', - 'right': '', - }, - }) - - table.push([ - gray('-'), - gray('Finished processing:'), - `${formatPath(name, getWidth(table, 2), 'cyan')}`, - gray(dur), - ]) - - console.log(table.toString()) - - console.log('') - } - - if (Date.now() - progress > throttle) { - // bump up the progress so we dont - // continuously get notifications - progress += throttle - const percentage = `${Math.ceil(float * 100)}%` - - console.log(' Compression progress: ', chalk.cyan(percentage)) - } - } + const { onProgress } = printResults.displayVideoProcessingProgress({ videoCompression }) return continueProcessing(onProgress) } @@ -1152,7 +608,7 @@ function waitForSocketConnection (project, id) { }) } -function waitForTestsToFinishRunning (options: { project: Project, screenshots: Screenshot[], startedVideoCapture?: any, endVideoCapture?: () => Promise, videoName?: string, compressedVideoName?: string, videoCompression: number | false, videoUploadOnPasses: boolean, exit: boolean, spec: SpecWithRelativeRoot, estimated: number, quiet: boolean, config: Cfg, shouldKeepTabOpen: boolean, testingType: TestingType}) { +function waitForTestsToFinishRunning (options: { project: Project, screenshots: ScreenshotMetadata[], startedVideoCapture?: any, endVideoCapture?: () => Promise, videoName?: string, compressedVideoName?: string, videoCompression: number | false, videoUploadOnPasses: boolean, exit: boolean, spec: SpecWithRelativeRoot, estimated: number, quiet: boolean, config: Cfg, shouldKeepTabOpen: boolean, testingType: TestingType}) { if (globalThis.CY_TEST_MOCK?.waitForTestsToFinishRunning) return Promise.resolve(globalThis.CY_TEST_MOCK.waitForTestsToFinishRunning) const { project, screenshots, startedVideoCapture, endVideoCapture, videoName, compressedVideoName, videoCompression, videoUploadOnPasses, exit, spec, estimated, quiet, config, shouldKeepTabOpen, testingType } = options @@ -1228,10 +684,7 @@ function waitForTestsToFinishRunning (options: { project: Project, screenshots: results.shouldUploadVideo = shouldUploadVideo if (!quiet && !skippedSpec) { - displayResults(results, estimated) - if (screenshots && screenshots.length) { - displayScreenshots(screenshots) - } + printResults.displayResults(results, estimated) } const project = openProject.getProject() @@ -1284,6 +737,7 @@ function screenshotMetadata (data, resp) { path: resp.path, height: resp.dimensions.height, width: resp.dimensions.width, + pathname: undefined as string | undefined, } } @@ -1298,7 +752,7 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea browser.isHeaded = !isHeadless if (!options.quiet) { - displayRunStarting({ + printResults.displayRunStarting({ config, specs, group, @@ -1314,7 +768,7 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea async function runEachSpec (spec: SpecWithRelativeRoot, index: number, length: number, estimated: number) { if (!options.quiet) { - displaySpecHeader(spec.baseName, index + 1, length, estimated) + printResults.displaySpecHeader(spec.baseName, index + 1, length, estimated) } const { results } = await runSpec(config, spec, options, estimated, isFirstSpec, index === length - 1) @@ -1379,34 +833,42 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea // Remap results for module API/after:run to remove private props and // rename props to make more user-friendly const moduleAPIResults = remapKeys(results, { - runs: each((run) => ({ - tests: each((test) => ({ - attempts: each((attempt, i) => ({ - timings: remove, - failedFromHookId: remove, + runs: each((run) => { + return { + tests: each((test) => { + return { + attempts: each((attempt, i) => { + return { + timings: remove, + failedFromHookId: remove, + wallClockDuration: renameKey('duration'), + wallClockStartedAt: renameKey('startedAt'), + wallClockEndedAt: renameKey('endedAt'), + screenshots: setValue( + _(run.screenshots) + .filter({ testId: test.testId, testAttemptIndex: i }) + .map((screenshot) => { + return _.omit(screenshot, + ['screenshotId', 'testId', 'testAttemptIndex']) + }) + .value(), + ), + } + }), + testId: remove, + } + }), + hooks: each({ + hookId: remove, + }), + stats: { wallClockDuration: renameKey('duration'), wallClockStartedAt: renameKey('startedAt'), wallClockEndedAt: renameKey('endedAt'), - screenshots: setValue( - _(run.screenshots) - .filter({ testId: test.testId, testAttemptIndex: i }) - .map((screenshot) => _.omit(screenshot, - ['screenshotId', 'testId', 'testAttemptIndex'])) - .value(), - ), - })), - testId: remove, - })), - hooks: each({ - hookId: remove, - }), - stats: { - wallClockDuration: renameKey('duration'), - wallClockStartedAt: renameKey('startedAt'), - wallClockEndedAt: renameKey('endedAt'), - }, - screenshots: remove, - })), + }, + screenshots: remove, + } + }), }) if (testingType === 'component') { @@ -1601,7 +1063,7 @@ async function ready (options: { projectRoot: string, record: boolean, key: stri }) if (!options.quiet) { - renderSummaryTable(runUrl)(results) + printResults.renderSummaryTable(runUrl, results) } return results diff --git a/packages/server/lib/util/print-run.ts b/packages/server/lib/util/print-run.ts new file mode 100644 index 000000000000..4cb92f9b07b8 --- /dev/null +++ b/packages/server/lib/util/print-run.ts @@ -0,0 +1,536 @@ +/* eslint-disable no-console */ +import _ from 'lodash' +import logSymbols from 'log-symbols' +import chalk from 'chalk' +import human from 'human-interval' +import pkg from '@packages/root' +import humanTime from './human_time' +import duration from './duration' +import newlines from './newlines' +import env from './env' +import terminal from './terminal' +import * as experiments from '../experiments' +import type { SpecFile } from '@packages/types' +import type { Cfg } from '../project-base' +import type { Browser } from '../browsers/types' + +type Screenshot = { + width: number + height: number + path: string + specName: string +} + +function color (val, c) { + return chalk[c](val) +} + +function gray (val) { + return color(val, 'gray') +} + +function colorIf (val, c) { + if (val === 0 || val == null) { + val = '-' + c = 'gray' + } + + return color(val, c) +} + +function getWidth (table, index) { + // get the true width of a table's column, + // based off of calculated table options for that column + const columnWidth = table.options.colWidths[index] + + if (columnWidth) { + return columnWidth - (table.options.style['padding-left'] + table.options.style['padding-right']) + } + + throw new Error('Unable to get width for column') +} + +function formatBrowser (browser) { + return _.compact([ + browser.displayName, + browser.majorVersion, + browser.isHeadless && gray('(headless)'), + ]).join(' ') +} + +function formatFooterSummary (results) { + const { totalFailed, runs } = results + + const isCanceled = _.some(results.runs, { skippedSpec: true }) + + // pass or fail color + const c = isCanceled ? 'magenta' : totalFailed ? 'red' : 'green' + + const phrase = (() => { + if (isCanceled) { + return 'The run was canceled' + } + + // if we have any specs failing... + if (!totalFailed) { + return 'All specs passed!' + } + + // number of specs + const total = runs.length + const failingRuns = _.filter(runs, 'stats.failures').length + const percent = Math.round((failingRuns / total) * 100) + + return `${failingRuns} of ${total} failed (${percent}%)` + })() + + return [ + isCanceled ? '-' : formatSymbolSummary(totalFailed), + color(phrase, c), + gray(duration.format(results.totalDuration)), + colorIf(results.totalTests, 'reset'), + colorIf(results.totalPassed, 'green'), + colorIf(totalFailed, 'red'), + colorIf(results.totalPending, 'cyan'), + colorIf(results.totalSkipped, 'blue'), + ] +} + +function formatSymbolSummary (failures) { + return failures ? logSymbols.error : logSymbols.success +} + +function macOSRemovePrivate (str) { + // consistent snapshots when running system tests on macOS + if (process.platform === 'darwin' && str.startsWith('/private')) { + return str.slice(8) + } + + return str +} + +function collectTestResults (obj: { video?: boolean, screenshots?: Screenshot[] }, estimated) { + return { + name: _.get(obj, 'spec.name'), + baseName: _.get(obj, 'spec.baseName'), + tests: _.get(obj, 'stats.tests'), + passes: _.get(obj, 'stats.passes'), + pending: _.get(obj, 'stats.pending'), + failures: _.get(obj, 'stats.failures'), + skipped: _.get(obj, 'stats.skipped'), + duration: humanTime.long(_.get(obj, 'stats.wallClockDuration')), + estimated: estimated && humanTime.long(estimated), + screenshots: obj.screenshots && obj.screenshots.length, + video: Boolean(obj.video), + } +} + +function formatPath (name, n, colour = 'reset') { + if (!name) return '' + + const fakeCwdPath = env.get('FAKE_CWD_PATH') + + if (fakeCwdPath && env.get('CYPRESS_INTERNAL_ENV') === 'test') { + // if we're testing within Cypress, we want to strip out + // the current working directory before calculating the stdout tables + // this will keep our snapshots consistent everytime we run + const cwdPath = process.cwd() + + name = name + .split(cwdPath) + .join(fakeCwdPath) + + name = macOSRemovePrivate(name) + } + + // add newLines at each n char and colorize the path + if (n) { + let nameWithNewLines = newlines.addNewlineAtEveryNChar(name, n) + + return `${color(nameWithNewLines, colour)}` + } + + return `${color(name, colour)}` +} + +function formatNodeVersion ({ resolvedNodeVersion, resolvedNodePath }: Pick, width) { + if (resolvedNodePath) return formatPath(`v${resolvedNodeVersion} ${gray(`(${resolvedNodePath})`)}`, width) + + return +} + +function formatRecordParams (runUrl, parallel, group, tag) { + if (runUrl) { + if (!group) { + group = false + } + + if (!tag) { + tag = false + } + + return `Tag: ${tag}, Group: ${group}, Parallel: ${Boolean(parallel)}` + } + + return +} + +export function displayRunStarting (options: { browser: Browser, config: Cfg, group: string | undefined, parallel?: boolean, runUrl?: string, specPattern: string | RegExp | string[], specs: SpecFile[], tag: string | undefined }) { + const { browser, config, group, parallel, runUrl, specPattern, specs, tag } = options + + console.log('') + + terminal.divider('=') + + console.log('') + + terminal.header('Run Starting', { + color: ['reset'], + }) + + console.log('') + + const experimental = experiments.getExperimentsFromResolved(config.resolved) + const enabledExperiments = _.pickBy(experimental, _.property('enabled')) + const hasExperiments = !_.isEmpty(enabledExperiments) + + // if we show Node Version, then increase 1st column width + // to include wider 'Node Version:'. + // Without Node version, need to account for possible "Experiments" label + const colWidths = config.resolvedNodePath ? [16, 84] : ( + hasExperiments ? [14, 86] : [12, 88] + ) + + const table = terminal.table({ + colWidths, + type: 'outsideBorder', + }) + + if (!specPattern) throw new Error('No specPattern in displayRunStarting') + + const formatSpecs = (specs) => { + // 25 found: (foo.spec.js, bar.spec.js, baz.spec.js) + const names = _.map(specs, 'baseName') + const specsTruncated = _.truncate(names.join(', '), { length: 250 }) + + const stringifiedSpecs = [ + `${names.length} found `, + '(', + specsTruncated, + ')', + ] + .join('') + + return formatPath(stringifiedSpecs, getWidth(table, 1)) + } + + const data = _ + .chain([ + [gray('Cypress:'), pkg.version], + [gray('Browser:'), formatBrowser(browser)], + [gray('Node Version:'), formatNodeVersion(config, getWidth(table, 1))], + [gray('Specs:'), formatSpecs(specs)], + [gray('Searched:'), formatPath(Array.isArray(specPattern) ? specPattern.join(', ') : specPattern, getWidth(table, 1))], + [gray('Params:'), formatRecordParams(runUrl, parallel, group, tag)], + [gray('Run URL:'), runUrl ? formatPath(runUrl, getWidth(table, 1)) : ''], + [gray('Experiments:'), hasExperiments ? experiments.formatExperiments(enabledExperiments) : ''], + ]) + .filter(_.property(1)) + .value() + + table.push(...data) + + const heading = table.toString() + + console.log(heading) + + console.log('') + + return heading +} + +export function displaySpecHeader (name, curr, total, estimated) { + console.log('') + + const PADDING = 2 + + const table = terminal.table({ + colWidths: [10, 70, 20], + colAligns: ['left', 'left', 'right'], + type: 'pageDivider', + style: { + 'padding-left': PADDING, + 'padding-right': 0, + }, + }) + + table.push(['', '']) + table.push([ + 'Running:', + `${formatPath(name, getWidth(table, 1), 'gray')}`, + gray(`(${curr} of ${total})`), + ]) + + console.log(table.toString()) + + if (estimated) { + const estimatedLabel = `${' '.repeat(PADDING)}Estimated:` + + return console.log(estimatedLabel, gray(humanTime.long(estimated))) + } +} + +export function renderSummaryTable (runUrl, results) { + const { runs } = results + + console.log('') + + terminal.divider('=') + + console.log('') + + terminal.header('Run Finished', { + color: ['reset'], + }) + + if (runs && runs.length) { + const colAligns = ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right'] + const colWidths = [3, 41, 11, 9, 9, 9, 9, 9] + + const table1 = terminal.table({ + colAligns, + colWidths, + type: 'noBorder', + head: [ + '', + gray('Spec'), + '', + gray('Tests'), + gray('Passing'), + gray('Failing'), + gray('Pending'), + gray('Skipped'), + ], + }) + + const table2 = terminal.table({ + colAligns, + colWidths, + type: 'border', + }) + + const table3 = terminal.table({ + colAligns, + colWidths, + type: 'noBorder', + head: formatFooterSummary(results), + }) + + _.each(runs, (run) => { + const { spec, stats } = run + + const ms = duration.format(stats.wallClockDuration || 0) + + const formattedSpec = formatPath(spec.baseName, getWidth(table2, 1)) + + if (run.skippedSpec) { + return table2.push([ + '-', + formattedSpec, color('SKIPPED', 'gray'), + '-', '-', '-', '-', '-', + ]) + } + + return table2.push([ + formatSymbolSummary(stats.failures), + formattedSpec, + color(ms, 'gray'), + colorIf(stats.tests, 'reset'), + colorIf(stats.passes, 'green'), + colorIf(stats.failures, 'red'), + colorIf(stats.pending, 'cyan'), + colorIf(stats.skipped, 'blue'), + ]) + }) + + console.log('') + console.log('') + console.log(terminal.renderTables(table1, table2, table3)) + console.log('') + + if (runUrl) { + console.log('') + + const table4 = terminal.table({ + colWidths: [100], + type: 'pageDivider', + style: { + 'padding-left': 2, + }, + }) + + table4.push(['', '']) + console.log(terminal.renderTables(table4)) + + console.log(` Recorded Run: ${formatPath(runUrl, undefined, 'gray')}`) + console.log('') + } + } +} + +export function displayResults (obj: { screenshots?: Screenshot[] }, estimated) { + const results = collectTestResults(obj, estimated) + + const c = results.failures ? 'red' : 'green' + + console.log('') + + terminal.header('Results', { + color: [c], + }) + + const table = terminal.table({ + colWidths: [14, 86], + type: 'outsideBorder', + }) + + const data = _.chain([ + ['Tests:', results.tests], + ['Passing:', results.passes], + ['Failing:', results.failures], + ['Pending:', results.pending], + ['Skipped:', results.skipped], + ['Screenshots:', results.screenshots], + ['Video:', results.video], + ['Duration:', results.duration], + estimated ? ['Estimated:', results.estimated] : undefined, + ['Spec Ran:', formatPath(results.baseName, getWidth(table, 1), c)], + ]) + .compact() + .map((arr) => { + const [key, val] = arr + + return [color(key, 'gray'), color(val, c)] + }) + .value() + + table.push(...data) + + console.log('') + console.log(table.toString()) + console.log('') + + if (obj.screenshots?.length) displayScreenshots(obj.screenshots) +} + +function displayScreenshots (screenshots: Screenshot[] = []) { + console.log('') + + terminal.header('Screenshots', { color: ['yellow'] }) + + console.log('') + + const table = terminal.table({ + colWidths: [3, 82, 15], + colAligns: ['left', 'left', 'right'], + type: 'noBorder', + style: { + 'padding-right': 0, + }, + chars: { + 'left': ' ', + 'right': '', + }, + }) + + screenshots.forEach((screenshot) => { + const dimensions = gray(`(${screenshot.width}x${screenshot.height})`) + + table.push([ + '-', + formatPath(`${screenshot.path}`, getWidth(table, 1)), + gray(dimensions), + ]) + }) + + console.log(table.toString()) + + console.log('') +} + +export function displayVideoProcessingProgress (opts: { videoCompression: number | false}) { + console.log('') + + terminal.header('Video', { + color: ['cyan'], + }) + + console.log('') + + const table = terminal.table({ + colWidths: [3, 21, 76], + colAligns: ['left', 'left', 'left'], + type: 'noBorder', + style: { + 'padding-right': 0, + }, + chars: { + 'left': ' ', + 'right': '', + }, + }) + + table.push([ + gray('-'), + gray('Started processing:'), + chalk.cyan(`Compressing to ${opts.videoCompression} CRF`), + ]) + + console.log(table.toString()) + + const started = Date.now() + let progress = Date.now() + const throttle = env.get('VIDEO_COMPRESSION_THROTTLE') || human('10 seconds') + + return { + onProgress (float: number) { + if (float === 1) { + const finished = Date.now() - started + const dur = `(${humanTime.long(finished)})` + + const table = terminal.table({ + colWidths: [3, 21, 61, 15], + colAligns: ['left', 'left', 'left', 'right'], + type: 'noBorder', + style: { + 'padding-right': 0, + }, + chars: { + 'left': ' ', + 'right': '', + }, + }) + + table.push([ + gray('-'), + gray('Finished processing:'), + `${formatPath(name, getWidth(table, 2), 'cyan')}`, + gray(dur), + ]) + + console.log(table.toString()) + + console.log('') + } + + if (Date.now() - progress > throttle) { + // bump up the progress so we dont + // continuously get notifications + progress += throttle + const percentage = `${Math.ceil(float * 100)}%` + + console.log(' Compression progress: ', chalk.cyan(percentage)) + } + }, + } +} From 173de9f5b2ed6ef4416c929fa55982815ae926c6 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 14:19:48 -0400 Subject: [PATCH 02/15] fix print-run --- packages/server/lib/modes/run.ts | 2 +- packages/server/lib/util/print-run.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index b285e377e530..ba7eaf522902 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -390,7 +390,7 @@ async function postProcessRecording (name, cname, videoCompression, shouldUpload return continueProcessing() } - const { onProgress } = printResults.displayVideoProcessingProgress({ videoCompression }) + const { onProgress } = printResults.displayVideoProcessingProgress({ name, videoCompression }) return continueProcessing(onProgress) } diff --git a/packages/server/lib/util/print-run.ts b/packages/server/lib/util/print-run.ts index 4cb92f9b07b8..31450cebabfe 100644 --- a/packages/server/lib/util/print-run.ts +++ b/packages/server/lib/util/print-run.ts @@ -458,7 +458,7 @@ function displayScreenshots (screenshots: Screenshot[] = []) { console.log('') } -export function displayVideoProcessingProgress (opts: { videoCompression: number | false}) { +export function displayVideoProcessingProgress (opts: { name: string, videoCompression: number | false}) { console.log('') terminal.header('Video', { @@ -514,7 +514,7 @@ export function displayVideoProcessingProgress (opts: { videoCompression: number table.push([ gray('-'), gray('Finished processing:'), - `${formatPath(name, getWidth(table, 2), 'cyan')}`, + `${formatPath(opts.name, getWidth(table, 2), 'cyan')}`, gray(dur), ]) From e49360071c3589e95ed5ad92f214c32ac0dc4a34 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 17:17:43 -0400 Subject: [PATCH 03/15] minimize diff --- packages/server/lib/modes/run.ts | 60 ++++++++++++++------------------ 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index ba7eaf522902..48dec59c4497 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-console */ +/* eslint-disable no-console, @cypress/dev/arrow-body-multiline-braces */ import _ from 'lodash' import la from 'lazy-ass' import pkg from '@packages/root' @@ -833,42 +833,34 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea // Remap results for module API/after:run to remove private props and // rename props to make more user-friendly const moduleAPIResults = remapKeys(results, { - runs: each((run) => { - return { - tests: each((test) => { - return { - attempts: each((attempt, i) => { - return { - timings: remove, - failedFromHookId: remove, - wallClockDuration: renameKey('duration'), - wallClockStartedAt: renameKey('startedAt'), - wallClockEndedAt: renameKey('endedAt'), - screenshots: setValue( - _(run.screenshots) - .filter({ testId: test.testId, testAttemptIndex: i }) - .map((screenshot) => { - return _.omit(screenshot, - ['screenshotId', 'testId', 'testAttemptIndex']) - }) - .value(), - ), - } - }), - testId: remove, - } - }), - hooks: each({ - hookId: remove, - }), - stats: { + runs: each((run) => ({ + tests: each((test) => ({ + attempts: each((attempt, i) => ({ + timings: remove, + failedFromHookId: remove, wallClockDuration: renameKey('duration'), wallClockStartedAt: renameKey('startedAt'), wallClockEndedAt: renameKey('endedAt'), - }, - screenshots: remove, - } - }), + screenshots: setValue( + _(run.screenshots) + .filter({ testId: test.testId, testAttemptIndex: i }) + .map((screenshot) => _.omit(screenshot, + ['screenshotId', 'testId', 'testAttemptIndex'])) + .value(), + ), + })), + testId: remove, + })), + hooks: each({ + hookId: remove, + }), + stats: { + wallClockDuration: renameKey('duration'), + wallClockStartedAt: renameKey('startedAt'), + wallClockEndedAt: renameKey('endedAt'), + }, + screenshots: remove, + })), }) if (testingType === 'component') { From beb3f2d5d40fbbad196410fbcd9a7e36495b83c1 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 14:11:55 -0400 Subject: [PATCH 04/15] chore(server): convert browsers/index to typescript --- .../src/actions/ProjectActions.ts | 8 +- .../src/data/ProjectLifecycleManager.ts | 2 +- .../cypress/e2e/support/e2eSupport.ts | 1 + .../schemaTypes/objectTypes/gql-Mutation.ts | 2 +- packages/server/lib/browsers/chrome.ts | 79 +++++++++--------- .../lib/browsers/{index.js => index.ts} | 80 +++++++++++-------- packages/server/lib/browsers/types.ts | 22 ++++- packages/server/lib/browsers/utils.ts | 4 +- packages/server/lib/browsers/webkit.ts | 9 ++- packages/server/lib/makeDataContext.ts | 4 +- packages/server/lib/modes/run.ts | 16 ++-- packages/server/lib/open_project.ts | 32 ++++---- .../test/unit/util/process_profiler_spec.ts | 1 + packages/types/src/server.ts | 25 ++++-- 14 files changed, 164 insertions(+), 121 deletions(-) rename packages/server/lib/browsers/{index.js => index.ts} (66%) diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index cc8b32e74d98..3e0eae11c1de 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -1,5 +1,5 @@ import type { CodeGenType, MutationSetProjectPreferencesInGlobalCacheArgs, NexusGenObjects, NexusGenUnions } from '@packages/graphql/src/gen/nxs.gen' -import type { InitializeProjectOptions, FoundBrowser, FoundSpec, LaunchOpts, OpenProjectLaunchOptions, Preferences, TestingType, ReceivedCypressOptions, AddProject, FullConfig, AllowedState, SpecWithRelativeRoot } from '@packages/types' +import type { InitializeProjectOptions, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, Preferences, TestingType, ReceivedCypressOptions, AddProject, FullConfig, AllowedState, SpecWithRelativeRoot, OpenProjectLaunchOpts } from '@packages/types' import type { EventEmitter } from 'events' import execa from 'execa' import path from 'path' @@ -22,7 +22,7 @@ export interface ProjectApiShape { * order for CT to startup */ openProjectCreate(args: InitializeProjectOptions, options: OpenProjectLaunchOptions): Promise - launchProject(browser: FoundBrowser, spec: Cypress.Spec, options: LaunchOpts): Promise + launchProject(browser: FoundBrowser, spec: Cypress.Spec, options?: OpenProjectLaunchOpts): Promise insertProjectToCache(projectRoot: string): Promise removeProjectFromCache(projectRoot: string): Promise getProjectRootsFromCache(): Promise @@ -175,7 +175,7 @@ export class ProjectActions { // When switching testing type, the project should be relaunched in the previously selected browser if (this.ctx.coreData.app.relaunchBrowser) { this.ctx.project.setRelaunchBrowser(false) - await this.ctx.actions.project.launchProject(this.ctx.coreData.currentTestingType, {}) + await this.ctx.actions.project.launchProject(this.ctx.coreData.currentTestingType) } }) } catch (e) { @@ -228,7 +228,7 @@ export class ProjectActions { } } - async launchProject (testingType: Cypress.TestingType | null, options: LaunchOpts, specPath?: string | null) { + async launchProject (testingType: Cypress.TestingType | null, options?: OpenProjectLaunchOpts, specPath?: string | null) { if (!this.ctx.currentProject) { return null } diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index c414b6cd1707..db8d09eca653 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -287,7 +287,7 @@ export class ProjectLifecycleManager { if (this.ctx.coreData.activeBrowser) { // if `cypress open` was launched with a `--project` and `--testingType`, go ahead and launch the `--browser` if (this.ctx.modeOptions.project && this.ctx.modeOptions.testingType) { - await this.ctx.actions.project.launchProject(this.ctx.coreData.currentTestingType, {}) + await this.ctx.actions.project.launchProject(this.ctx.coreData.currentTestingType) } return diff --git a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts index d9ddffbcf3cd..719b60e498ae 100644 --- a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts +++ b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts @@ -294,6 +294,7 @@ function startAppServer (mode: 'component' | 'e2e' = 'e2e', options: { skipMocki if (!ctx.lifecycleManager.browsers?.length) throw new Error('No browsers available in startAppServer') await ctx.actions.browser.setActiveBrowser(ctx.lifecycleManager.browsers[0]) + // @ts-expect-error this interface is strict about the options it expects await ctx.actions.project.launchProject(o.mode, { url: o.url }) if (!o.skipMockingPrompts diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 8c3f4a9770f2..b5586ca75236 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -290,7 +290,7 @@ export const mutation = mutationType({ specPath: stringArg(), }, resolve: async (_, args, ctx) => { - await ctx.actions.project.launchProject(ctx.coreData.currentTestingType, {}, args.specPath) + await ctx.actions.project.launchProject(ctx.coreData.currentTestingType, undefined, args.specPath) return ctx.lifecycleManager }, diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 7d350c3fb8a7..96c8fbb4ac05 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -15,14 +15,12 @@ import { fs } from '../util/fs' import { CdpAutomation, screencastOpts } from './cdp_automation' import * as protocol from './protocol' import utils from './utils' -import type { Browser } from './types' +import type { Browser, BrowserLauncher } from './types' import { BrowserCriClient } from './browser-cri-client' import type { LaunchedBrowser } from '@packages/launcher/lib/browsers' import type { CRIWrapper } from './cri-client' import type { Automation } from '../automation' - -// TODO: this is defined in `cypress-npm-api` but there is currently no way to get there -type CypressConfiguration = any +import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' const debug = debugModule('cypress:server:browsers:chrome') @@ -123,7 +121,7 @@ const DEFAULT_ARGS = [ '--disable-dev-shm-usage', ] -let browserCriClient +let browserCriClient: BrowserCriClient | undefined /** * Reads all known preference files (CHROME_PREFERENCE_PATHS) from disk and retur @@ -433,8 +431,8 @@ const _handlePausedRequests = async (client) => { }) } -const _setAutomation = async (client: CRIWrapper.Client, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise, options: CypressConfiguration = {}) => { - const cdpAutomation = await CdpAutomation.create(client.send, client.on, resetBrowserTargets, automation, options.experimentalSessionAndOrigin) +const _setAutomation = async (client: CRIWrapper.Client, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise, options: BrowserLaunchOpts) => { + const cdpAutomation = await CdpAutomation.create(client.send, client.on, resetBrowserTargets, automation, !!options.experimentalSessionAndOrigin) return automation.use(cdpAutomation) } @@ -490,7 +488,7 @@ export = { return extensionDest }, - _getArgs (browser: Browser, options: CypressConfiguration, port: string) { + _getArgs (browser: Browser, options: BrowserLaunchOpts, port: string) { const args = ([] as string[]).concat(DEFAULT_ARGS) if (os.platform() === 'linux') { @@ -551,18 +549,43 @@ export = { return args }, - async connectToNewSpec (browser: Browser, options: CypressConfiguration = {}, automation: Automation) { + async connectToNewSpec (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { debug('connecting to new chrome tab in existing instance with url and debugging port', { url: options.url }) const browserCriClient = this._getBrowserCriClient() + + if (!browserCriClient) throw new Error('Missing browserCriClient in connectToNewSpec') + const pageCriClient = browserCriClient.currentlyAttachedTarget + if (!pageCriClient) throw new Error('Missing pageCriClient in connectToNewSpec') + + await this.attachListeners(browser, pageCriClient, automation, options) + }, + + async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation) { + const port = await protocol.getRemoteDebuggingPort() + + debug('connecting to existing chrome instance with url and debugging port', { url: options.url, port }) + if (!options.onError) throw new Error('Missing onError in connectToExisting') + + const browserCriClient = await BrowserCriClient.create(port, browser.displayName, options.onError, onReconnect) + + if (!options.url) throw new Error('Missing url in connectToExisting') + + const pageCriClient = await browserCriClient.attachToTargetUrl(options.url) + + await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) + }, + + async attachListeners (browser: Browser, pageCriClient, automation: Automation, options: BrowserNewTabOpts) { + if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') + await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) - // make sure page events are re enabled or else frame tree updates will NOT work as well as other items listening for page events await pageCriClient.send('Page.enable') - await options.onInitializeNewBrowserTab() + await options.onInitializeNewBrowserTab?.() await Promise.all([ this._maybeRecordVideo(pageCriClient, options, browser.majorVersion), @@ -577,17 +600,7 @@ export = { } }, - async connectToExisting (browser: Browser, options: CypressConfiguration = {}, automation) { - const port = await protocol.getRemoteDebuggingPort() - - debug('connecting to existing chrome instance with url and debugging port', { url: options.url, port }) - const browserCriClient = await BrowserCriClient.create(port, browser.displayName, options.onError, onReconnect) - const pageCriClient = await browserCriClient.attachToTargetUrl(options.url) - - await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) - }, - - async open (browser: Browser, url, options: CypressConfiguration = {}, automation: Automation): Promise { + async open (browser: Browser, url, options: BrowserLaunchOpts, automation: Automation): Promise { const { isTextTerminal } = options const userDir = utils.getProfileDir(browser, isTextTerminal) @@ -646,6 +659,8 @@ export = { // SECOND connect to the Chrome remote interface // and when the connection is ready // navigate to the actual url + if (!options.onError) throw new Error('Missing onError in chrome#open') + browserCriClient = await BrowserCriClient.create(port, browser.displayName, options.onError, onReconnect) la(browserCriClient, 'expected Chrome remote interface reference', browserCriClient) @@ -669,7 +684,7 @@ export = { debug('closing remote interface client') // Do nothing on failure here since we're shutting down anyway - browserCriClient.close().catch() + browserCriClient?.close().catch() browserCriClient = undefined debug('closing chrome') @@ -679,24 +694,10 @@ export = { const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') - await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) - - await pageCriClient.send('Page.enable') - - await Promise.all([ - this._maybeRecordVideo(pageCriClient, options, browser.majorVersion), - this._handleDownloads(pageCriClient, options.downloadsFolder, automation), - ]) - - await this._navigateUsingCRI(pageCriClient, url) - - if (options.experimentalSessionAndOrigin) { - await this._handlePausedRequests(pageCriClient) - _listenForFrameTreeChanges(pageCriClient) - } + await this.attachListeners(browser, pageCriClient, automation, options) // return the launched browser process // with additional method to close the remote connection return launchedBrowser }, -} +} as BrowserLauncher & Omit diff --git a/packages/server/lib/browsers/index.js b/packages/server/lib/browsers/index.ts similarity index 66% rename from packages/server/lib/browsers/index.js rename to packages/server/lib/browsers/index.ts index 69df090f3c66..f1bb3a98c926 100644 --- a/packages/server/lib/browsers/index.js +++ b/packages/server/lib/browsers/index.ts @@ -1,16 +1,19 @@ -const _ = require('lodash') -const Promise = require('bluebird') -const debug = require('debug')('cypress:server:browsers') -const utils = require('./utils') -const check = require('check-more-types') -const { exec } = require('child_process') -const util = require('util') -const os = require('os') -const { BROWSER_FAMILY } = require('@packages/types') - +import _ from 'lodash' +import Promise from 'bluebird' +import Debug from 'debug' +import utils from './utils' +import check from 'check-more-types' +import { exec } from 'child_process' +import util from 'util' +import os from 'os' +import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' +import type { Browser, BrowserInstance, BrowserLauncher } from './types' +import type { Automation } from '../automation' + +const debug = Debug('cypress:server:browsers') const isBrowserFamily = check.oneOf(BROWSER_FAMILY) -let instance = null +let instance: BrowserInstance | null = null const kill = function (unbind = true, isProcessExit = false) { // Clean up the instance when the browser is closed @@ -43,16 +46,22 @@ const kill = function (unbind = true, isProcessExit = false) { }) } -const setFocus = async function () { +async function setFocus () { const platform = os.platform() const execAsync = util.promisify(exec) try { + if (!instance) throw new Error('No instance in setFocus!') + switch (platform) { case 'darwin': - return execAsync(`open -a "$(ps -p ${instance.pid} -o comm=)"`) + await execAsync(`open -a "$(ps -p ${instance.pid} -o comm=)"`) + + return case 'win32': { - return execAsync(`(New-Object -ComObject WScript.Shell).AppActivate(((Get-WmiObject -Class win32_process -Filter "ParentProcessID = '${instance.pid}'") | Select -ExpandProperty ProcessId))`, { shell: 'powershell.exe' }) + await execAsync(`(New-Object -ComObject WScript.Shell).AppActivate(((Get-WmiObject -Class win32_process -Filter "ParentProcessID = '${instance.pid}'") | Select -ExpandProperty ProcessId))`, { shell: 'powershell.exe' }) + + return } default: debug(`Unexpected os platform ${platform}. Set focus is only functional on Windows and MacOS`) @@ -62,32 +71,31 @@ const setFocus = async function () { } } -const getBrowserLauncher = function (browser) { +function getBrowserLauncher (browser): BrowserLauncher { debug('getBrowserLauncher %o', { browser }) - if (!isBrowserFamily(browser.family)) { - debug('unknown browser family', browser.family) - } if (browser.name === 'electron') { - return require('./electron') + return require('./electron') as typeof import('./electron') } if (browser.family === 'chromium') { - return require('./chrome') + return require('./chrome') as typeof import('./chrome') } if (browser.family === 'firefox') { - return require('./firefox') + return require('./firefox') as typeof import('./firefox') } if (browser.family === 'webkit') { - return require('./webkit') + return require('./webkit') as typeof import('./webkit') } + + throw new Error('Missing browserLauncher for family') } process.once('exit', () => kill(true, true)) -module.exports = { +exports = { ensureAndGetByNameOrPath: utils.ensureAndGetByNameOrPath, isBrowserFamily, @@ -100,7 +108,7 @@ module.exports = { formatBrowsersToOptions: utils.formatBrowsersToOptions, - _setInstance (_instance) { + _setInstance (_instance: BrowserInstance) { // for testing instance = _instance }, @@ -111,7 +119,7 @@ module.exports = { return instance }, - getAllBrowsersWith (nameOrPath) { + getAllBrowsersWith (nameOrPath?: string) { debug('getAllBrowsersWith %o', { nameOrPath }) if (nameOrPath) { return utils.ensureAndGetByNameOrPath(nameOrPath, true) @@ -120,7 +128,7 @@ module.exports = { return utils.getBrowsers() }, - async connectToExisting (browser, options = {}, automation) { + async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { const browserLauncher = getBrowserLauncher(browser) if (!browserLauncher) { @@ -132,7 +140,7 @@ module.exports = { return this.getBrowserInstance() }, - async connectToNewSpec (browser, options = {}, automation) { + async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { const browserLauncher = getBrowserLauncher(browser) if (!browserLauncher) { @@ -145,7 +153,7 @@ module.exports = { return this.getBrowserInstance() }, - open (browser, options = {}, automation, ctx) { + open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx) { return kill(true) .then(() => { _.defaults(options, { @@ -161,15 +169,11 @@ module.exports = { utils.throwBrowserNotFound(browser.name, options.browsers) } - const { url } = options - - if (!url) { - throw new Error('options.url must be provided when opening a browser. You passed:', options) - } + if (!options.url) throw new Error('Missing url in browsers.open') debug('opening browser %o', browser) - return browserLauncher.open(browser, url, options, automation) + return browserLauncher.open(browser, options.url, options, automation) .then((i) => { debug('browser opened') // TODO: bind to process.exit here @@ -184,6 +188,9 @@ module.exports = { // enable the browser to configure the interface instance.once('exit', () => { ctx.browser.setBrowserStatus('closed') + // TODO: make this a required property + if (!options.onBrowserClose) throw new Error('onBrowserClose did not exist in interactive mode') + options.onBrowserClose() instance = null }) @@ -205,6 +212,9 @@ module.exports = { return null } + // TODO: make this a required property + if (!options.onBrowserOpen) throw new Error('onBrowserOpen did not exist in interactive mode') + options.onBrowserOpen() ctx.browser.setBrowserStatus('open') @@ -215,3 +225,5 @@ module.exports = { }, setFocus, } + +export = exports diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index 8259cc6dee68..66f0533d72c3 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -1,5 +1,6 @@ -import type { FoundBrowser } from '@packages/types' +import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' import type { EventEmitter } from 'events' +import type { Automation } from '../automation' export type Browser = FoundBrowser & { majorVersion: number @@ -10,4 +11,23 @@ export type Browser = FoundBrowser & { export type BrowserInstance = EventEmitter & { kill: () => void pid: number + /** + * After `.open`, this is set to the `Browser` used to launch this instance. + * TODO: remove need for this + */ + browser?: Browser + /** + * If set, the browser is currently in the process of exiting due to the parent process exiting. + * TODO: remove need for this + */ + isProcessExit?: boolean +} + +export type BrowserLauncher = { + open: (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation) => Promise + connectToNewSpec: (browser: Browser, options: BrowserNewTabOpts, automation: Automation) => Promise + /** + * Used in Cypress-in-Cypress tests to connect to the existing browser instance. + */ + connectToExisting: (browser: Browser, options: BrowserLaunchOpts, automation: Automation) => void | Promise } diff --git a/packages/server/lib/browsers/utils.ts b/packages/server/lib/browsers/utils.ts index 3963d8d7fb4e..7c9062534d83 100644 --- a/packages/server/lib/browsers/utils.ts +++ b/packages/server/lib/browsers/utils.ts @@ -293,8 +293,8 @@ const parseBrowserOption = (opt) => { } } -function ensureAndGetByNameOrPath(nameOrPath: string, returnAll: false, browsers: FoundBrowser[]): Bluebird -function ensureAndGetByNameOrPath(nameOrPath: string, returnAll: true, browsers: FoundBrowser[]): Bluebird +function ensureAndGetByNameOrPath(nameOrPath: string, returnAll: false, browsers?: FoundBrowser[]): Bluebird +function ensureAndGetByNameOrPath(nameOrPath: string, returnAll: true, browsers?: FoundBrowser[]): Bluebird async function ensureAndGetByNameOrPath (nameOrPath: string, returnAll = false, prevKnownBrowsers: FoundBrowser[] = []) { const browsers = prevKnownBrowsers.length ? prevKnownBrowsers : (await getBrowsers()) diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index 58c7661856c6..e8a669361571 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -4,12 +4,13 @@ import type playwright from 'playwright-webkit' import type { Browser, BrowserInstance } from './types' import type { Automation } from '../automation' import { WebKitAutomation } from './webkit-automation' +import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' const debug = Debug('cypress:server:browsers:webkit') let wkAutomation: WebKitAutomation | undefined -export async function connectToNewSpec (browser: Browser, options, automation: Automation) { +export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { if (!wkAutomation) throw new Error('connectToNewSpec called without wkAutomation') automation.use(wkAutomation) @@ -18,7 +19,11 @@ export async function connectToNewSpec (browser: Browser, options, automation: A await wkAutomation.reset(options.url) } -export async function open (browser: Browser, url, options: any = {}, automation: Automation): Promise { +export async function connectToExisting () { + throw new Error('Cypress-in-Cypress is not supported for WebKit.') +} + +export async function open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation): Promise { // resolve pw from user's project path const pwModulePath = require.resolve('playwright-webkit', { paths: [process.cwd()] }) const pw = require(pwModulePath) as typeof playwright diff --git a/packages/server/lib/makeDataContext.ts b/packages/server/lib/makeDataContext.ts index 2fcc8be88fb1..eb2c4cb59688 100644 --- a/packages/server/lib/makeDataContext.ts +++ b/packages/server/lib/makeDataContext.ts @@ -7,9 +7,9 @@ import { isMainWindowFocused, focusMainWindow } from './gui/windows' import type { AllModeOptions, AllowedState, + OpenProjectLaunchOpts, FoundBrowser, InitializeProjectOptions, - LaunchOpts, OpenProjectLaunchOptions, Preferences, } from '@packages/types' @@ -75,7 +75,7 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext { }, }, projectApi: { - launchProject (browser: FoundBrowser, spec: Cypress.Spec, options?: LaunchOpts) { + launchProject (browser: FoundBrowser, spec: Cypress.Spec, options: OpenProjectLaunchOpts) { return openProject.launch({ ...browser }, spec, options) }, openProjectCreate (args: InitializeProjectOptions, options: OpenProjectLaunchOptions) { diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 48dec59c4497..3241a5dcdfff 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -22,7 +22,7 @@ import random from '../util/random' import system from '../util/system' import chromePolicyCheck from '../util/chrome_policy_check' import * as objUtils from '../util/obj_utils' -import type { SpecWithRelativeRoot, LaunchOpts, SpecFile, TestingType } from '@packages/types' +import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import * as printResults from '../util/print-run' @@ -331,13 +331,11 @@ async function maybeStartVideoRecording (options: { spec: SpecWithRelativeRoot, const { spec, browser, video, videosFolder } = options debug(`video recording has been ${video ? 'enabled' : 'disabled'}. video: %s`, video) - // bail if we've been told not to capture - // a video recording + if (!video) { return } - // make sure we have a videosFolder if (!videosFolder) { throw new Error('Missing videoFolder for recording') } @@ -400,7 +398,7 @@ function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, const warnings = {} - const browserOpts: LaunchOpts = { + const browserOpts: OpenProjectLaunchOpts = { ...getDefaultBrowserOptsByFamily(browser, project, writeVideoFrame, onError), projectRoot, shouldLaunchNewTab, @@ -943,7 +941,7 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project: return { results } } -async function ready (options: { projectRoot: string, record: boolean, key: string, ciBuildId: string, parallel: boolean, group: string, browser: string, tag: string, testingType: TestingType, socketId: string, spec: string | RegExp | string[], headed: boolean, outputPath: string, exit: boolean, quiet: boolean, onError?: (err: Error) => void, browsers?: Browser[], webSecurity: boolean }) { +async function ready (options: { projectRoot: string, record: boolean, key: string, ciBuildId: string, parallel: boolean, group: string, browser: string, tag: string, testingType: TestingType, socketId: string, spec: string | RegExp | string[], headed: boolean, outputPath: string, exit: boolean, quiet: boolean, onError?: (err: Error) => void, browsers?: FoundBrowser[], webSecurity: boolean }) { debug('run mode ready with options %o', options) if (process.env.ELECTRON_RUN_AS_NODE && !process.env.DISPLAY) { @@ -1001,11 +999,11 @@ async function ready (options: { projectRoot: string, record: boolean, key: stri const [sys, browser] = await Promise.all([ system.info(), (async () => { - const browsers = await browserUtils.ensureAndGetByNameOrPath(browserName, false, userBrowsers) + const browser = await browserUtils.ensureAndGetByNameOrPath(browserName, false, userBrowsers) - await removeOldProfiles(browsers) + await removeOldProfiles(browser) - return browsers + return browser })(), trashAssets(config), ]) diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 6c5fce26d398..b6613377da63 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -12,7 +12,7 @@ import runEvents from './plugins/run_events' import * as session from './session' import { cookieJar } from './util/cookies' import { getSpecUrl } from './project_utils' -import type { LaunchOpts, OpenProjectLaunchOptions, InitializeProjectOptions } from '@packages/types' +import type { BrowserLaunchOpts, OpenProjectLaunchOptions, InitializeProjectOptions, OpenProjectLaunchOpts, FoundBrowser } from '@packages/types' import { DataContext, getCtx } from '@packages/data-context' import { autoBindDebug } from '@packages/data-context/src/util' @@ -48,15 +48,13 @@ export class OpenProject { return this.projectBase } - async launch (browser, spec: Cypress.Cypress['spec'], options: LaunchOpts = { - onError: () => undefined, - }) { + async launch (browser, spec: Cypress.Cypress['spec'], prevOptions?: OpenProjectLaunchOpts) { this._ctx = getCtx() assert(this.projectBase, 'Cannot launch runner if projectBase is undefined!') debug('resetting project state, preparing to launch browser %s for spec %o options %o', - browser.name, spec, options) + browser.name, spec, prevOptions) la(_.isPlainObject(browser), 'expected browser object:', browser) @@ -64,7 +62,7 @@ export class OpenProject { // of potential domain changes, request buffers, etc this.projectBase!.reset() - let url = getSpecUrl({ + const url = process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF ? undefined : getSpecUrl({ spec, browserUrl: this.projectBase.cfg.browserUrl, projectRoot: this.projectBase.projectRoot, @@ -74,8 +72,14 @@ export class OpenProject { const cfg = this.projectBase.getConfig() - _.defaults(options, { - browsers: cfg.browsers, + if (!cfg.proxyServer) throw new Error('Missing proxyServer in launch') + + const options: BrowserLaunchOpts = { + ...prevOptions || {}, + browser, + url, + // TODO: fix majorVersion discrepancy that causes this to be necessary + browsers: cfg.browsers as FoundBrowser[], userAgent: cfg.userAgent, proxyUrl: cfg.proxyUrl, proxyServer: cfg.proxyServer, @@ -85,7 +89,7 @@ export class OpenProject { downloadsFolder: cfg.downloadsFolder, experimentalSessionAndOrigin: cfg.experimentalSessionAndOrigin, experimentalModifyObstructiveThirdPartyCode: cfg.experimentalModifyObstructiveThirdPartyCode, - }) + } // if we don't have the isHeaded property // then we're in interactive mode and we @@ -96,21 +100,13 @@ export class OpenProject { browser.isHeadless = false } - // set the current browser object on options - // so we can pass it down - options.browser = browser - - if (!process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { - options.url = url - } - this.projectBase.setCurrentSpecAndBrowser(spec, browser) const automation = this.projectBase.getAutomation() // use automation middleware if its // been defined here - let am = options.automationMiddleware + const am = options.automationMiddleware if (am) { automation.use(am) diff --git a/packages/server/test/unit/util/process_profiler_spec.ts b/packages/server/test/unit/util/process_profiler_spec.ts index bd1c965289bc..c2cf9ba005f1 100644 --- a/packages/server/test/unit/util/process_profiler_spec.ts +++ b/packages/server/test/unit/util/process_profiler_spec.ts @@ -187,6 +187,7 @@ describe('lib/util/process_profiler', function () { const result = _aggregateGroups(_groupCyProcesses({ list: processes })) // main process will have variable pid, replace it w constant for snapshotting + // @ts-ignore _.find(result, { pids: String(MAIN_PID) }).pids = '111111111' // @ts-ignore diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index a38eba449acc..fae60c3fae53 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -1,17 +1,26 @@ import type { FoundBrowser } from './browser' +import type { ReceivedCypressOptions } from './config' import type { PlatformName } from './platform' -export interface LaunchOpts { - browser?: FoundBrowser - url?: string - automationMiddleware?: AutomationMiddleware - projectRoot?: string - shouldLaunchNewTab?: boolean +export type OpenProjectLaunchOpts = { + projectRoot: string + shouldLaunchNewTab: boolean + automationMiddleware: AutomationMiddleware + onWarning: (err: Error) => void +} + +export type BrowserLaunchOpts = { + browsers: FoundBrowser[] + browser: FoundBrowser + url: string | undefined + proxyServer: string onBrowserClose?: (...args: unknown[]) => void onBrowserOpen?: (...args: unknown[]) => void onError?: (err: Error) => void - onWarning?: (err: Error) => void -} +} & Partial // TODO: remove the `Partial` here by making it impossible for openProject.launch to be called w/o OpenProjectLaunchOpts +& Pick + +export type BrowserNewTabOpts = { onInitializeNewBrowserTab: () => void } & BrowserLaunchOpts export interface LaunchArgs { _: [string] // Cypress App binary location From 4957a836b316cf58014bffef222412c302c2dd84 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 17:02:50 -0400 Subject: [PATCH 05/15] fix tests --- packages/server/lib/browsers/chrome.ts | 8 ++-- packages/server/lib/browsers/index.ts | 26 ++++--------- packages/server/lib/modes/run.ts | 31 +++++----------- packages/server/lib/open_project.ts | 2 +- .../server/test/unit/browsers/chrome_spec.js | 37 +++++++++++-------- .../server/test/unit/open_project_spec.js | 1 + 6 files changed, 43 insertions(+), 62 deletions(-) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 96c8fbb4ac05..35003c5a87d9 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -560,7 +560,7 @@ export = { if (!pageCriClient) throw new Error('Missing pageCriClient in connectToNewSpec') - await this.attachListeners(browser, pageCriClient, automation, options) + await this.attachListeners(browser, options.url, pageCriClient, automation, options) }, async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation) { @@ -578,7 +578,7 @@ export = { await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) }, - async attachListeners (browser: Browser, pageCriClient, automation: Automation, options: BrowserNewTabOpts) { + async attachListeners (browser: Browser, url: string, pageCriClient, automation: Automation, options: BrowserNewTabOpts) { if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) @@ -592,7 +592,7 @@ export = { this._handleDownloads(pageCriClient, options.downloadsFolder, automation), ]) - await this._navigateUsingCRI(pageCriClient, options.url) + await this._navigateUsingCRI(pageCriClient, url) if (options.experimentalSessionAndOrigin) { await this._handlePausedRequests(pageCriClient) @@ -694,7 +694,7 @@ export = { const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') - await this.attachListeners(browser, pageCriClient, automation, options) + await this.attachListeners(browser, url, pageCriClient, automation, options) // return the launched browser process // with additional method to close the remote connection diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index f1bb3a98c926..7d35542af06c 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -6,7 +6,7 @@ import check from 'check-more-types' import { exec } from 'child_process' import util from 'util' import os from 'os' -import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' +import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser } from '@packages/types' import type { Browser, BrowserInstance, BrowserLauncher } from './types' import type { Automation } from '../automation' @@ -71,7 +71,7 @@ async function setFocus () { } } -function getBrowserLauncher (browser): BrowserLauncher { +function getBrowserLauncher (browser: Browser, browsers: FoundBrowser[]): BrowserLauncher { debug('getBrowserLauncher %o', { browser }) if (browser.name === 'electron') { @@ -90,7 +90,7 @@ function getBrowserLauncher (browser): BrowserLauncher { return require('./webkit') as typeof import('./webkit') } - throw new Error('Missing browserLauncher for family') + return utils.throwBrowserNotFound(browser.name, browsers) } process.once('exit', () => kill(true, true)) @@ -129,11 +129,7 @@ exports = { }, async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { - const browserLauncher = getBrowserLauncher(browser) - - if (!browserLauncher) { - utils.throwBrowserNotFound(browser.name, options.browsers) - } + const browserLauncher = getBrowserLauncher(browser, options.browsers) await browserLauncher.connectToExisting(browser, options, automation) @@ -141,11 +137,7 @@ exports = { }, async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { - const browserLauncher = getBrowserLauncher(browser) - - if (!browserLauncher) { - utils.throwBrowserNotFound(browser.name, options.browsers) - } + const browserLauncher = getBrowserLauncher(browser, options.browsers) // Instance will be null when we're dealing with electron. In that case we don't need a browserCriClient await browserLauncher.connectToNewSpec(browser, options, automation) @@ -163,11 +155,7 @@ exports = { ctx.browser.setBrowserStatus('opening') - const browserLauncher = getBrowserLauncher(browser) - - if (!browserLauncher) { - utils.throwBrowserNotFound(browser.name, options.browsers) - } + const browserLauncher = getBrowserLauncher(browser, options.browsers) if (!options.url) throw new Error('Missing url in browsers.open') @@ -224,6 +212,6 @@ exports = { }) }, setFocus, -} +} as const export = exports diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 3241a5dcdfff..d196afb75355 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -129,7 +129,7 @@ const getDefaultBrowserOptsByFamily = (browser, project, writeVideoFrame, onErro } if (browser.family === 'chromium') { - return getChromeProps(writeVideoFrame) + return getCdpVideoProp(writeVideoFrame) } if (browser.family === 'firefox') { @@ -149,33 +149,22 @@ const getFirefoxProps = (project, writeVideoFrame) => { return {} } -const getCdpVideoPropSetter = (writeVideoFrame) => { +const getCdpVideoProp = (writeVideoFrame) => { if (!writeVideoFrame) { - return _.noop + return {} } - return (props) => { - props.onScreencastFrame = (e) => { + return { + onScreencastFrame: (e) => { // https://chromedevtools.github.io/devtools-protocol/tot/Page#event-screencastFrame writeVideoFrame(Buffer.from(e.data, 'base64')) - } + }, } } -const getChromeProps = (writeVideoFrame) => { - const shouldWriteVideo = Boolean(writeVideoFrame) - - debug('setting Chrome properties %o', { shouldWriteVideo }) - - return _ - .chain({}) - .tap(getCdpVideoPropSetter(writeVideoFrame)) - .value() -} - const getElectronProps = (isHeaded, writeVideoFrame, onError) => { - return _ - .chain({ + return { + ...getCdpVideoProp(writeVideoFrame), width: 1280, height: 720, show: isHeaded, @@ -193,9 +182,7 @@ const getElectronProps = (isHeaded, writeVideoFrame, onError) => { // https://github.com/cypress-io/cypress/issues/123 options.show = false }, - }) - .tap(getCdpVideoPropSetter(writeVideoFrame)) - .value() + } } const sumByProp = (runs, prop) => { diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index b6613377da63..242a88a96ed0 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -75,7 +75,6 @@ export class OpenProject { if (!cfg.proxyServer) throw new Error('Missing proxyServer in launch') const options: BrowserLaunchOpts = { - ...prevOptions || {}, browser, url, // TODO: fix majorVersion discrepancy that causes this to be necessary @@ -89,6 +88,7 @@ export class OpenProject { downloadsFolder: cfg.downloadsFolder, experimentalSessionAndOrigin: cfg.experimentalSessionAndOrigin, experimentalModifyObstructiveThirdPartyCode: cfg.experimentalModifyObstructiveThirdPartyCode, + ...prevOptions || {}, } // if we don't have the isHeaded property diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 52c9ad294d57..75352873edde 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -13,6 +13,10 @@ const chrome = require(`../../../lib/browsers/chrome`) const { fs } = require(`../../../lib/util/fs`) const { BrowserCriClient } = require('../../../lib/browsers/browser-cri-client') +const openOpts = { + onError: () => {}, +} + describe('lib/browsers/chrome', () => { context('#open', () => { beforeEach(function () { @@ -45,7 +49,7 @@ describe('lib/browsers/chrome', () => { this.onCriEvent = (event, data, options) => { this.pageCriClient.on.withArgs(event).yieldsAsync(data) - return chrome.open({ isHeadless: true }, 'http://', options, this.automation) + return chrome.open({ isHeadless: true }, 'http://', { ...openOpts, ...options }, this.automation) .then(() => { this.pageCriClient.on = undefined }) @@ -73,7 +77,7 @@ describe('lib/browsers/chrome', () => { }) it('focuses on the page, calls CRI Page.visit, enables Page events, and sets download behavior', function () { - return chrome.open({ isHeadless: true }, 'http://', {}, this.automation) + return chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation) .then(() => { expect(utils.getPort).to.have.been.calledOnce // to get remote interface port expect(this.pageCriClient.send.callCount).to.equal(5) @@ -87,7 +91,7 @@ describe('lib/browsers/chrome', () => { }) it('is noop without before:browser:launch', function () { - return chrome.open({ isHeadless: true }, 'http://', {}, this.automation) + return chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation) .then(() => { expect(plugins.execute).not.to.be.called }) @@ -101,7 +105,7 @@ describe('lib/browsers/chrome', () => { plugins.execute.resolves(null) - return chrome.open({ isHeadless: true }, 'http://', {}, this.automation) + return chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation) .then(() => { // to initialize remote interface client and prepare for true tests // we load the browser with blank page first @@ -112,7 +116,7 @@ describe('lib/browsers/chrome', () => { it('sets default window size and DPR in headless mode', function () { chrome._writeExtension.restore() - return chrome.open({ isHeadless: true }, 'http://', {}, this.automation) + return chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation) .then(() => { const args = launch.launch.firstCall.args[3] @@ -127,7 +131,7 @@ describe('lib/browsers/chrome', () => { it('does not load extension in headless mode', function () { chrome._writeExtension.restore() - return chrome.open({ isHeadless: true }, 'http://', {}, this.automation) + return chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation) .then(() => { const args = launch.launch.firstCall.args[3] @@ -158,7 +162,7 @@ describe('lib/browsers/chrome', () => { profilePath, name: 'chromium', channel: 'stable', - }, 'http://', {}, this.automation) + }, 'http://', openOpts, this.automation) .then(() => { const args = launch.launch.firstCall.args[3] @@ -177,7 +181,7 @@ describe('lib/browsers/chrome', () => { const onWarning = sinon.stub() - return chrome.open({ isHeaded: true }, 'http://', { onWarning }, this.automation) + return chrome.open({ isHeaded: true }, 'http://', { onWarning, onError: () => {} }, this.automation) .then(() => { const args = launch.launch.firstCall.args[3] @@ -201,7 +205,7 @@ describe('lib/browsers/chrome', () => { const pathToTheme = extension.getPathToTheme() - return chrome.open({ isHeaded: true }, 'http://', {}, this.automation) + return chrome.open({ isHeaded: true }, 'http://', openOpts, this.automation) .then(() => { const args = launch.launch.firstCall.args[3] @@ -223,7 +227,7 @@ describe('lib/browsers/chrome', () => { const onWarning = sinon.stub() - return chrome.open({ isHeaded: true }, 'http://', { onWarning }, this.automation) + return chrome.open({ isHeaded: true }, 'http://', { onWarning, onError: () => {} }, this.automation) .then(() => { const args = launch.launch.firstCall.args[3] @@ -269,7 +273,7 @@ describe('lib/browsers/chrome', () => { profilePath, name: 'chromium', channel: 'stable', - }, 'http://', {}, this.automation) + }, 'http://', openOpts, this.automation) .then(() => { expect((getFile(fullPath).getMode()) & 0o0700).to.be.above(0o0500) }) @@ -285,7 +289,7 @@ describe('lib/browsers/chrome', () => { sinon.stub(fs, 'outputJson').resolves() - return chrome.open({ isHeadless: true }, 'http://', {}, this.automation) + return chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation) .then(() => { expect(fs.outputJson).to.be.calledWith('/profile/dir/Default/Preferences', { profile: { @@ -302,7 +306,7 @@ describe('lib/browsers/chrome', () => { kill, } = this.launchedBrowser - return chrome.open({ isHeadless: true }, 'http://', {}, this.automation) + return chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation) .then(() => { expect(typeof this.launchedBrowser.kill).to.eq('function') @@ -316,7 +320,7 @@ describe('lib/browsers/chrome', () => { it('rejects if CDP version check fails', function () { this.browserCriClient.ensureMinimumProtocolVersion.throws() - return expect(chrome.open({ isHeadless: true }, 'http://', {}, this.automation)).to.be.rejectedWith('Cypress requires at least Chrome 64.') + return expect(chrome.open({ isHeadless: true }, 'http://', openOpts, this.automation)).to.be.rejectedWith('Cypress requires at least Chrome 64.') }) // https://github.com/cypress-io/cypress/issues/9265 @@ -371,6 +375,7 @@ describe('lib/browsers/chrome', () => { describe('adding header to AUT iframe request', function () { const withExperimentalFlagOn = { + ...openOpts, experimentalSessionAndOrigin: true, } @@ -398,7 +403,7 @@ describe('lib/browsers/chrome', () => { }) it('does not listen to Fetch.requestPaused if experimental flag is off', async function () { - await chrome.open('chrome', 'http://', { experimentalSessionAndOrigin: false }, this.automation) + await chrome.open('chrome', 'http://', { ...openOpts, experimentalSessionAndOrigin: false }, this.automation) expect(this.pageCriClient.on).not.to.be.calledWith('Fetch.requestPaused') }) @@ -511,7 +516,7 @@ describe('lib/browsers/chrome', () => { } let onInitializeNewBrowserTabCalled = false - const options = { onError: () => {}, url: 'https://www.google.com', downloadsFolder: '/tmp/folder', onInitializeNewBrowserTab: () => { + const options = { ...openOpts, url: 'https://www.google.com', downloadsFolder: '/tmp/folder', onInitializeNewBrowserTab: () => { onInitializeNewBrowserTabCalled = true } } diff --git a/packages/server/test/unit/open_project_spec.js b/packages/server/test/unit/open_project_spec.js index 44c9d11fcfb6..f209dccb9262 100644 --- a/packages/server/test/unit/open_project_spec.js +++ b/packages/server/test/unit/open_project_spec.js @@ -21,6 +21,7 @@ describe('lib/open_project', () => { this.config = { excludeSpecPattern: '**/*.nope', projectRoot: todosPath, + proxyServer: 'http://cy-proxy-server', } this.onError = sinon.stub() From c2555485b8199dd531f22601dd09aebbd45abc60 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 17:24:13 -0400 Subject: [PATCH 06/15] update stubbed tests --- packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts | 2 +- packages/app/cypress/e2e/top-nav.cy.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts index a7347a6a9685..6bb825839dea 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress-component.cy.ts @@ -144,7 +144,7 @@ describe('Cypress In Cypress CT', { viewportWidth: 1500, defaultCommandTimeout: expect(ctx.actions.browser.setActiveBrowserById).to.have.been.calledWith(browserId) expect(genId).to.eql('firefox-firefox-stable') expect(ctx.actions.project.launchProject).to.have.been.calledWith( - ctx.coreData.currentTestingType, {}, o.sinon.match(new RegExp('cypress\-in\-cypress\/src\/TestComponent\.spec\.jsx$')), + ctx.coreData.currentTestingType, undefined, o.sinon.match(new RegExp('cypress\-in\-cypress\/src\/TestComponent\.spec\.jsx$')), ) }) }) diff --git a/packages/app/cypress/e2e/top-nav.cy.ts b/packages/app/cypress/e2e/top-nav.cy.ts index f52bf0bf2ad9..871c9186595b 100644 --- a/packages/app/cypress/e2e/top-nav.cy.ts +++ b/packages/app/cypress/e2e/top-nav.cy.ts @@ -110,7 +110,7 @@ describe('App Top Nav Workflows', () => { expect(ctx.actions.browser.setActiveBrowserById).to.have.been.calledWith(browserId) expect(genId).to.eql('edge-chromium-stable') expect(ctx.actions.project.launchProject).to.have.been.calledWith( - ctx.coreData.currentTestingType, {}, undefined, + ctx.coreData.currentTestingType, undefined, undefined, ) }) }) From b01e8c0e98c0e239273866c5644dd7e3b749ac41 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 18:40:27 -0400 Subject: [PATCH 07/15] convert electron.js to .ts --- .../server/lib/browsers/cdp_automation.ts | 2 +- packages/server/lib/browsers/chrome.ts | 10 +- .../lib/browsers/{electron.js => electron.ts} | 311 +++++++++--------- packages/server/lib/browsers/types.ts | 5 + packages/server/lib/util/process_profiler.ts | 4 +- .../test/unit/browsers/electron_spec.js | 9 +- 6 files changed, 176 insertions(+), 165 deletions(-) rename packages/server/lib/browsers/{electron.js => electron.ts} (61%) diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index 77d7872827ad..617fa6b84e61 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -164,7 +164,7 @@ export const normalizeResourceType = (resourceType: string | undefined): Resourc } type SendDebuggerCommand = (message: string, data?: any) => Promise -type SendCloseCommand = (shouldKeepTabOpen: boolean) => Promise +type SendCloseCommand = (shouldKeepTabOpen: boolean) => Promise | void type OnFn = (eventName: string, cb: Function) => void // the intersection of what's valid in CDP and what's valid in FFCDP diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 35003c5a87d9..22c52fae535d 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -15,7 +15,7 @@ import { fs } from '../util/fs' import { CdpAutomation, screencastOpts } from './cdp_automation' import * as protocol from './protocol' import utils from './utils' -import type { Browser, BrowserLauncher } from './types' +import type { Browser } from './types' import { BrowserCriClient } from './browser-cri-client' import type { LaunchedBrowser } from '@packages/launcher/lib/browsers' import type { CRIWrapper } from './cri-client' @@ -549,7 +549,7 @@ export = { return args }, - async connectToNewSpec (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { + async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { debug('connecting to new chrome tab in existing instance with url and debugging port', { url: options.url }) const browserCriClient = this._getBrowserCriClient() @@ -560,6 +560,8 @@ export = { if (!pageCriClient) throw new Error('Missing pageCriClient in connectToNewSpec') + if (!options.url) throw new Error('Missing url in connectToNewSpec') + await this.attachListeners(browser, options.url, pageCriClient, automation, options) }, @@ -578,7 +580,7 @@ export = { await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) }, - async attachListeners (browser: Browser, url: string, pageCriClient, automation: Automation, options: BrowserNewTabOpts) { + async attachListeners (browser: Browser, url: string, pageCriClient, automation: Automation, options: BrowserLaunchOpts & { onInitializeNewBrowserTab?: () => void }) { if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) @@ -700,4 +702,4 @@ export = { // with additional method to close the remote connection return launchedBrowser }, -} as BrowserLauncher & Omit +} diff --git a/packages/server/lib/browsers/electron.js b/packages/server/lib/browsers/electron.ts similarity index 61% rename from packages/server/lib/browsers/electron.js rename to packages/server/lib/browsers/electron.ts index 0f432da86184..d078d63f69ed 100644 --- a/packages/server/lib/browsers/electron.js +++ b/packages/server/lib/browsers/electron.ts @@ -1,15 +1,19 @@ -const _ = require('lodash') -const EE = require('events') -const path = require('path') -const Bluebird = require('bluebird') -const debug = require('debug')('cypress:server:browsers:electron') -const debugVerbose = require('debug')('cypress-verbose:server:browsers:electron') -const menu = require('../gui/menu') -const Windows = require('../gui/windows') -const { CdpAutomation, screencastOpts } = require('./cdp_automation') -const savedState = require('../saved_state') -const utils = require('./utils') -const errors = require('../errors') +import _ from 'lodash' +import EE from 'events' +import path from 'path' +import Debug from 'debug' +import menu from '../gui/menu' +import * as Windows from '../gui/windows' +import { CdpAutomation, screencastOpts } from './cdp_automation' +import * as savedState from '../saved_state' +import utils from './utils' +import * as errors from '../errors' +import type { BrowserInstance } from './types' +import type { BrowserWindow, WebContents } from 'electron' +import type { Automation } from '../automation' + +const debug = Debug('cypress:server:browsers:electron') +const debugVerbose = Debug('cypress-verbose:server:browsers:electron') // additional events that are nice to know about to be logged // https://electronjs.org/docs/api/browser-window#instance-events @@ -20,7 +24,7 @@ const ELECTRON_DEBUG_EVENTS = [ 'unresponsive', ] -let instance = null +let instance: BrowserInstance | null = null const tryToCall = function (win, method) { try { @@ -37,12 +41,12 @@ const tryToCall = function (win, method) { } const _getAutomation = async function (win, options, parent) { - const sendCommand = Bluebird.method((...args) => { + async function sendCommand (method: string, data?: object) { return tryToCall(win, () => { return win.webContents.debugger.sendCommand - .apply(win.webContents.debugger, args) + .call(win.webContents.debugger, method, data) }) - }) + } const on = (eventName, cb) => { win.webContents.debugger.on('message', (event, method, params) => { @@ -89,16 +93,16 @@ const _getAutomation = async function (win, options, parent) { return automation } -const _installExtensions = function (win, extensionPaths = [], options) { +async function _installExtensions (win: BrowserWindow, extensionPaths: string[], options) { Windows.removeAllExtensions(win) - return Bluebird.map(extensionPaths, (extensionPath) => { + return Promise.all(extensionPaths.map((extensionPath) => { try { return Windows.installExtension(win, extensionPath) } catch (error) { return options.onWarning(errors.get('EXTENSION_NOT_LOADED', 'Electron', extensionPath)) } - }) + })) } const _maybeRecordVideo = async function (webContents, options) { @@ -120,7 +124,7 @@ const _maybeRecordVideo = async function (webContents, options) { await webContents.debugger.sendCommand('Page.startScreencast', screencastOpts()) } -module.exports = { +export = { _defaultOptions (projectRoot, state, options, automation) { const _this = this @@ -149,24 +153,25 @@ module.exports = { return menu.set({ withInternalDevTools: true }) } }, - onNewWindow (e, url) { + async onNewWindow (this: BrowserWindow, e, url) { const _win = this - return _this._launchChild(e, url, _win, projectRoot, state, options, automation) - .then((child) => { - // close child on parent close - _win.on('close', () => { - if (!child.isDestroyed()) { - child.destroy() - } - }) - - // add this pid to list of pids - tryToCall(child, () => { - if (instance && instance.pid) { - instance.pid.push(child.webContents.getOSProcessId()) - } - }) + const child = await _this._launchChild(e, url, _win, projectRoot, state, options, automation) + + // close child on parent close + _win.on('close', () => { + if (!child.isDestroyed()) { + child.destroy() + } + }) + + // add this pid to list of pids + tryToCall(child, () => { + if (instance && instance.pid) { + if (!instance.allPids) throw new Error('Missing allPids!') + + instance.allPids.push(child.webContents.getOSProcessId()) + } }) }, } @@ -182,7 +187,7 @@ module.exports = { _getAutomation, - async _render (url, automation, preferences = {}, options = {}) { + async _render (url: string, automation: Automation, preferences, options: { projectRoot: string, isTextTerminal: boolean }) { const win = Windows.create(options.projectRoot, preferences) if (preferences.browser.isHeadless) { @@ -195,9 +200,11 @@ module.exports = { win.maximize() } - return this._launch(win, url, automation, preferences).tap(async () => { - automation.use(await _getAutomation(win, preferences, automation)) - }) + const launched = await this._launch(win, url, automation, preferences) + + automation.use(await _getAutomation(win, preferences, automation)) + + return launched }, _launchChild (e, url, parent, projectRoot, state, options, automation) { @@ -205,7 +212,7 @@ module.exports = { const [parentX, parentY] = parent.getPosition() - options = this._defaultOptions(projectRoot, state, options) + options = this._defaultOptions(projectRoot, state, options, automation) _.extend(options, { x: parentX + 100, @@ -222,75 +229,68 @@ module.exports = { return this._launch(win, url, automation, options) }, - _launch (win, url, automation, options) { + async _launch (win: BrowserWindow, url: string, automation: Automation, options) { if (options.show) { menu.set({ withInternalDevTools: true }) } ELECTRON_DEBUG_EVENTS.forEach((e) => { + // @ts-expect-error mapping strings to event names is failing typecheck win.on(e, () => { debug('%s fired on the BrowserWindow %o', e, { browserWindowUrl: url }) }) }) - return Bluebird.try(() => { - return this._attachDebugger(win.webContents) - }) - .then(() => { - let ua + this._attachDebugger(win.webContents) - ua = options.userAgent + let ua - if (ua) { - this._setUserAgent(win.webContents, ua) - // @see https://github.com/cypress-io/cypress/issues/22953 - } else if (options.experimentalModifyObstructiveThirdPartyCode) { - const userAgent = this._getUserAgent(win.webContents) - // replace any obstructive electron user agents that contain electron or cypress references to appear more chrome-like - const modifiedNonObstructiveUserAgent = userAgent.replace(/Cypress.*?\s|[Ee]lectron.*?\s/g, '') + ua = options.userAgent - this._setUserAgent(win.webContents, modifiedNonObstructiveUserAgent) - } + if (ua) { + this._setUserAgent(win.webContents, ua) + // @see https://github.com/cypress-io/cypress/issues/22953 + } else if (options.experimentalModifyObstructiveThirdPartyCode) { + const userAgent = this._getUserAgent(win.webContents) + // replace any obstructive electron user agents that contain electron or cypress references to appear more chrome-like + const modifiedNonObstructiveUserAgent = userAgent.replace(/Cypress.*?\s|[Ee]lectron.*?\s/g, '') - const setProxy = () => { - let ps + this._setUserAgent(win.webContents, modifiedNonObstructiveUserAgent) + } - ps = options.proxyServer + const setProxy = () => { + let ps - if (ps) { - return this._setProxy(win.webContents, ps) - } - } + ps = options.proxyServer - return Bluebird.join( - setProxy(), - this._clearCache(win.webContents), - ) - }) - .then(() => { - return win.loadURL('about:blank') - }) - .then(() => this._getAutomation(win, options, automation)) - .then((cdpAutomation) => automation.use(cdpAutomation)) - .then(() => { - return Promise.all([ - _maybeRecordVideo(win.webContents, options), - this._handleDownloads(win, options.downloadsFolder, automation), - ]) - }) - .then(() => { - // enabling can only happen once the window has loaded - return this._enableDebugger(win.webContents) - }) - .then(() => { - return win.loadURL(url) - }) - .then(() => { - if (options.experimentalSessionAndOrigin) { - this._listenToOnBeforeHeaders(win) + if (ps) { + return this._setProxy(win.webContents, ps) } - }) - .return(win) + } + + await Promise.all([ + setProxy(), + this._clearCache(win.webContents), + ]) + + await win.loadURL('about:blank') + const cdpAutomation = await this._getAutomation(win, options, automation) + + automation.use(cdpAutomation) + await Promise.all([ + _maybeRecordVideo(win.webContents, options), + this._handleDownloads(win, options.downloadsFolder, automation), + ]) + + // enabling can only happen once the window has loaded + await this._enableDebugger(win.webContents) + + await win.loadURL(url) + if (options.experimentalSessionAndOrigin) { + this._listenToOnBeforeHeaders(win) + } + + return win }, _attachDebugger (webContents) { @@ -304,11 +304,12 @@ module.exports = { const originalSendCommand = webContents.debugger.sendCommand - webContents.debugger.sendCommand = function (message, data) { + webContents.debugger.sendCommand = async function (message, data) { debugVerbose('debugger: sending %s with params %o', message, data) - return originalSendCommand.call(webContents.debugger, message, data) - .then((res) => { + try { + const res = await originalSendCommand.call(webContents.debugger, message, data) + let debugRes = res if (debug.enabled && (_.get(debugRes, 'data.length') > 100)) { @@ -319,10 +320,10 @@ module.exports = { debugVerbose('debugger: received response to %s: %o', message, debugRes) return res - }).catch((err) => { + } catch (err) { debug('debugger: received error on %s: %o', message, err) throw err - }) + } } webContents.debugger.sendCommand('Browser.getVersion') @@ -338,7 +339,7 @@ module.exports = { }) }, - _enableDebugger (webContents) { + _enableDebugger (webContents: WebContents) { debug('debugger: enable Console and Network') return webContents.debugger.sendCommand('Console.enable') @@ -375,7 +376,7 @@ module.exports = { }) }, - _listenToOnBeforeHeaders (win) { + _listenToOnBeforeHeaders (win: BrowserWindow) { // true if the frame only has a single parent, false otherwise const isFirstLevelIFrame = (frame) => (!!frame?.parent && !frame.parent.parent) @@ -449,85 +450,87 @@ module.exports = { }, async connectToNewSpec (browser, options, automation) { - this.open(browser, options.url, options, automation) + if (!options.url) throw new Error('Missing url in connectToNewSpec') + + await this.open(browser, options.url, options, automation) }, async connectToExisting () { throw new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for electron') }, - open (browser, url, options = {}, automation) { + async open (browser, url, options, automation) { const { projectRoot, isTextTerminal } = options debug('open %o', { browser, url }) - return savedState.create(projectRoot, isTextTerminal) - .then((state) => { - return state.get() - }).then((state) => { - debug('received saved state %o', state) + const State = await savedState.create(projectRoot, isTextTerminal) + const state = await State.get() - // get our electron default options - // TODO: this is bad, don't mutate the options object - options = this._defaultOptions(projectRoot, state, options, automation) + debug('received saved state %o', state) - // get the GUI window defaults now - options = Windows.defaults(options) + // get our electron default options + // TODO: this is bad, don't mutate the options object + options = this._defaultOptions(projectRoot, state, options, automation) - debug('browser window options %o', _.omitBy(options, _.isFunction)) + // get the GUI window defaults now + options = Windows.defaults(options) - const defaultLaunchOptions = utils.getDefaultLaunchOptions({ - preferences: options, - }) + debug('browser window options %o', _.omitBy(options, _.isFunction)) - return utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options) - }).then((launchOptions) => { - const { preferences } = launchOptions + const defaultLaunchOptions = utils.getDefaultLaunchOptions({ + preferences: options, + }) - debug('launching browser window to url: %s', url) + const launchOptions = await utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options) - return this._render(url, automation, preferences, { - projectRoot: options.projectRoot, - isTextTerminal: options.isTextTerminal, - }) - .then(async (win) => { - await _installExtensions(win, launchOptions.extensions, options) + const { preferences } = launchOptions + + debug('launching browser window to url: %s', url) - // cause the webview to receive focus so that - // native browser focus + blur events fire correctly - // https://github.com/cypress-io/cypress/issues/1939 - tryToCall(win, 'focusOnWebView') + const win = await this._render(url, automation, preferences, { + projectRoot: options.projectRoot, + isTextTerminal: options.isTextTerminal, + }) - const events = new EE + await _installExtensions(win, launchOptions.extensions, options) - win.once('closed', () => { - debug('closed event fired') + // cause the webview to receive focus so that + // native browser focus + blur events fire correctly + // https://github.com/cypress-io/cypress/issues/1939 + tryToCall(win, 'focusOnWebView') - Windows.removeAllExtensions(win) + const events = new EE - return events.emit('exit') - }) + win.once('closed', () => { + debug('closed event fired') - instance = _.extend(events, { - pid: [tryToCall(win, () => { - return win.webContents.getOSProcessId() - })], - browserWindow: win, - kill () { - if (this.isProcessExit) { - // if the process is exiting, all BrowserWindows will be destroyed anyways - return - } - - return tryToCall(win, 'destroy') - }, - removeAllListeners () { - return tryToCall(win, 'removeAllListeners') - }, - }) + Windows.removeAllExtensions(win) - return instance - }) + return events.emit('exit') + }) + + const mainPid: number = tryToCall(win, () => { + return win.webContents.getOSProcessId() }) + + instance = _.extend(events, { + pid: mainPid, + allPids: [mainPid], + browserWindow: win, + kill (this: BrowserInstance) { + if (this.isProcessExit) { + // if the process is exiting, all BrowserWindows will be destroyed anyways + return + } + + return tryToCall(win, 'destroy') + }, + removeAllListeners () { + return tryToCall(win, 'removeAllListeners') + }, + }) as BrowserInstance + + return instance }, } diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index 66f0533d72c3..08dafccf42a3 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -10,6 +10,11 @@ export type Browser = FoundBrowser & { export type BrowserInstance = EventEmitter & { kill: () => void + /** + * Used in Electron to keep a list of what pids are spawned by the browser, to keep them separate from the launchpad/server pids. + * In all other browsers, the process tree of `BrowserInstance.pid` can be used instead of `allPids`. + */ + allPids?: number[] pid: number /** * After `.open`, this is set to the `Browser` used to launch this instance. diff --git a/packages/server/lib/util/process_profiler.ts b/packages/server/lib/util/process_profiler.ts index 01563aeb28e5..d228c7b30bd9 100644 --- a/packages/server/lib/util/process_profiler.ts +++ b/packages/server/lib/util/process_profiler.ts @@ -51,9 +51,9 @@ export const _groupCyProcesses = ({ list }: si.Systeminformation.ProcessesData) const isBrowserProcess = (proc: Process): boolean => { const instance = browsers.getBrowserInstance() // electron will return a list of pids, since it's not a hierarchy - const pid: number | number[] = instance && instance.pid + const pids: number[] = instance.allPids ? instance.allPids : [instance.pid] - return (Array.isArray(pid) ? (pid as number[]).includes(proc.pid) : proc.pid === pid) + return (pids.includes(proc.pid)) || isParentProcessInGroup(proc, 'browser') } diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index 802d48415cd7..96f152d61f62 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -78,7 +78,7 @@ describe('lib/browsers/electron', () => { context('.connectToNewSpec', () => { it('calls open with the browser, url, options, and automation', async function () { sinon.stub(electron, 'open').withArgs({ isHeaded: true }, 'http://www.example.com', { url: 'http://www.example.com' }, this.automation) - await electron.connectToNewSpec({ isHeaded: true }, 50505, { url: 'http://www.example.com' }, this.automation) + await electron.connectToNewSpec({ isHeaded: true }, { url: 'http://www.example.com' }, this.automation) expect(electron.open).to.be.called }) }) @@ -120,7 +120,8 @@ describe('lib/browsers/electron', () => { expect(this.win.webContents.getOSProcessId).to.be.calledOnce - expect(obj.pid).to.deep.eq([ELECTRON_PID]) + expect(obj.pid).to.eq(ELECTRON_PID) + expect(obj.allPids).to.deep.eq([ELECTRON_PID]) }) }) @@ -722,7 +723,7 @@ describe('lib/browsers/electron', () => { ) }) - it('adds pid of new BrowserWindow to pid list', function () { + it('adds pid of new BrowserWindow to allPids list', function () { const opts = electron._defaultOptions(this.options.projectRoot, this.state, this.options) const NEW_WINDOW_PID = ELECTRON_PID * 2 @@ -739,7 +740,7 @@ describe('lib/browsers/electron', () => { }).then((instance) => { return opts.onNewWindow.call(this.win, {}, this.url) .then(() => { - expect(instance.pid).to.deep.eq([ELECTRON_PID, NEW_WINDOW_PID]) + expect(instance.allPids).to.deep.eq([ELECTRON_PID, NEW_WINDOW_PID]) }) }) }) From ce151923dab8d8e535bcc4a7be87f49de71db952 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 26 Aug 2022 13:24:21 -0400 Subject: [PATCH 08/15] Suggestions from code review --- .../server/lib/browsers/browser-cri-client.ts | 10 +- .../server/lib/browsers/cdp_automation.ts | 9 +- packages/server/lib/browsers/chrome.ts | 8 +- packages/server/lib/browsers/cri-client.ts | 76 +++------ packages/server/lib/browsers/electron.ts | 10 +- packages/server/lib/browsers/index.ts | 152 ++++++++---------- packages/server/lib/browsers/webkit.ts | 2 +- packages/server/lib/modes/run.ts | 2 + 8 files changed, 116 insertions(+), 153 deletions(-) diff --git a/packages/server/lib/browsers/browser-cri-client.ts b/packages/server/lib/browsers/browser-cri-client.ts index 0e03bf03f469..2c6bc5e6cfbb 100644 --- a/packages/server/lib/browsers/browser-cri-client.ts +++ b/packages/server/lib/browsers/browser-cri-client.ts @@ -2,7 +2,7 @@ import CRI from 'chrome-remote-interface' import Debug from 'debug' import { _connectAsync, _getDelayMsForRetry } from './protocol' import * as errors from '../errors' -import { create, CRIWrapper } from './cri-client' +import { create, CriClient } from './cri-client' const HOST = '127.0.0.1' @@ -67,8 +67,8 @@ const retryWithIncreasingDelay = async (retryable: () => Promise, browserN } export class BrowserCriClient { - currentlyAttachedTarget: CRIWrapper.Client | undefined - private constructor (private browserClient: CRIWrapper.Client, private versionInfo, private port: number, private browserName: string, private onAsynchronousError: Function) {} + currentlyAttachedTarget: CriClient | undefined + private constructor (private browserClient: CriClient, private versionInfo, private port: number, private browserName: string, private onAsynchronousError: Function) {} /** * Factory method for the browser cri client. Connects to the browser and then returns a chrome remote interface wrapper around the @@ -79,7 +79,7 @@ export class BrowserCriClient { * @param onAsynchronousError callback for any cdp fatal errors * @returns a wrapper around the chrome remote interface that is connected to the browser target */ - static async create (port: number, browserName: string, onAsynchronousError: Function, onReconnect?: (client: CRIWrapper.Client) => void): Promise { + static async create (port: number, browserName: string, onAsynchronousError: Function, onReconnect?: (client: CriClient) => void): Promise { await ensureLiveBrowser(port, browserName) return retryWithIncreasingDelay(async () => { @@ -110,7 +110,7 @@ export class BrowserCriClient { * @param url the url to attach to * @returns the chrome remote interface wrapper for the target */ - attachToTargetUrl = async (url: string): Promise => { + attachToTargetUrl = async (url: string): Promise => { // Continue trying to re-attach until succcessful. // If the browser opens slowly, this will fail until // The browser and automation API is ready, so we try a few diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index 617fa6b84e61..df00b00d059d 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -3,6 +3,7 @@ import _ from 'lodash' import Bluebird from 'bluebird' import type { Protocol } from 'devtools-protocol' +import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping' import { cors, uri } from '@packages/network' import debugModule from 'debug' import { URL } from 'url' @@ -10,6 +11,10 @@ import { URL } from 'url' import type { Automation } from '../automation' import type { ResourceType, BrowserPreRequest, BrowserResponseReceived } from '@packages/proxy' +export type CdpCommand = keyof ProtocolMapping.Commands + +export type CdpEvent = keyof ProtocolMapping.Events + const debugVerbose = debugModule('cypress-verbose:server:browsers:cdp_automation') export type CyCookie = Pick & { @@ -163,9 +168,9 @@ export const normalizeResourceType = (resourceType: string | undefined): Resourc return ffToStandardResourceTypeMap[resourceType] || 'other' } -type SendDebuggerCommand = (message: string, data?: any) => Promise +type SendDebuggerCommand = (message: CdpCommand, data?: any) => Promise type SendCloseCommand = (shouldKeepTabOpen: boolean) => Promise | void -type OnFn = (eventName: string, cb: Function) => void +type OnFn = (eventName: CdpEvent, cb: Function) => void // the intersection of what's valid in CDP and what's valid in FFCDP // Firefox: https://searchfox.org/mozilla-central/rev/98a9257ca2847fad9a19631ac76199474516b31e/remote/cdp/domains/parent/Network.jsm#22 diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 22c52fae535d..c2500b1ee230 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -18,7 +18,7 @@ import utils from './utils' import type { Browser } from './types' import { BrowserCriClient } from './browser-cri-client' import type { LaunchedBrowser } from '@packages/launcher/lib/browsers' -import type { CRIWrapper } from './cri-client' +import type { CriClient } from './cri-client' import type { Automation } from '../automation' import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' @@ -318,7 +318,7 @@ const _handleDownloads = async function (client, dir, automation) { let frameTree let gettingFrameTree -const onReconnect = (client: CRIWrapper.Client) => { +const onReconnect = (client: CriClient) => { // if the client disconnects (e.g. due to a computer sleeping), update // the frame tree on reconnect in cases there were changes while // the client was disconnected @@ -326,7 +326,7 @@ const onReconnect = (client: CRIWrapper.Client) => { } // eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces -const _updateFrameTree = (client: CRIWrapper.Client, eventName) => async () => { +const _updateFrameTree = (client: CriClient, eventName) => async () => { debug(`update frame tree for ${eventName}`) gettingFrameTree = new Promise(async (resolve) => { @@ -431,7 +431,7 @@ const _handlePausedRequests = async (client) => { }) } -const _setAutomation = async (client: CRIWrapper.Client, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise, options: BrowserLaunchOpts) => { +const _setAutomation = async (client: CriClient, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise, options: BrowserLaunchOpts) => { const cdpAutomation = await CdpAutomation.create(client.send, client.on, resetBrowserTargets, automation, !!options.experimentalSessionAndOrigin) return automation.use(cdpAutomation) diff --git a/packages/server/lib/browsers/cri-client.ts b/packages/server/lib/browsers/cri-client.ts index 44bcfdb3d626..2958c7946e8a 100644 --- a/packages/server/lib/browsers/cri-client.ts +++ b/packages/server/lib/browsers/cri-client.ts @@ -2,6 +2,7 @@ import debugModule from 'debug' import _ from 'lodash' import CRI from 'chrome-remote-interface' import * as errors from '../errors' +import type { CdpCommand, CdpEvent } from './cdp_automation' const debug = debugModule('cypress:server:browsers:cri-client') // debug using cypress-verbose:server:browsers:cri-client:send:* @@ -11,54 +12,25 @@ const debugVerboseReceive = debugModule('cypress-verbose:server:browsers:cri-cli const WEBSOCKET_NOT_OPEN_RE = /^WebSocket is (?:not open|already in CLOSING or CLOSED state)/ -/** - * Enumerations to make programming CDP slightly simpler - provides - * IntelliSense whenever you use named types. - */ -export namespace CRIWrapper { - export type Command = - 'Page.enable' | - 'Network.enable' | - 'Console.enable' | - 'Browser.getVersion' | - 'Page.bringToFront' | - 'Page.captureScreenshot' | - 'Page.navigate' | - 'Page.startScreencast' | - 'Page.screencastFrameAck' | - 'Page.setDownloadBehavior' | - string - - export type EventName = - 'Page.screencastFrame' | - 'Page.downloadWillBegin' | - 'Page.downloadProgress' | - string - +export interface CriClient { /** - * Wrapper for Chrome Remote Interface client. Only allows "send" method. - * @see https://github.com/cyrus-and/chrome-remote-interface#clientsendmethod-params-callback + * The target id attached to by this client */ - export interface Client { - /** - * The target id attached to by this client - */ - targetId: string - /** - * Sends a command to the Chrome remote interface. - * @example client.send('Page.navigate', { url }) - */ - send (command: Command, params?: object): Promise - /** - * Registers callback for particular event. - * @see https://github.com/cyrus-and/chrome-remote-interface#class-cdp - */ - on (eventName: EventName, cb: Function): void - /** - * Calls underlying remote interface client close - */ - close (): Promise - } + targetId: string + /** + * Sends a command to the Chrome remote interface. + * @example client.send('Page.navigate', { url }) + */ + send (command: CdpCommand, params?: object): Promise + /** + * Registers callback for particular event. + * @see https://github.com/cyrus-and/chrome-remote-interface#class-cdp + */ + on (eventName: CdpEvent, cb: Function): void + /** + * Calls underlying remote interface client close + */ + close (): Promise } const maybeDebugCdpMessages = (cri) => { @@ -104,16 +76,16 @@ const maybeDebugCdpMessages = (cri) => { type DeferredPromise = { resolve: Function, reject: Function } -export const create = async (target: string, onAsynchronousError: Function, host?: string, port?: number, onReconnect?: (client: CRIWrapper.Client) => void): Promise => { - const subscriptions: {eventName: CRIWrapper.EventName, cb: Function}[] = [] - const enableCommands: CRIWrapper.Command[] = [] - let enqueuedCommands: {command: CRIWrapper.Command, params: any, p: DeferredPromise }[] = [] +export const create = async (target: string, onAsynchronousError: Function, host?: string, port?: number, onReconnect?: (client: CriClient) => void): Promise => { + const subscriptions: {eventName: CdpEvent, cb: Function}[] = [] + const enableCommands: CdpCommand[] = [] + let enqueuedCommands: {command: CdpCommand, params: any, p: DeferredPromise }[] = [] let closed = false // has the user called .close on this? let connected = false // is this currently connected to CDP? let cri - let client: CRIWrapper.Client + let client: CriClient const reconnect = async () => { debug('disconnected, attempting to reconnect... %o', { closed }) @@ -184,7 +156,7 @@ export const create = async (target: string, onAsynchronousError: Function, host client = { targetId: target, - async send (command: CRIWrapper.Command, params?: object) { + async send (command: CdpCommand, params?: object) { const enqueue = () => { return new Promise((resolve, reject) => { enqueuedCommands.push({ command, params, p: { resolve, reject } }) diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index d078d63f69ed..8306ffc4f7c6 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -4,7 +4,7 @@ import path from 'path' import Debug from 'debug' import menu from '../gui/menu' import * as Windows from '../gui/windows' -import { CdpAutomation, screencastOpts } from './cdp_automation' +import { CdpAutomation, screencastOpts, CdpCommand, CdpEvent } from './cdp_automation' import * as savedState from '../saved_state' import utils from './utils' import * as errors from '../errors' @@ -41,14 +41,14 @@ const tryToCall = function (win, method) { } const _getAutomation = async function (win, options, parent) { - async function sendCommand (method: string, data?: object) { + async function sendCommand (method: CdpCommand, data?: object) { return tryToCall(win, () => { return win.webContents.debugger.sendCommand .call(win.webContents.debugger, method, data) }) } - const on = (eventName, cb) => { + const on = (eventName: CdpEvent, cb) => { win.webContents.debugger.on('message', (event, method, params) => { if (method === eventName) { cb(params) @@ -93,7 +93,7 @@ const _getAutomation = async function (win, options, parent) { return automation } -async function _installExtensions (win: BrowserWindow, extensionPaths: string[], options) { +function _installExtensions (win: BrowserWindow, extensionPaths: string[], options) { Windows.removeAllExtensions(win) return Promise.all(extensionPaths.map((extensionPath) => { @@ -500,7 +500,7 @@ export = { // https://github.com/cypress-io/cypress/issues/1939 tryToCall(win, 'focusOnWebView') - const events = new EE + const events = new EE() win.once('closed', () => { debug('closed event fired') diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index 7d35542af06c..f9278f9142fb 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -1,5 +1,5 @@ import _ from 'lodash' -import Promise from 'bluebird' +import Bluebird from 'bluebird' import Debug from 'debug' import utils from './utils' import check from 'check-more-types' @@ -27,7 +27,7 @@ const kill = function (unbind = true, isProcessExit = false) { instance = null - return new Promise((resolve) => { + return new Promise((resolve) => { _instance.once('exit', () => { if (unbind) { _instance.removeAllListeners() @@ -71,31 +71,23 @@ async function setFocus () { } } -function getBrowserLauncher (browser: Browser, browsers: FoundBrowser[]): BrowserLauncher { +async function getBrowserLauncher (browser: Browser, browsers: FoundBrowser[]): Promise { debug('getBrowserLauncher %o', { browser }) - if (browser.name === 'electron') { - return require('./electron') as typeof import('./electron') - } + if (browser.name === 'electron') return await import('./electron') - if (browser.family === 'chromium') { - return require('./chrome') as typeof import('./chrome') - } + if (browser.family === 'chromium') return await import('./chrome') - if (browser.family === 'firefox') { - return require('./firefox') as typeof import('./firefox') - } + if (browser.family === 'firefox') return await import('./firefox') - if (browser.family === 'webkit') { - return require('./webkit') as typeof import('./webkit') - } + if (browser.family === 'webkit') return await import('./webkit') return utils.throwBrowserNotFound(browser.name, browsers) } process.once('exit', () => kill(true, true)) -exports = { +export = { ensureAndGetByNameOrPath: utils.ensureAndGetByNameOrPath, isBrowserFamily, @@ -129,7 +121,7 @@ exports = { }, async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { - const browserLauncher = getBrowserLauncher(browser, options.browsers) + const browserLauncher = await getBrowserLauncher(browser, options.browsers) await browserLauncher.connectToExisting(browser, options, automation) @@ -137,7 +129,7 @@ exports = { }, async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { - const browserLauncher = getBrowserLauncher(browser, options.browsers) + const browserLauncher = await getBrowserLauncher(browser, options.browsers) // Instance will be null when we're dealing with electron. In that case we don't need a browserCriClient await browserLauncher.connectToNewSpec(browser, options, automation) @@ -145,73 +137,65 @@ exports = { return this.getBrowserInstance() }, - open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx) { - return kill(true) - .then(() => { - _.defaults(options, { - onBrowserOpen () {}, - onBrowserClose () {}, - }) - - ctx.browser.setBrowserStatus('opening') - - const browserLauncher = getBrowserLauncher(browser, options.browsers) - - if (!options.url) throw new Error('Missing url in browsers.open') - - debug('opening browser %o', browser) - - return browserLauncher.open(browser, options.url, options, automation) - .then((i) => { - debug('browser opened') - // TODO: bind to process.exit here - // or move this functionality into cypress-core-launder - - i.browser = browser - - instance = i - - // TODO: normalizing opening and closing / exiting - // so that there is a default for each browser but - // enable the browser to configure the interface - instance.once('exit', () => { - ctx.browser.setBrowserStatus('closed') - // TODO: make this a required property - if (!options.onBrowserClose) throw new Error('onBrowserClose did not exist in interactive mode') - - options.onBrowserClose() - instance = null - }) - - // TODO: instead of waiting an arbitrary - // amount of time here we could instead - // wait for the socket.io connect event - // which would mean that our browser is - // completely rendered and open. that would - // mean moving this code out of here and - // into the project itself - // (just like headless code) - // ---------------------------- - // give a little padding around - // the browser opening - return Promise.delay(1000) - .then(() => { - if (instance === null) { - return null - } - - // TODO: make this a required property - if (!options.onBrowserOpen) throw new Error('onBrowserOpen did not exist in interactive mode') - - options.onBrowserOpen() - ctx.browser.setBrowserStatus('open') - - return instance - }) - }) + async open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx) { + await kill(true) + + _.defaults(options, { + onBrowserOpen () {}, + onBrowserClose () {}, }) + + ctx.browser.setBrowserStatus('opening') + + const browserLauncher = await getBrowserLauncher(browser, options.browsers) + + if (!options.url) throw new Error('Missing url in browsers.open') + + debug('opening browser %o', browser) + + const _instance = await browserLauncher.open(browser, options.url, options, automation) + + debug('browser opened') + + instance = _instance + instance.browser = browser + + // TODO: normalizing opening and closing / exiting + // so that there is a default for each browser but + // enable the browser to configure the interface + instance.once('exit', () => { + ctx.browser.setBrowserStatus('closed') + // TODO: make this a required property + if (!options.onBrowserClose) throw new Error('onBrowserClose did not exist in interactive mode') + + options.onBrowserClose() + instance = null + }) + + // TODO: instead of waiting an arbitrary + // amount of time here we could instead + // wait for the socket.io connect event + // which would mean that our browser is + // completely rendered and open. that would + // mean moving this code out of here and + // into the project itself + // (just like headless code) + // ---------------------------- + // give a little padding around + // the browser opening + await Bluebird.delay(1000) + + if (instance === null) { + return null + } + + // TODO: make this a required property + if (!options.onBrowserOpen) throw new Error('onBrowserOpen did not exist in interactive mode') + + options.onBrowserOpen() + ctx.browser.setBrowserStatus('open') + + return instance }, setFocus, } as const - -export = exports diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index e8a669361571..0eb3399a64b1 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -19,7 +19,7 @@ export async function connectToNewSpec (browser: Browser, options: BrowserNewTab await wkAutomation.reset(options.url) } -export async function connectToExisting () { +export function connectToExisting () { throw new Error('Cypress-in-Cypress is not supported for WebKit.') } diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index d196afb75355..c52d792f44b6 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -1018,6 +1018,8 @@ async function ready (options: { projectRoot: string, record: boolean, key: stri socketId, parallel, onError, + // TODO: refactor this so that augmenting the browser object here is not needed and there is no type conflict + // @ts-expect-error runSpecs augments browser with isHeadless and isHeaded, which is "missing" from the type here browser, project, runUrl, From 8bd49f3f6569be9b52f8a1a3383eb1ace818c281 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 26 Aug 2022 13:32:00 -0400 Subject: [PATCH 09/15] Clean up new type errors --- packages/server/lib/open_project.ts | 43 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 242a88a96ed0..8a5c8858ef2a 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -20,7 +20,7 @@ const debug = Debug('cypress:server:open_project') export class OpenProject { private projectBase: ProjectBase | null = null - relaunchBrowser: ((...args: unknown[]) => Bluebird) | null = null + relaunchBrowser: (() => Promise) | null = null constructor () { return autoBindDebug(this) @@ -151,41 +151,38 @@ export class OpenProject { options.onError = this.projectBase.options.onError - this.relaunchBrowser = () => { + this.relaunchBrowser = async () => { debug( 'launching browser: %o, spec: %s', browser, spec.relative, ) - return Bluebird.try(() => { - if (!cfg.isTextTerminal && cfg.experimentalInteractiveRunEvents) { - return runEvents.execute('before:spec', cfg, spec) - } - + if (!cfg.isTextTerminal && cfg.experimentalInteractiveRunEvents) { + await runEvents.execute('before:spec', cfg, spec) + } else { // clear cookies and all session data before each spec cookieJar.removeAllCookies() session.clearSessions() - }) - .then(() => { - // TODO: Stub this so we can detect it being called - if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { - return browsers.connectToExisting(browser, options, automation) - } + } - if (options.shouldLaunchNewTab) { - const onInitializeNewBrowserTab = async () => { - await this.resetBrowserState() - } + // TODO: Stub this so we can detect it being called + if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) { + return await browsers.connectToExisting(browser, options, automation) + } - // If we do not launch the browser, - // we tell it that we are ready - // to receive the next spec - return browsers.connectToNewSpec(browser, { onInitializeNewBrowserTab, ...options }, automation) + if (options.shouldLaunchNewTab) { + const onInitializeNewBrowserTab = async () => { + await this.resetBrowserState() } - return browsers.open(browser, options, automation, this._ctx) - }) + // If we do not launch the browser, + // we tell it that we are ready + // to receive the next spec + return await browsers.connectToNewSpec(browser, { onInitializeNewBrowserTab, ...options }, automation) + } + + return await browsers.open(browser, options, automation, this._ctx) } return this.relaunchBrowser() From 8e8f62ab1a1047f630442ba8b5fb275188ca80a0 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 26 Aug 2022 13:37:20 -0400 Subject: [PATCH 10/15] electron.connectToExisting can be sync --- packages/server/lib/browsers/electron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 8306ffc4f7c6..5e022761a508 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -455,7 +455,7 @@ export = { await this.open(browser, options.url, options, automation) }, - async connectToExisting () { + connectToExisting () { throw new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for electron') }, From 501895a0d9d064051941ac5feb468c40630489dc Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 26 Aug 2022 14:48:03 -0400 Subject: [PATCH 11/15] more type errors for the type god --- packages/server/lib/open_project.ts | 2 +- packages/server/lib/project-base.ts | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 8a5c8858ef2a..632e0dc14c63 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -213,7 +213,7 @@ export class OpenProject { close () { debug('closing opened project') - this.closeOpenProjectAndBrowsers() + return this.closeOpenProjectAndBrowsers() } changeUrlToSpec (spec: Cypress.Spec) { diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 94315d6f636b..e1278f273d3b 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -299,14 +299,6 @@ export class ProjectBase extends EE { return runEvents.execute('after:run', config) } - _onError> (err: Error, options: Options) { - debug('got plugins error', err.stack) - - browsers.close() - - options.onError(err) - } - initializeReporter ({ report, reporter, From 5e743998a8fc54197aeacd8a2606028837a168ce Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 26 Aug 2022 17:00:16 -0400 Subject: [PATCH 12/15] Suggestions from code review --- packages/server/lib/browsers/index.ts | 7 +++---- packages/server/lib/modes/run.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index f9278f9142fb..89dd0dc087c5 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -106,7 +106,6 @@ export = { }, // note: does not guarantee that `browser` is still running - // note: electron will return a list of pids for each webContent getBrowserInstance () { return instance }, @@ -120,7 +119,7 @@ export = { return utils.getBrowsers() }, - async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { + async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation): Promise { const browserLauncher = await getBrowserLauncher(browser, options.browsers) await browserLauncher.connectToExisting(browser, options, automation) @@ -128,7 +127,7 @@ export = { return this.getBrowserInstance() }, - async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { + async connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation): Promise { const browserLauncher = await getBrowserLauncher(browser, options.browsers) // Instance will be null when we're dealing with electron. In that case we don't need a browserCriClient @@ -137,7 +136,7 @@ export = { return this.getBrowserInstance() }, - async open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx) { + async open (browser: Browser, options: BrowserLaunchOpts, automation: Automation, ctx): Promise { await kill(true) _.defaults(options, { diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index c52d792f44b6..0343e4427938 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -533,11 +533,11 @@ function waitForBrowserToConnect (options: { project: Project, socketId: string, const wait = () => { debug('waiting for socket to connect and browser to launch...') - return Bluebird.join( + return Bluebird.all([ waitForSocketConnection(project, socketId), // TODO: remove the need to extend options and coerce this type launchBrowser(options as typeof options & { setScreenshotMetadata: SetScreenshotMetadata }), - ) + ]) .timeout(browserTimeout) .catch(Bluebird.TimeoutError, async (err) => { attempts += 1 From d25b9e0dd56d5868434008c91511d4f86c482026 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 26 Aug 2022 23:12:36 -0400 Subject: [PATCH 13/15] refactor: move more of video capture into browser automations --- packages/resolve-dist/lib/index.ts | 2 - .../server/lib/browsers/cdp_automation.ts | 14 ++- packages/server/lib/browsers/chrome.ts | 38 +++----- packages/server/lib/browsers/electron.ts | 96 +++++++++---------- packages/server/lib/browsers/firefox.ts | 10 +- packages/server/lib/browsers/utils.ts | 7 +- packages/server/lib/gui/windows.ts | 70 ++++++-------- packages/server/lib/modes/interactive.ts | 29 +++--- packages/server/lib/modes/run.ts | 82 +++------------- packages/server/lib/open_project.ts | 2 +- packages/server/lib/saved_state.ts | 2 - packages/server/lib/video_capture.ts | 3 +- .../server/test/integration/cypress_spec.js | 6 +- .../server/test/unit/browsers/chrome_spec.js | 22 +++-- .../test/unit/browsers/electron_spec.js | 9 +- packages/types/src/server.ts | 10 +- 16 files changed, 164 insertions(+), 238 deletions(-) diff --git a/packages/resolve-dist/lib/index.ts b/packages/resolve-dist/lib/index.ts index 32840ce388cb..809e42773b1b 100644 --- a/packages/resolve-dist/lib/index.ts +++ b/packages/resolve-dist/lib/index.ts @@ -33,7 +33,5 @@ export const getPathToIndex = (pkg: RunnerPkg) => { } export const getPathToDesktopIndex = (graphqlPort: number) => { - // For now, if we see that there's a CYPRESS_INTERNAL_VITE_DEV - // we assume we're running Cypress targeting that (dev server) return `http://localhost:${graphqlPort}/__launchpad/index.html` } diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index df00b00d059d..4d47aa459d70 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -10,6 +10,7 @@ import { URL } from 'url' import type { Automation } from '../automation' import type { ResourceType, BrowserPreRequest, BrowserResponseReceived } from '@packages/proxy' +import type { WriteVideoFrame } from '@packages/types' export type CdpCommand = keyof ProtocolMapping.Commands @@ -168,9 +169,9 @@ export const normalizeResourceType = (resourceType: string | undefined): Resourc return ffToStandardResourceTypeMap[resourceType] || 'other' } -type SendDebuggerCommand = (message: CdpCommand, data?: any) => Promise +type SendDebuggerCommand = (message: T, data?: any) => Promise type SendCloseCommand = (shouldKeepTabOpen: boolean) => Promise | void -type OnFn = (eventName: CdpEvent, cb: Function) => void +type OnFn = (eventName: T, cb: (data: ProtocolMapping.Events[T][0]) => void) => void // the intersection of what's valid in CDP and what's valid in FFCDP // Firefox: https://searchfox.org/mozilla-central/rev/98a9257ca2847fad9a19631ac76199474516b31e/remote/cdp/domains/parent/Network.jsm#22 @@ -188,6 +189,15 @@ export class CdpAutomation { onFn('Network.responseReceived', this.onResponseReceived) } + async startVideoRecording (writeVideoFrame: WriteVideoFrame, screencastOpts?) { + this.onFn('Page.screencastFrame', async (e) => { + writeVideoFrame(Buffer.from(e.data, 'base64')) + await this.sendDebuggerCommandFn('Page.screencastFrameAck', { sessionId: e.sessionId }) + }) + + await this.sendDebuggerCommandFn('Page.startScreencast', screencastOpts) + } + static async create (sendDebuggerCommandFn: SendDebuggerCommand, onFn: OnFn, sendCloseCommandFn: SendCloseCommand, automation: Automation, experimentalSessionAndOrigin: boolean): Promise { const cdpAutomation = new CdpAutomation(sendDebuggerCommandFn, onFn, sendCloseCommandFn, automation, experimentalSessionAndOrigin) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index c2500b1ee230..2a70a0b94105 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -20,7 +20,7 @@ import { BrowserCriClient } from './browser-cri-client' import type { LaunchedBrowser } from '@packages/launcher/lib/browsers' import type { CriClient } from './cri-client' import type { Automation } from '../automation' -import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' +import type { BrowserLaunchOpts, BrowserNewTabOpts, WriteVideoFrame } from '@packages/types' const debug = debugModule('cypress:server:browsers:chrome') @@ -249,22 +249,10 @@ const _disableRestorePagesPrompt = function (userDir) { .catch(() => { }) } -const _maybeRecordVideo = async function (client, options, browserMajorVersion) { - if (!options.onScreencastFrame) { - debug('options.onScreencastFrame is false') +async function _recordVideo (cdpAutomation: CdpAutomation, writeVideoFrame: WriteVideoFrame, browserMajorVersion: number) { + const opts = browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1) - return client - } - - debug('starting screencast') - client.on('Page.screencastFrame', (meta) => { - options.onScreencastFrame(meta) - client.send('Page.screencastFrameAck', { sessionId: meta.sessionId }) - }) - - await client.send('Page.startScreencast', browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1)) - - return client + await cdpAutomation.startVideoRecording(writeVideoFrame, opts) } // a utility function that navigates to the given URL @@ -434,7 +422,9 @@ const _handlePausedRequests = async (client) => { const _setAutomation = async (client: CriClient, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise, options: BrowserLaunchOpts) => { const cdpAutomation = await CdpAutomation.create(client.send, client.on, resetBrowserTargets, automation, !!options.experimentalSessionAndOrigin) - return automation.use(cdpAutomation) + automation.use(cdpAutomation) + + return cdpAutomation } export = { @@ -448,7 +438,7 @@ export = { _removeRootExtension, - _maybeRecordVideo, + _recordVideo, _navigateUsingCRI, @@ -468,7 +458,7 @@ export = { return browserCriClient }, - async _writeExtension (browser: Browser, options) { + async _writeExtension (browser: Browser, options: BrowserLaunchOpts) { if (browser.isHeadless) { debug('chrome is running headlessly, not installing extension') @@ -565,7 +555,7 @@ export = { await this.attachListeners(browser, options.url, pageCriClient, automation, options) }, - async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation) { + async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { const port = await protocol.getRemoteDebuggingPort() debug('connecting to existing chrome instance with url and debugging port', { url: options.url, port }) @@ -580,17 +570,17 @@ export = { await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) }, - async attachListeners (browser: Browser, url: string, pageCriClient, automation: Automation, options: BrowserLaunchOpts & { onInitializeNewBrowserTab?: () => void }) { + async attachListeners (browser: Browser, url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) { if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') - await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) + const cdpAutomation = await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) await pageCriClient.send('Page.enable') - await options.onInitializeNewBrowserTab?.() + await options['onInitializeNewBrowserTab']?.() await Promise.all([ - this._maybeRecordVideo(pageCriClient, options, browser.majorVersion), + options.writeVideoFrame && this._recordVideo(cdpAutomation, options.writeVideoFrame, browser.majorVersion), this._handleDownloads(pageCriClient, options.downloadsFolder, automation), ]) diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 5e022761a508..7f3eba817379 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -8,9 +8,13 @@ import { CdpAutomation, screencastOpts, CdpCommand, CdpEvent } from './cdp_autom import * as savedState from '../saved_state' import utils from './utils' import * as errors from '../errors' -import type { BrowserInstance } from './types' +import type { Browser, BrowserInstance } from './types' import type { BrowserWindow, WebContents } from 'electron' import type { Automation } from '../automation' +import type { BrowserLaunchOpts, Preferences } from '@packages/types' + +// TODO: unmix these two types +type ElectronOpts = Windows.WindowOptions & BrowserLaunchOpts const debug = Debug('cypress:server:browsers:electron') const debugVerbose = Debug('cypress-verbose:server:browsers:electron') @@ -68,7 +72,7 @@ const _getAutomation = async function (win, options, parent) { // after upgrading to Electron 8, CDP screenshots can hang if a screencast is not also running // workaround: start and stop screencasts between screenshots // @see https://github.com/cypress-io/cypress/pull/6555#issuecomment-596747134 - if (!options.onScreencastFrame) { + if (!options.writeVideoFrame) { await sendCommand('Page.startScreencast', screencastOpts()) const ret = await fn(message, data) @@ -105,37 +109,18 @@ function _installExtensions (win: BrowserWindow, extensionPaths: string[], optio })) } -const _maybeRecordVideo = async function (webContents, options) { - const { onScreencastFrame } = options - - debug('maybe recording video %o', { onScreencastFrame }) - - if (!onScreencastFrame) { - return - } - - webContents.debugger.on('message', (event, method, params) => { - if (method === 'Page.screencastFrame') { - onScreencastFrame(params) - webContents.debugger.sendCommand('Page.screencastFrameAck', { sessionId: params.sessionId }) - } - }) - - await webContents.debugger.sendCommand('Page.startScreencast', screencastOpts()) -} - export = { - _defaultOptions (projectRoot, state, options, automation) { + _defaultOptions (projectRoot: string | undefined, state: Preferences, options: BrowserLaunchOpts, automation: Automation): ElectronOpts { const _this = this - const defaults = { - x: state.browserX, - y: state.browserY, + const defaults: Windows.WindowOptions = { + x: state.browserX || undefined, + y: state.browserY || undefined, width: state.browserWidth || 1280, height: state.browserHeight || 720, - devTools: state.isBrowserDevToolsOpen, minWidth: 100, minHeight: 100, + devTools: state.isBrowserDevToolsOpen || undefined, contextMenu: true, partition: this._getPartition(options), trackState: { @@ -148,8 +133,21 @@ export = { webPreferences: { sandbox: true, }, + show: !options.browser.isHeadless, + // prevents a tiny 1px padding around the window + // causing screenshots/videos to be off by 1px + resizable: !options.browser.isHeadless, + onCrashed () { + const err = errors.get('RENDERER_CRASHED') + + errors.log(err) + + if (!options.onError) throw new Error('Missing onError in onCrashed') + + options.onError(err) + }, onFocus () { - if (options.show) { + if (!options.browser.isHeadless) { return menu.set({ withInternalDevTools: true }) } }, @@ -176,18 +174,12 @@ export = { }, } - if (options.browser.isHeadless) { - // prevents a tiny 1px padding around the window - // causing screenshots/videos to be off by 1px - options.resizable = false - } - return _.defaultsDeep({}, options, defaults) }, _getAutomation, - async _render (url: string, automation: Automation, preferences, options: { projectRoot: string, isTextTerminal: boolean }) { + async _render (url: string, automation: Automation, preferences, options: { projectRoot?: string, isTextTerminal: boolean }) { const win = Windows.create(options.projectRoot, preferences) if (preferences.browser.isHeadless) { @@ -212,21 +204,25 @@ export = { const [parentX, parentY] = parent.getPosition() - options = this._defaultOptions(projectRoot, state, options, automation) + const electronOptions = this._defaultOptions(projectRoot, state, options, automation) - _.extend(options, { + _.extend(electronOptions, { x: parentX + 100, y: parentY + 100, trackState: false, + // in run mode, 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 + show: !options.isTextTerminal, }) - const win = Windows.create(projectRoot, options) + const win = Windows.create(projectRoot, electronOptions) // needed by electron since we prevented default and are creating // our own BrowserWindow (https://electron.atom.io/docs/api/web-contents/#event-new-window) e.newGuest = win - return this._launch(win, url, automation, options) + return this._launch(win, url, automation, electronOptions) }, async _launch (win: BrowserWindow, url: string, automation: Automation, options) { @@ -278,7 +274,7 @@ export = { automation.use(cdpAutomation) await Promise.all([ - _maybeRecordVideo(win.webContents, options), + options.writeVideoFrame && cdpAutomation.startVideoRecording(options.writeVideoFrame), this._handleDownloads(win, options.downloadsFolder, automation), ]) @@ -459,30 +455,26 @@ export = { throw new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for electron') }, - async open (browser, url, options, automation) { - const { projectRoot, isTextTerminal } = options - + async open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation) { debug('open %o', { browser, url }) - const State = await savedState.create(projectRoot, isTextTerminal) + const State = await savedState.create(options.projectRoot, options.isTextTerminal) const state = await State.get() debug('received saved state %o', state) // get our electron default options - // TODO: this is bad, don't mutate the options object - options = this._defaultOptions(projectRoot, state, options, automation) - - // get the GUI window defaults now - options = Windows.defaults(options) + const electronOptions: ElectronOpts = Windows.defaults( + this._defaultOptions(options.projectRoot, state, options, automation), + ) - debug('browser window options %o', _.omitBy(options, _.isFunction)) + debug('browser window options %o', _.omitBy(electronOptions, _.isFunction)) const defaultLaunchOptions = utils.getDefaultLaunchOptions({ - preferences: options, + preferences: electronOptions, }) - const launchOptions = await utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options) + const launchOptions = await utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, electronOptions) const { preferences } = launchOptions @@ -493,7 +485,7 @@ export = { isTextTerminal: options.isTextTerminal, }) - await _installExtensions(win, launchOptions.extensions, options) + await _installExtensions(win, launchOptions.extensions, electronOptions) // cause the webview to receive focus so that // native browser focus + blur events fire correctly diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index cb454643c837..4ebcd132f36e 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -1,5 +1,4 @@ import _ from 'lodash' -import Bluebird from 'bluebird' import fs from 'fs-extra' import Debug from 'debug' import getPort from 'get-port' @@ -21,6 +20,7 @@ import type { BrowserCriClient } from './browser-cri-client' import type { Automation } from '../automation' import { getCtx } from '@packages/data-context' import { getError } from '@packages/errors' +import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' const debug = Debug('cypress:server:browsers:firefox') @@ -371,7 +371,7 @@ export function _createDetachedInstance (browserInstance: BrowserInstance, brows return detachedInstance } -export async function connectToNewSpec (browser: Browser, options: any = {}, automation: Automation) { +export async function connectToNewSpec (browser: Browser, options: BrowserNewTabOpts, automation: Automation) { await firefoxUtil.connectToNewSpec(options, automation, browserCriClient) } @@ -379,7 +379,7 @@ export function connectToExisting () { getCtx().onWarning(getError('UNEXPECTED_INTERNAL_ERROR', new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for firefox'))) } -export async function open (browser: Browser, url, options: any = {}, automation): Promise { +export async function open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation): Promise { // see revision comment here https://wiki.mozilla.org/index.php?title=WebDriver/RemoteProtocol&oldid=1234946 const hasCdp = browser.majorVersion >= 86 const defaultLaunchOptions = utils.getDefaultLaunchOptions({ @@ -441,7 +441,7 @@ export async function open (browser: Browser, url, options: any = {}, automation const [ foxdriverPort, marionettePort, - ] = await Bluebird.all([getPort(), getPort()]) + ] = await Promise.all([getPort(), getPort()]) defaultLaunchOptions.preferences['devtools.debugger.remote-port'] = foxdriverPort defaultLaunchOptions.preferences['marionette.port'] = marionettePort @@ -452,7 +452,7 @@ export async function open (browser: Browser, url, options: any = {}, automation cacheDir, extensionDest, launchOptions, - ] = await Bluebird.all([ + ] = await Promise.all([ utils.ensureCleanCache(browser, options.isTextTerminal), utils.writeExtension(browser, options.isTextTerminal, options.proxyUrl, options.socketIoRoute), utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options), diff --git a/packages/server/lib/browsers/utils.ts b/packages/server/lib/browsers/utils.ts index 7c9062534d83..6fea932747ed 100644 --- a/packages/server/lib/browsers/utils.ts +++ b/packages/server/lib/browsers/utils.ts @@ -1,7 +1,7 @@ /* eslint-disable no-redeclare */ import Bluebird from 'bluebird' import _ from 'lodash' -import type { FoundBrowser } from '@packages/types' +import type { BrowserLaunchOpts, FoundBrowser } from '@packages/types' import * as errors from '../errors' import * as plugins from '../plugins' import { getError } from '@packages/errors' @@ -132,13 +132,14 @@ async function executeBeforeBrowserLaunch (browser, launchOptions: typeof defaul return launchOptions } -function extendLaunchOptionsFromPlugins (launchOptions, pluginConfigResult, options) { +function extendLaunchOptionsFromPlugins (launchOptions, pluginConfigResult, options: BrowserLaunchOpts) { // if we returned an array from the plugin // then we know the user is using the deprecated // interface and we need to warn them // TODO: remove this logic in >= v5.0.0 if (pluginConfigResult[0]) { - options.onWarning(getError( + // eslint-disable-next-line no-console + (options.onWarning || console.warn)(getError( 'DEPRECATED_BEFORE_BROWSER_LAUNCH_ARGS', )) diff --git a/packages/server/lib/gui/windows.ts b/packages/server/lib/gui/windows.ts index 5e3a11a928a8..b16673762fa5 100644 --- a/packages/server/lib/gui/windows.ts +++ b/packages/server/lib/gui/windows.ts @@ -3,34 +3,34 @@ import Bluebird from 'bluebird' import { BrowserWindow } from 'electron' import Debug from 'debug' import * as savedState from '../saved_state' -import { getPathToDesktopIndex } from '@packages/resolve-dist' const debug = Debug('cypress:server:windows') export type WindowOptions = Electron.BrowserWindowConstructorOptions & { type?: 'INDEX' - url?: string devTools?: boolean graphqlPort?: number + contextMenu?: boolean + partition?: string + /** + * Synchronizes properties of browserwindow with local state + */ + trackState?: TrackStateMap + onFocus?: () => void + onNewWindow?: (e, url, frameName, disposition, options) => Promise + onCrashed?: () => void } +type TrackStateMap = Record<'width' | 'height' | 'x' | 'y' | 'devTools', string> + let windows = {} let recentlyCreatedWindow = false -const getUrl = function (type, port: number) { - switch (type) { - case 'INDEX': - return getPathToDesktopIndex(port) - - default: - throw new Error(`No acceptable window type found for: '${type}'`) - } -} -const getByType = (type) => { +const getByType = (type: string) => { return windows[type] } -const setWindowProxy = function (win) { +const setWindowProxy = function (win: BrowserWindow) { if (!process.env.HTTP_PROXY) { return } @@ -41,7 +41,7 @@ const setWindowProxy = function (win) { }) } -export function installExtension (win: BrowserWindow, path) { +export function installExtension (win: BrowserWindow, path: string) { return win.webContents.session.loadExtension(path) .then((data) => { debug('electron extension installed %o', { data, path }) @@ -70,7 +70,7 @@ export function reset () { windows = {} } -export function destroy (type) { +export function destroy (type: string) { let win if (type && (win = getByType(type))) { @@ -78,7 +78,7 @@ export function destroy (type) { } } -export function get (type) { +export function get (type: string) { return getByType(type) || (() => { throw new Error(`No window exists for: '${type}'`) })() @@ -143,7 +143,7 @@ export function defaults (options = {}) { }) } -export function create (projectRoot, _options: WindowOptions = {}, newBrowserWindow = _newBrowserWindow) { +export function create (projectRoot, _options: WindowOptions, newBrowserWindow = _newBrowserWindow) { const options = defaults(_options) if (options.show === false) { @@ -213,15 +213,15 @@ export function create (projectRoot, _options: WindowOptions = {}, newBrowserWin } // open launchpad BrowserWindow -export function open (projectRoot, launchpadPort: number, options: WindowOptions = {}, newBrowserWindow = _newBrowserWindow): Bluebird { +export async function open (projectRoot: string, options: WindowOptions & { url: string }, newBrowserWindow = _newBrowserWindow): Promise { // if we already have a window open based // on that type then just show + focus it! - let win = getByType(options.type) + const knownWin = options.type && getByType(options.type) - if (win) { - win.show() + if (knownWin) { + knownWin.show() - return Bluebird.resolve(win) + return Bluebird.resolve(knownWin) } recentlyCreatedWindow = true @@ -235,11 +235,7 @@ export function open (projectRoot, launchpadPort: number, options: WindowOptions }, }) - if (!options.url) { - options.url = getUrl(options.type, launchpadPort) - } - - win = create(projectRoot, options, newBrowserWindow) + const win = create(projectRoot, options, newBrowserWindow) debug('creating electron window with options %o', options) @@ -251,21 +247,15 @@ export function open (projectRoot, launchpadPort: number, options: WindowOptions }) } - // enable our url to be a promise - // and wait for this to be resolved - return Bluebird.join( - options.url, - setWindowProxy(win), - ) - .spread((url) => { - // navigate the window here! - win.loadURL(url) - - recentlyCreatedWindow = false - }).thenReturn(win) + await setWindowProxy(win) + await win.loadURL(options.url) + + recentlyCreatedWindow = false + + return win } -export function trackState (projectRoot, isTextTerminal, win, keys) { +export function trackState (projectRoot, isTextTerminal, win, keys: TrackStateMap) { const isDestroyed = () => { return win.isDestroyed() } diff --git a/packages/server/lib/modes/interactive.ts b/packages/server/lib/modes/interactive.ts index 5796f2023904..72e0a8c2b902 100644 --- a/packages/server/lib/modes/interactive.ts +++ b/packages/server/lib/modes/interactive.ts @@ -11,9 +11,10 @@ import { globalPubSub, getCtx, clearCtx } from '@packages/data-context' // eslint-disable-next-line no-duplicate-imports import type { WebContents } from 'electron' -import type { LaunchArgs } from '@packages/types' +import type { LaunchArgs, Preferences } from '@packages/types' import debugLib from 'debug' +import { getPathToDesktopIndex } from '@packages/resolve-dist' const debug = debugLib('cypress:server:interactive') @@ -26,7 +27,7 @@ export = { return os.platform() === 'darwin' }, - getWindowArgs (state) { + getWindowArgs (url: string, state: Preferences) { // Electron Window's arguments // These options are passed to Electron's BrowserWindow const minWidth = Math.round(/* 13" MacBook Air */ 1792 / 3) // Thirds @@ -46,6 +47,7 @@ export = { } const common = { + url, // The backgroundColor should match the value we will show in the // launchpad frontend. @@ -129,16 +131,10 @@ export = { return args[os.platform()] }, - /** - * @param {import('@packages/types').LaunchArgs} options - * @returns - */ - ready (options: {projectRoot?: string} = {}, port: number) { + async ready (options: LaunchArgs, launchpadPort: number) { const { projectRoot } = options const ctx = getCtx() - // TODO: potentially just pass an event emitter - // instance here instead of callback functions menu.set({ withInternalDevTools: isDev(), onLogOutClicked () { @@ -149,15 +145,14 @@ export = { }, }) - return savedState.create(projectRoot, false).then((state) => state.get()) - .then((state) => { - return Windows.open(projectRoot, port, this.getWindowArgs(state)) - .then((win) => { - ctx?.actions.electron.setBrowserWindow(win) + const State = await savedState.create(projectRoot, false) + const state = await State.get() + const url = getPathToDesktopIndex(launchpadPort) + const win = await Windows.open(projectRoot, this.getWindowArgs(url, state)) - return win - }) - }) + ctx?.actions.electron.setBrowserWindow(win) + + return win }, async run (options: LaunchArgs, _loading: Promise) { diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 0343e4427938..7f7cab291212 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -1,6 +1,5 @@ /* eslint-disable no-console, @cypress/dev/arrow-body-multiline-braces */ import _ from 'lodash' -import la from 'lazy-ass' import pkg from '@packages/root' import path from 'path' import chalk from 'chalk' @@ -22,7 +21,7 @@ import random from '../util/random' import system from '../util/system' import chromePolicyCheck from '../util/chrome_policy_check' import * as objUtils from '../util/obj_utils' -import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser } from '@packages/types' +import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser, WriteVideoFrame } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import * as printResults from '../util/print-run' @@ -41,7 +40,7 @@ let exitEarly = (err) => { earlyExitErr = err } let earlyExitErr: Error -let currentWriteVideoFrameCallback: videoCapture.WriteVideoFrame +let currentWriteVideoFrameCallback: WriteVideoFrame let currentSetScreenshotMetadata: SetScreenshotMetadata const debug = Debug('cypress:server:run') @@ -121,70 +120,6 @@ async function getProjectId (project, id) { } } -const getDefaultBrowserOptsByFamily = (browser, project, writeVideoFrame, onError) => { - la(browserUtils.isBrowserFamily(browser.family), 'invalid browser family in', browser) - - if (browser.name === 'electron') { - return getElectronProps(browser.isHeaded, writeVideoFrame, onError) - } - - if (browser.family === 'chromium') { - return getCdpVideoProp(writeVideoFrame) - } - - if (browser.family === 'firefox') { - return getFirefoxProps(project, writeVideoFrame) - } - - return {} -} - -const getFirefoxProps = (project, writeVideoFrame) => { - if (writeVideoFrame) { - project.on('capture:video:frames', writeVideoFrame) - - return { onScreencastFrame: true } - } - - return {} -} - -const getCdpVideoProp = (writeVideoFrame) => { - if (!writeVideoFrame) { - return {} - } - - return { - onScreencastFrame: (e) => { - // https://chromedevtools.github.io/devtools-protocol/tot/Page#event-screencastFrame - writeVideoFrame(Buffer.from(e.data, 'base64')) - }, - } -} - -const getElectronProps = (isHeaded, writeVideoFrame, onError) => { - return { - ...getCdpVideoProp(writeVideoFrame), - width: 1280, - height: 720, - show: isHeaded, - onCrashed () { - const err = errors.get('RENDERER_CRASHED') - - errors.log(err) - - onError(err) - }, - 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 - }, - } -} - const sumByProp = (runs, prop) => { return _.sumBy(runs, prop) || 0 } @@ -380,15 +315,20 @@ async function postProcessRecording (name, cname, videoCompression, shouldUpload return continueProcessing(onProgress) } -function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, writeVideoFrame?: videoCapture.WriteVideoFrame, setScreenshotMetadata: SetScreenshotMetadata, project: Project, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void }) { - const { browser, spec, writeVideoFrame, setScreenshotMetadata, project, screenshots, projectRoot, shouldLaunchNewTab, onError } = options +function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, writeVideoFrame?: WriteVideoFrame, setScreenshotMetadata: SetScreenshotMetadata, project: Project, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void }) { + const { browser, spec, setScreenshotMetadata, project, screenshots, projectRoot, shouldLaunchNewTab, onError } = options const warnings = {} + if (options.writeVideoFrame && browser.family === 'firefox') { + project.on('capture:video:frames', options.writeVideoFrame) + } + const browserOpts: OpenProjectLaunchOpts = { - ...getDefaultBrowserOptsByFamily(browser, project, writeVideoFrame, onError), projectRoot, shouldLaunchNewTab, + onError, + writeVideoFrame: options.writeVideoFrame, automationMiddleware: { onBeforeRequest (message, data) { if (message === 'take:screenshot') { @@ -491,7 +431,7 @@ function writeVideoFrameCallback (data: Buffer) { return currentWriteVideoFrameCallback(data) } -function waitForBrowserToConnect (options: { project: Project, socketId: string, onError: (err: Error) => void, writeVideoFrame?: videoCapture.WriteVideoFrame, spec: SpecWithRelativeRoot, isFirstSpec: boolean, testingType: string, experimentalSingleTabRunMode: boolean, browser: Browser, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, webSecurity: boolean }) { +function waitForBrowserToConnect (options: { project: Project, socketId: string, onError: (err: Error) => void, writeVideoFrame?: WriteVideoFrame, spec: SpecWithRelativeRoot, isFirstSpec: boolean, testingType: string, experimentalSingleTabRunMode: boolean, browser: Browser, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, webSecurity: boolean }) { if (globalThis.CY_TEST_MOCK?.waitForBrowserToConnect) return Promise.resolve() const { project, socketId, onError, writeVideoFrame, spec } = options diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 632e0dc14c63..9a015af115df 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -84,7 +84,7 @@ export class OpenProject { proxyServer: cfg.proxyServer, socketIoRoute: cfg.socketIoRoute, chromeWebSecurity: cfg.chromeWebSecurity, - isTextTerminal: cfg.isTextTerminal, + isTextTerminal: !!cfg.isTextTerminal, downloadsFolder: cfg.downloadsFolder, experimentalSessionAndOrigin: cfg.experimentalSessionAndOrigin, experimentalModifyObstructiveThirdPartyCode: cfg.experimentalModifyObstructiveThirdPartyCode, diff --git a/packages/server/lib/saved_state.ts b/packages/server/lib/saved_state.ts index 3ee675e2c46b..47fbaa7ff41e 100644 --- a/packages/server/lib/saved_state.ts +++ b/packages/server/lib/saved_state.ts @@ -13,8 +13,6 @@ const debug = Debug('cypress:server:saved_state') const stateFiles: Record = {} -// TODO: remove `showedOnBoardingModal` from this list - it is only included so that misleading `allowed` are not thrown -// now that it has been removed from use export const formStatePath = (projectRoot?: string) => { return Bluebird.try(() => { debug('making saved state from %s', cwd()) diff --git a/packages/server/lib/video_capture.ts b/packages/server/lib/video_capture.ts index 9d25331978d4..0bdad7cfbe0a 100644 --- a/packages/server/lib/video_capture.ts +++ b/packages/server/lib/video_capture.ts @@ -7,8 +7,7 @@ import Bluebird from 'bluebird' import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg' import BlackHoleStream from 'black-hole-stream' import { fs } from './util/fs' - -export type WriteVideoFrame = (data: Buffer) => void +import type { WriteVideoFrame } from '@packages/types' const debug = Debug('cypress:server:video') const debugVerbose = Debug('cypress-verbose:server:video') diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index caa4fa28d6dd..55b957073da4 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.js @@ -510,7 +510,9 @@ describe('lib/cypress', () => { .then(() => { expect(browsers.open).to.be.calledWithMatch(ELECTRON_BROWSER, { proxyServer: 'http://localhost:8888', - show: true, + browser: { + isHeadless: false, + }, }) this.expectExitWith(0) @@ -1022,7 +1024,7 @@ describe('lib/cypress', () => { browser: 'electron', foo: 'bar', onNewWindow: sinon.match.func, - onScreencastFrame: sinon.match.func, + writeVideoFrame: sinon.match.func, }) this.expectExitWith(0) diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 75352873edde..7f3faafb3259 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -325,14 +325,14 @@ describe('lib/browsers/chrome', () => { // https://github.com/cypress-io/cypress/issues/9265 it('respond ACK after receiving new screenshot frame', function () { - const frameMeta = { data: Buffer.from(''), sessionId: '1' } + const frameMeta = { data: Buffer.from('foo'), sessionId: '1' } const write = sinon.stub() - const options = { onScreencastFrame: write } + const options = { writeVideoFrame: write } return this.onCriEvent('Page.screencastFrame', frameMeta, options) .then(() => { expect(this.pageCriClient.send).to.have.been.calledWith('Page.startScreencast') - expect(write).to.have.been.calledWith(frameMeta) + expect(write).to.have.been.calledWithMatch((arg) => Buffer.isBuffer(arg) && arg.length > 0) expect(this.pageCriClient.send).to.have.been.calledWith('Page.screencastFrameAck', { sessionId: frameMeta.sessionId }) }) }) @@ -516,12 +516,18 @@ describe('lib/browsers/chrome', () => { } let onInitializeNewBrowserTabCalled = false - const options = { ...openOpts, url: 'https://www.google.com', downloadsFolder: '/tmp/folder', onInitializeNewBrowserTab: () => { - onInitializeNewBrowserTabCalled = true - } } + const options = { + ...openOpts, + url: 'https://www.google.com', + downloadsFolder: '/tmp/folder', + writeVideoFrame: () => {}, + onInitializeNewBrowserTab: () => { + onInitializeNewBrowserTabCalled = true + }, + } sinon.stub(chrome, '_getBrowserCriClient').returns(browserCriClient) - sinon.stub(chrome, '_maybeRecordVideo').withArgs(pageCriClient, options, 354).resolves() + sinon.stub(chrome, '_recordVideo').withArgs(sinon.match.object, options.writeVideoFrame, 354).resolves() sinon.stub(chrome, '_navigateUsingCRI').withArgs(pageCriClient, options.url, 354).resolves() sinon.stub(chrome, '_handleDownloads').withArgs(pageCriClient, options.downloadFolder, automation).resolves() @@ -529,7 +535,7 @@ describe('lib/browsers/chrome', () => { expect(automation.use).to.be.called expect(chrome._getBrowserCriClient).to.be.called - expect(chrome._maybeRecordVideo).to.be.called + expect(chrome._recordVideo).to.be.called expect(chrome._navigateUsingCRI).to.be.called expect(chrome._handleDownloads).to.be.called expect(onInitializeNewBrowserTabCalled).to.be.true diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index 96f152d61f62..194bd1fecd3e 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -690,15 +690,16 @@ describe('lib/browsers/electron', () => { }) it('.onFocus', function () { - let opts = electron._defaultOptions('/foo', this.state, { show: true, browser: {} }) + const headlessOpts = electron._defaultOptions('/foo', this.state, { browser: { isHeadless: false } }) - opts.onFocus() + headlessOpts.onFocus() expect(menu.set).to.be.calledWith({ withInternalDevTools: true }) menu.set.reset() - opts = electron._defaultOptions('/foo', this.state, { show: false, browser: {} }) - opts.onFocus() + const headedOpts = electron._defaultOptions('/foo', this.state, { browser: { isHeadless: true } }) + + headedOpts.onFocus() expect(menu.set).not.to.be.called }) diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index fae60c3fae53..ce1540bb09b7 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -2,23 +2,27 @@ import type { FoundBrowser } from './browser' import type { ReceivedCypressOptions } from './config' import type { PlatformName } from './platform' +export type WriteVideoFrame = (data: Buffer) => void + export type OpenProjectLaunchOpts = { projectRoot: string shouldLaunchNewTab: boolean automationMiddleware: AutomationMiddleware + writeVideoFrame?: WriteVideoFrame onWarning: (err: Error) => void + onError: (err: Error) => void } export type BrowserLaunchOpts = { browsers: FoundBrowser[] - browser: FoundBrowser + browser: FoundBrowser & { isHeadless: boolean } url: string | undefined proxyServer: string + isTextTerminal: boolean onBrowserClose?: (...args: unknown[]) => void onBrowserOpen?: (...args: unknown[]) => void - onError?: (err: Error) => void } & Partial // TODO: remove the `Partial` here by making it impossible for openProject.launch to be called w/o OpenProjectLaunchOpts -& Pick +& Pick export type BrowserNewTabOpts = { onInitializeNewBrowserTab: () => void } & BrowserLaunchOpts From 9b8cfbe187364bf2340cbe40de9e2170c04d7366 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Mon, 29 Aug 2022 09:36:54 -0400 Subject: [PATCH 14/15] unit tests --- packages/server/lib/gui/windows.ts | 4 ++- packages/server/test/unit/gui/windows_spec.ts | 11 +++----- .../test/unit/modes/interactive_spec.js | 28 +++++++++---------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/server/lib/gui/windows.ts b/packages/server/lib/gui/windows.ts index b16673762fa5..66695fb52568 100644 --- a/packages/server/lib/gui/windows.ts +++ b/packages/server/lib/gui/windows.ts @@ -21,6 +21,8 @@ export type WindowOptions = Electron.BrowserWindowConstructorOptions & { onCrashed?: () => void } +export type WindowOpenOptions = WindowOptions & { url: string } + type TrackStateMap = Record<'width' | 'height' | 'x' | 'y' | 'devTools', string> let windows = {} @@ -213,7 +215,7 @@ export function create (projectRoot, _options: WindowOptions, newBrowserWindow = } // open launchpad BrowserWindow -export async function open (projectRoot: string, options: WindowOptions & { url: string }, newBrowserWindow = _newBrowserWindow): Promise { +export async function open (projectRoot: string, options: WindowOpenOptions, newBrowserWindow = _newBrowserWindow): Promise { // if we already have a window open based // on that type then just show + focus it! const knownWin = options.type && getByType(options.type) diff --git a/packages/server/test/unit/gui/windows_spec.ts b/packages/server/test/unit/gui/windows_spec.ts index ad7dee54b92e..4f14683ce768 100644 --- a/packages/server/test/unit/gui/windows_spec.ts +++ b/packages/server/test/unit/gui/windows_spec.ts @@ -9,7 +9,6 @@ import { EventEmitter } from 'events' import { BrowserWindow } from 'electron' import * as Windows from '../../../lib/gui/windows' import * as savedState from '../../../lib/saved_state' -import { getPathToDesktopIndex } from '@packages/resolve-dist' const DEFAULT_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Cypress/0.0.0 Chrome/59.0.3071.115 Electron/1.8.2 Safari/537.36' @@ -42,23 +41,21 @@ describe('lib/gui/windows', () => { context('.open', () => { it('sets default options', function () { - const options: Windows.WindowOptions = { + const options: Windows.WindowOpenOptions = { type: 'INDEX', + url: 'foo', } - return Windows.open('/path/to/project', 1234, options, () => this.win) + return Windows.open('/path/to/project', options, () => this.win) .then((win) => { expect(options).to.include({ height: 500, width: 600, type: 'INDEX', show: true, - url: getPathToDesktopIndex(1234), }) - expect(win.loadURL).to.be.calledWith(getPathToDesktopIndex( - 1234, - )) + expect(win.loadURL).to.be.calledWith('foo') }) }) }) diff --git a/packages/server/test/unit/modes/interactive_spec.js b/packages/server/test/unit/modes/interactive_spec.js index 826e5974cc8b..9603d1155178 100644 --- a/packages/server/test/unit/modes/interactive_spec.js +++ b/packages/server/test/unit/modes/interactive_spec.js @@ -27,13 +27,13 @@ describe('gui/interactive', () => { context('.getWindowArgs', () => { it('quits app when onClose is called', () => { electron.app.quit = sinon.stub() - interactiveMode.getWindowArgs({}).onClose() + interactiveMode.getWindowArgs(1234, {}).onClose() expect(electron.app.quit).to.be.called }) it('tracks state properties', () => { - const { trackState } = interactiveMode.getWindowArgs({}) + const { trackState } = interactiveMode.getWindowArgs(1234, {}) const args = _.pick(trackState, 'width', 'height', 'x', 'y', 'devTools') @@ -51,49 +51,49 @@ describe('gui/interactive', () => { // Use the saved value if it's valid describe('when no dimension', () => { it('renders with preferred width if no width saved', () => { - expect(interactiveMode.getWindowArgs({}).width).to.equal(1200) + expect(interactiveMode.getWindowArgs(1234, {}).width).to.equal(1200) }) it('renders with preferred height if no height saved', () => { - expect(interactiveMode.getWindowArgs({}).height).to.equal(800) + expect(interactiveMode.getWindowArgs(1234, {}).height).to.equal(800) }) }) describe('when saved dimension is too small', () => { it('uses the preferred width', () => { - expect(interactiveMode.getWindowArgs({ appWidth: 1 }).width).to.equal(1200) + expect(interactiveMode.getWindowArgs(1234, { appWidth: 1 }).width).to.equal(1200) }) it('uses the preferred height', () => { - expect(interactiveMode.getWindowArgs({ appHeight: 1 }).height).to.equal(800) + expect(interactiveMode.getWindowArgs(1234, { appHeight: 1 }).height).to.equal(800) }) }) describe('when saved dimension is within min/max dimension', () => { it('uses the saved width', () => { - expect(interactiveMode.getWindowArgs({ appWidth: 1500 }).width).to.equal(1500) + expect(interactiveMode.getWindowArgs(1234, { appWidth: 1500 }).width).to.equal(1500) }) it('uses the saved height', () => { - expect(interactiveMode.getWindowArgs({ appHeight: 1500 }).height).to.equal(1500) + expect(interactiveMode.getWindowArgs(1234, { appHeight: 1500 }).height).to.equal(1500) }) }) }) it('renders with saved x if it exists', () => { - expect(interactiveMode.getWindowArgs({ appX: 3 }).x).to.equal(3) + expect(interactiveMode.getWindowArgs(1234, { appX: 3 }).x).to.equal(3) }) it('renders with no x if no x saved', () => { - expect(interactiveMode.getWindowArgs({}).x).to.be.undefined + expect(interactiveMode.getWindowArgs(1234, {}).x).to.be.undefined }) it('renders with saved y if it exists', () => { - expect(interactiveMode.getWindowArgs({ appY: 4 }).y).to.equal(4) + expect(interactiveMode.getWindowArgs(1234, { appY: 4 }).y).to.equal(4) }) it('renders with no y if no y saved', () => { - expect(interactiveMode.getWindowArgs({}).y).to.be.undefined + expect(interactiveMode.getWindowArgs(1234, {}).y).to.be.undefined }) describe('on window focus', () => { @@ -105,7 +105,7 @@ describe('gui/interactive', () => { const env = process.env['CYPRESS_INTERNAL_ENV'] process.env['CYPRESS_INTERNAL_ENV'] = 'development' - interactiveMode.getWindowArgs({}).onFocus() + interactiveMode.getWindowArgs(1234, {}).onFocus() expect(menu.set.lastCall.args[0].withInternalDevTools).to.be.true process.env['CYPRESS_INTERNAL_ENV'] = env }) @@ -114,7 +114,7 @@ describe('gui/interactive', () => { const env = process.env['CYPRESS_INTERNAL_ENV'] process.env['CYPRESS_INTERNAL_ENV'] = 'production' - interactiveMode.getWindowArgs({}).onFocus() + interactiveMode.getWindowArgs(1234, {}).onFocus() expect(menu.set.lastCall.args[0].withInternalDevTools).to.be.false process.env['CYPRESS_INTERNAL_ENV'] = env }) From 4deada77e3419f44196f513396fa030218a979bc Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 11:56:11 -0400 Subject: [PATCH 15/15] Tyler's feedback --- .../test/unit/modes/interactive_spec.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/server/test/unit/modes/interactive_spec.js b/packages/server/test/unit/modes/interactive_spec.js index 9603d1155178..2c6967e71fba 100644 --- a/packages/server/test/unit/modes/interactive_spec.js +++ b/packages/server/test/unit/modes/interactive_spec.js @@ -27,13 +27,13 @@ describe('gui/interactive', () => { context('.getWindowArgs', () => { it('quits app when onClose is called', () => { electron.app.quit = sinon.stub() - interactiveMode.getWindowArgs(1234, {}).onClose() + interactiveMode.getWindowArgs('http://app', {}).onClose() expect(electron.app.quit).to.be.called }) it('tracks state properties', () => { - const { trackState } = interactiveMode.getWindowArgs(1234, {}) + const { trackState } = interactiveMode.getWindowArgs('http://app', {}) const args = _.pick(trackState, 'width', 'height', 'x', 'y', 'devTools') @@ -51,49 +51,49 @@ describe('gui/interactive', () => { // Use the saved value if it's valid describe('when no dimension', () => { it('renders with preferred width if no width saved', () => { - expect(interactiveMode.getWindowArgs(1234, {}).width).to.equal(1200) + expect(interactiveMode.getWindowArgs('http://app', {}).width).to.equal(1200) }) it('renders with preferred height if no height saved', () => { - expect(interactiveMode.getWindowArgs(1234, {}).height).to.equal(800) + expect(interactiveMode.getWindowArgs('http://app', {}).height).to.equal(800) }) }) describe('when saved dimension is too small', () => { it('uses the preferred width', () => { - expect(interactiveMode.getWindowArgs(1234, { appWidth: 1 }).width).to.equal(1200) + expect(interactiveMode.getWindowArgs('http://app', { appWidth: 1 }).width).to.equal(1200) }) it('uses the preferred height', () => { - expect(interactiveMode.getWindowArgs(1234, { appHeight: 1 }).height).to.equal(800) + expect(interactiveMode.getWindowArgs('http://app', { appHeight: 1 }).height).to.equal(800) }) }) describe('when saved dimension is within min/max dimension', () => { it('uses the saved width', () => { - expect(interactiveMode.getWindowArgs(1234, { appWidth: 1500 }).width).to.equal(1500) + expect(interactiveMode.getWindowArgs('http://app', { appWidth: 1500 }).width).to.equal(1500) }) it('uses the saved height', () => { - expect(interactiveMode.getWindowArgs(1234, { appHeight: 1500 }).height).to.equal(1500) + expect(interactiveMode.getWindowArgs('http://app', { appHeight: 1500 }).height).to.equal(1500) }) }) }) it('renders with saved x if it exists', () => { - expect(interactiveMode.getWindowArgs(1234, { appX: 3 }).x).to.equal(3) + expect(interactiveMode.getWindowArgs('http://app', { appX: 3 }).x).to.equal(3) }) it('renders with no x if no x saved', () => { - expect(interactiveMode.getWindowArgs(1234, {}).x).to.be.undefined + expect(interactiveMode.getWindowArgs('http://app', {}).x).to.be.undefined }) it('renders with saved y if it exists', () => { - expect(interactiveMode.getWindowArgs(1234, { appY: 4 }).y).to.equal(4) + expect(interactiveMode.getWindowArgs('http://app', { appY: 4 }).y).to.equal(4) }) it('renders with no y if no y saved', () => { - expect(interactiveMode.getWindowArgs(1234, {}).y).to.be.undefined + expect(interactiveMode.getWindowArgs('http://app', {}).y).to.be.undefined }) describe('on window focus', () => { @@ -105,7 +105,7 @@ describe('gui/interactive', () => { const env = process.env['CYPRESS_INTERNAL_ENV'] process.env['CYPRESS_INTERNAL_ENV'] = 'development' - interactiveMode.getWindowArgs(1234, {}).onFocus() + interactiveMode.getWindowArgs('http://app', {}).onFocus() expect(menu.set.lastCall.args[0].withInternalDevTools).to.be.true process.env['CYPRESS_INTERNAL_ENV'] = env }) @@ -114,7 +114,7 @@ describe('gui/interactive', () => { const env = process.env['CYPRESS_INTERNAL_ENV'] process.env['CYPRESS_INTERNAL_ENV'] = 'production' - interactiveMode.getWindowArgs(1234, {}).onFocus() + interactiveMode.getWindowArgs('http://app', {}).onFocus() expect(menu.set.lastCall.args[0].withInternalDevTools).to.be.false process.env['CYPRESS_INTERNAL_ENV'] = env })