From 297e41b125ef86c6e12d4345d73e834ba3c7cbac Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Thu, 25 Aug 2022 11:21:20 -0400 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 e3b197998b34efc110a6ca087ada8beb0a0838a5 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Mon, 29 Aug 2022 21:00:23 -0400 Subject: [PATCH 15/29] refactor: move videoCapture to browsers --- packages/server/lib/browsers/chrome.ts | 31 ++- packages/server/lib/browsers/electron.ts | 31 ++- packages/server/lib/browsers/firefox.ts | 16 +- packages/server/lib/modes/run.ts | 270 +++++++++---------- packages/server/lib/open_project.ts | 9 +- packages/server/lib/util/process_profiler.ts | 2 +- packages/server/lib/video_capture.ts | 12 +- packages/types/src/server.ts | 20 ++ 8 files changed, 218 insertions(+), 173 deletions(-) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 2a70a0b94105..5ca661cce792 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -12,15 +12,15 @@ import type { Protocol } from 'devtools-protocol' import appData from '../util/app_data' import { fs } from '../util/fs' +import * as videoCapture from '../video_capture' import { CdpAutomation, screencastOpts } from './cdp_automation' import * as protocol from './protocol' import utils from './utils' -import type { Browser } from './types' +import type { Browser, BrowserInstance } from './types' 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, WriteVideoFrame } from '@packages/types' +import type { BrowserLaunchOpts, BrowserNewTabOpts, VideoBrowserOpt } from '@packages/types' const debug = debugModule('cypress:server:browsers:chrome') @@ -249,10 +249,14 @@ const _disableRestorePagesPrompt = function (userDir) { .catch(() => { }) } -async function _recordVideo (cdpAutomation: CdpAutomation, writeVideoFrame: WriteVideoFrame, browserMajorVersion: number) { - const opts = browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1) +async function _recordVideo (cdpAutomation: CdpAutomation, videoOptions: VideoBrowserOpt, browserMajorVersion: number) { + const screencastOptions = browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1) - await cdpAutomation.startVideoRecording(writeVideoFrame, opts) + const videoController = await videoCapture.start(videoOptions) + + videoOptions.setVideoController(videoController) + + await cdpAutomation.startVideoRecording(videoController.writeVideoFrame, screencastOptions) } // a utility function that navigates to the given URL @@ -552,7 +556,7 @@ export = { if (!options.url) throw new Error('Missing url in connectToNewSpec') - await this.attachListeners(browser, options.url, pageCriClient, automation, options) + await this.attachListeners(options.url, pageCriClient, automation, options) }, async connectToExisting (browser: Browser, options: BrowserLaunchOpts, automation: Automation) { @@ -570,7 +574,7 @@ export = { await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) }, - async attachListeners (browser: Browser, url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) { + async attachListeners (url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) { if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') const cdpAutomation = await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) @@ -580,7 +584,7 @@ export = { await options['onInitializeNewBrowserTab']?.() await Promise.all([ - options.writeVideoFrame && this._recordVideo(cdpAutomation, options.writeVideoFrame, browser.majorVersion), + options.video && _recordVideo(cdpAutomation, options.video, Number(options.browser.majorVersion)), this._handleDownloads(pageCriClient, options.downloadsFolder, automation), ]) @@ -590,9 +594,11 @@ export = { await this._handlePausedRequests(pageCriClient) _listenForFrameTreeChanges(pageCriClient) } + + return cdpAutomation }, - async open (browser: Browser, url, options: BrowserLaunchOpts, automation: Automation): Promise { + async open (browser: Browser, url, options: BrowserLaunchOpts, automation: Automation): Promise { const { isTextTerminal } = options const userDir = utils.getProfileDir(browser, isTextTerminal) @@ -644,7 +650,7 @@ export = { // first allows us to connect the remote interface, // start video recording and then // we will load the actual page - const launchedBrowser = await launch(browser, 'about:blank', port, args) as LaunchedBrowser & { browserCriClient: BrowserCriClient} + const launchedBrowser = await launch(browser, 'about:blank', port, args) as unknown as BrowserInstance & { browserCriClient: BrowserCriClient} la(launchedBrowser, 'did not get launched browser instance') @@ -671,7 +677,6 @@ export = { launchedBrowser.browserCriClient = browserCriClient - /* @ts-expect-error */ launchedBrowser.kill = (...args) => { debug('closing remote interface client') @@ -686,7 +691,7 @@ export = { const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') - await this.attachListeners(browser, url, pageCriClient, automation, options) + await this.attachListeners(url, pageCriClient, automation, options) // return the launched browser process // with additional method to close the remote connection diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 7f3eba817379..028ae3d5543e 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -6,12 +6,13 @@ import menu from '../gui/menu' import * as Windows from '../gui/windows' import { CdpAutomation, screencastOpts, CdpCommand, CdpEvent } from './cdp_automation' import * as savedState from '../saved_state' +import * as videoCapture from '../video_capture' import utils from './utils' import * as errors from '../errors' import type { Browser, BrowserInstance } from './types' import type { BrowserWindow, WebContents } from 'electron' import type { Automation } from '../automation' -import type { BrowserLaunchOpts, Preferences } from '@packages/types' +import type { BrowserLaunchOpts, Preferences, VideoBrowserOpt } from '@packages/types' // TODO: unmix these two types type ElectronOpts = Windows.WindowOptions & BrowserLaunchOpts @@ -72,7 +73,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.writeVideoFrame) { + if (!options.video) { await sendCommand('Page.startScreencast', screencastOpts()) const ret = await fn(message, data) @@ -109,6 +110,14 @@ function _installExtensions (win: BrowserWindow, extensionPaths: string[], optio })) } +async function recordVideo (cdpAutomation: CdpAutomation, videoOptions: VideoBrowserOpt) { + const videoController = await videoCapture.start(videoOptions) + + videoOptions.setVideoController(videoController) + + await cdpAutomation.startVideoRecording(videoController.writeVideoFrame) +} + export = { _defaultOptions (projectRoot: string | undefined, state: Preferences, options: BrowserLaunchOpts, automation: Automation): ElectronOpts { const _this = this @@ -179,7 +188,7 @@ export = { _getAutomation, - async _render (url: string, automation: Automation, preferences, options: { projectRoot?: string, isTextTerminal: boolean }) { + async _render (url: string, automation: Automation, preferences, options: ElectronOpts) { const win = Windows.create(options.projectRoot, preferences) if (preferences.browser.isHeadless) { @@ -192,7 +201,7 @@ export = { win.maximize() } - const launched = await this._launch(win, url, automation, preferences) + const launched = await this._launch(win, url, automation, preferences, options.video) automation.use(await _getAutomation(win, preferences, automation)) @@ -225,7 +234,7 @@ export = { return this._launch(win, url, automation, electronOptions) }, - async _launch (win: BrowserWindow, url: string, automation: Automation, options) { + async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoOptions?: VideoBrowserOpt) { if (options.show) { menu.set({ withInternalDevTools: true }) } @@ -239,9 +248,7 @@ export = { this._attachDebugger(win.webContents) - let ua - - ua = options.userAgent + const ua = options.userAgent if (ua) { this._setUserAgent(win.webContents, ua) @@ -273,8 +280,9 @@ export = { const cdpAutomation = await this._getAutomation(win, options, automation) automation.use(cdpAutomation) + await Promise.all([ - options.writeVideoFrame && cdpAutomation.startVideoRecording(options.writeVideoFrame), + videoOptions && recordVideo(cdpAutomation, videoOptions), this._handleDownloads(win, options.downloadsFolder, automation), ]) @@ -480,10 +488,7 @@ export = { debug('launching browser window to url: %s', url) - const win = await this._render(url, automation, preferences, { - projectRoot: options.projectRoot, - isTextTerminal: options.isTextTerminal, - }) + const win = await this._render(url, automation, preferences, electronOptions) await _installExtensions(win, launchOptions.extensions, electronOptions) diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 4ebcd132f36e..10f95d9b90a8 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -4,7 +4,7 @@ import Debug from 'debug' import getPort from 'get-port' import path from 'path' import urlUtil from 'url' -import { debug as launcherDebug, launch, LaunchedBrowser } from '@packages/launcher/lib/browsers' +import { debug as launcherDebug, launch } from '@packages/launcher/lib/browsers' import { doubleEscape } from '@packages/launcher/lib/windows' import FirefoxProfile from 'firefox-profile' import * as errors from '../errors' @@ -18,9 +18,10 @@ import mimeDb from 'mime-db' import { getRemoteDebuggingPort } from './protocol' import type { BrowserCriClient } from './browser-cri-client' import type { Automation } from '../automation' +import * as videoCapture from '../video_capture' import { getCtx } from '@packages/data-context' import { getError } from '@packages/errors' -import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' +import type { BrowserLaunchOpts, BrowserNewTabOpts, VideoBrowserOpt } from '@packages/types' const debug = Debug('cypress:server:browsers:firefox') @@ -379,6 +380,13 @@ 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'))) } +async function recordVideo (videoOptions: VideoBrowserOpt) { + const videoController = await videoCapture.start({ ...videoOptions, webmInput: true }) + + videoOptions.onProjectCaptureVideoFrames(videoController.writeVideoFrame) + videoOptions.setVideoController(videoController) +} + 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 @@ -456,6 +464,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc utils.ensureCleanCache(browser, options.isTextTerminal), utils.writeExtension(browser, options.isTextTerminal, options.proxyUrl, options.socketIoRoute), utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options), + options.video && recordVideo(options.video), ]) if (Array.isArray(launchOptions.extensions)) { @@ -529,7 +538,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc // user can overwrite this default with these env vars or --height, --width arguments MOZ_HEADLESS_WIDTH: '1280', MOZ_HEADLESS_HEIGHT: '721', - }) as LaunchedBrowser & { browserCriClient: BrowserCriClient} + }) as unknown as BrowserInstance try { browserCriClient = await firefoxUtil.setup({ automation, extensions: launchOptions.extensions, url, foxdriverPort, marionettePort, remotePort, onError: options.onError, options }) @@ -543,7 +552,6 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc // monkey-patch the .kill method to that the CDP connection is closed const originalBrowserKill = browserInstance.kill - /* @ts-expect-error */ browserInstance.kill = (...args) => { debug('closing remote interface client') diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 7f7cab291212..7070f6fef8cb 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -21,10 +21,11 @@ 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, WriteVideoFrame } from '@packages/types' +import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser, WriteVideoFrame, VideoController, VideoBrowserOpt } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import * as printResults from '../util/print-run' +import pDefer from 'p-defer' type SetScreenshotMetadata = (data: TakeScreenshotProps) => void type ScreenshotMetadata = ReturnType @@ -223,13 +224,29 @@ async function trashAssets (config: Cfg) { } } -async function createVideoRecording (videoName, options = {}) { +type VideoRecording = { + info: VideoBrowserOpt + promise: Promise +} + +async function startVideoRecording (options: { project: Project, spec: SpecWithRelativeRoot, videosFolder: string }): Promise { + if (!options.videosFolder) throw new Error('Missing videoFolder for recording') + + function videoPath (suffix: string) { + return path.join(options.videosFolder, options.spec.relativeToCommonRoot + suffix) + } + + const videoName = videoPath('.mp4') + const compressedVideoName = videoPath('-compressed.mp4') + const outputDir = path.dirname(videoName) const onError = _.once((err) => { // catch video recording failures and log them out // but don't let this affect the run at all - return errors.warning('VIDEO_RECORDING_FAILED', err) + errors.warning('VIDEO_RECORDING_FAILED', err) + + return undefined }) try { @@ -238,44 +255,21 @@ async function createVideoRecording (videoName, options = {}) { onError(err) } - return videoCapture.start(videoName, _.extend({}, options, { onError })) -} - -const getVideoRecordingDelay = function (startedVideoCapture) { - if (startedVideoCapture) { - return DELAY_TO_LET_VIDEO_FINISH_MS - } - - return 0 -} - -async function maybeStartVideoRecording (options: { spec: SpecWithRelativeRoot, browser: Browser, video: boolean, videosFolder: string }) { - const { spec, browser, video, videosFolder } = options - - debug(`video recording has been ${video ? 'enabled' : 'disabled'}. video: %s`, video) - - if (!video) { - return - } - - if (!videosFolder) { - throw new Error('Missing videoFolder for recording') - } - - const videoPath = (suffix) => { - return path.join(videosFolder, spec.relativeToCommonRoot + suffix) - } - - const videoName = videoPath('.mp4') - const compressedVideoName = videoPath('-compressed.mp4') - const props = await createVideoRecording(videoName, { webmInput: browser.family === 'firefox' }) + const videoPromise = pDefer() return { - videoName, - compressedVideoName, - endVideoCapture: props.endVideoCapture, - writeVideoFrame: props.writeVideoFrame, - startedVideoCapture: props.startedVideoCapture, + info: { + onError, + videoName, + compressedVideoName, + setVideoController (videoController) { + videoPromise.resolve(videoController) + }, + onProjectCaptureVideoFrames (fn) { + options.project.on('capture:video:frames', fn) + }, + }, + promise: videoPromise.promise.catch(onError), } } @@ -315,7 +309,7 @@ async function postProcessRecording (name, cname, videoCompression, shouldUpload return continueProcessing(onProgress) } -function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, writeVideoFrame?: WriteVideoFrame, setScreenshotMetadata: SetScreenshotMetadata, project: Project, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void }) { +function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, writeVideoFrame?: WriteVideoFrame, setScreenshotMetadata: SetScreenshotMetadata, project: Project, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void, videoRecording?: VideoRecording }) { const { browser, spec, setScreenshotMetadata, project, screenshots, projectRoot, shouldLaunchNewTab, onError } = options const warnings = {} @@ -328,7 +322,7 @@ function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, projectRoot, shouldLaunchNewTab, onError, - writeVideoFrame: options.writeVideoFrame, + video: options.videoRecording?.info, automationMiddleware: { onBeforeRequest (message, data) { if (message === 'take:screenshot') { @@ -431,7 +425,7 @@ function writeVideoFrameCallback (data: Buffer) { return currentWriteVideoFrameCallback(data) } -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 }) { +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, videoRecording?: VideoRecording }) { if (globalThis.CY_TEST_MOCK?.waitForBrowserToConnect) return Promise.resolve() const { project, socketId, onError, writeVideoFrame, spec } = options @@ -470,7 +464,7 @@ function waitForBrowserToConnect (options: { project: Project, socketId: string, }) } - const wait = () => { + const wait = async () => { debug('waiting for socket to connect and browser to launch...') return Bluebird.all([ @@ -533,123 +527,131 @@ function waitForSocketConnection (project, id) { }) } -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}) { +async function waitForTestsToFinishRunning (options: { project: Project, screenshots: ScreenshotMetadata[], videoCompression: number | false, videoUploadOnPasses: boolean, exit: boolean, spec: SpecWithRelativeRoot, estimated: number, quiet: boolean, config: Cfg, shouldKeepTabOpen: boolean, testingType: TestingType, videoRecording?: VideoRecording }) { 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 + const { project, screenshots, videoRecording, videoCompression, videoUploadOnPasses, exit, spec, estimated, quiet, config, shouldKeepTabOpen, testingType } = options + + const results = await listenForProjectEnd(project, exit) // https://github.com/cypress-io/cypress/issues/2370 // delay 1 second if we're recording a video to give // the browser padding to render the final frames // to avoid chopping off the end of the video - const delay = getVideoRecordingDelay(startedVideoCapture) - - return listenForProjectEnd(project, exit) - .delay(delay) - .then(async (results) => { - _.defaults(results, { - error: null, - hooks: null, - tests: null, - video: null, - screenshots: null, - reporterStats: null, - }) + const videoController = await videoRecording?.promise - // dashboard told us to skip this spec - const skippedSpec = results.skippedSpec + if (videoController) { + await Bluebird.delay(DELAY_TO_LET_VIDEO_FINISH_MS) + } - if (startedVideoCapture) { - results.video = videoName - } + _.defaults(results, { + error: null, + hooks: null, + tests: null, + video: null, + screenshots: null, + reporterStats: null, + }) - if (screenshots) { - results.screenshots = screenshots - } + // dashboard told us to skip this spec + const skippedSpec = results.skippedSpec + + if (screenshots) { + results.screenshots = screenshots + } + + results.spec = spec + + const { tests, stats } = results + const attempts = _.flatMap(tests, (test) => test.attempts) - results.spec = spec + let videoCaptureFailed = false - const { tests, stats } = results - const attempts = _.flatMap(tests, (test) => test.attempts) + // if we have a video recording + if (videoController) { + results.video = videoRecording!.info.videoName - // if we have a video recording - if (startedVideoCapture && tests && tests.length) { + if (tests && tests.length) { // always set the video timestamp on tests - Reporter.setVideoTimestamp(startedVideoCapture, attempts) + Reporter.setVideoTimestamp(videoController.startedVideoCapture, attempts) } - let videoCaptureFailed = false - - if (endVideoCapture) { - try { - await endVideoCapture() - } catch (err) { - videoCaptureFailed = true - warnVideoRecordingFailed(err) - } + // TODO: should always have endvideocapture? + // if (endVideoCapture) { + try { + await videoController.endVideoCapture() + } catch (err) { + videoCaptureFailed = true + // TODO; could this warn twice...? + warnVideoRecordingFailed(err) } + // } + } - await runEvents.execute('after:spec', config, spec, results) + await runEvents.execute('after:spec', config, spec, results) - const videoExists = videoName ? await fs.pathExists(videoName) : false + const videoName = videoRecording?.info.videoName + const videoExists = videoName && await fs.pathExists(videoName) - if (startedVideoCapture && !videoExists) { - // the video file no longer exists at the path where we expect it, - // likely because the user deleted it in the after:spec event - debug(`No video found after spec ran - skipping processing. Video path: ${videoName}`) + if (!videoExists) { + // the video file no longer exists at the path where we expect it, + // possibly because the user deleted it in the after:spec event + debug(`No video found after spec ran - skipping processing. Video path: ${videoName}`) - results.video = null - } + results.video = null + } - const hasFailingTests = _.get(stats, 'failures') > 0 - // we should upload the video if we upload on passes (by default) - // or if we have any failures and have started the video - const shouldUploadVideo = !skippedSpec && videoUploadOnPasses === true || Boolean((startedVideoCapture && hasFailingTests)) + const hasFailingTests = _.get(stats, 'failures') > 0 + // we should upload the video if we upload on passes (by default) + // or if we have any failures and have started the video + const shouldUploadVideo = !skippedSpec && videoUploadOnPasses === true || Boolean((/* startedVideoCapture */ videoExists && hasFailingTests)) - results.shouldUploadVideo = shouldUploadVideo + results.shouldUploadVideo = shouldUploadVideo - if (!quiet && !skippedSpec) { - printResults.displayResults(results, estimated) - } + if (!quiet && !skippedSpec) { + printResults.displayResults(results, estimated) + } - const project = openProject.getProject() + // TODO: not needed right? + // const project = openProject.getProject() - if (!project) throw new Error('Missing project!') + if (!project) throw new Error('Missing project!') - // @ts-expect-error experimentalSingleTabRunMode only exists on the CT-specific config type - const usingExperimentalSingleTabMode = testingType === 'component' && config.experimentalSingleTabRunMode + // @ts-expect-error experimentalSingleTabRunMode only exists on the CT-specific config type + const usingExperimentalSingleTabMode = testingType === 'component' && config.experimentalSingleTabRunMode - if (usingExperimentalSingleTabMode) { - await project.server.destroyAut() - } + if (usingExperimentalSingleTabMode) { + await project.server.destroyAut() + } - // we do not support experimentalSingleTabRunMode for e2e - if (!usingExperimentalSingleTabMode) { - debug('attempting to close the browser tab') + // we do not support experimentalSingleTabRunMode for e2e + if (!usingExperimentalSingleTabMode) { + debug('attempting to close the browser tab') - await openProject.resetBrowserTabsForNextTest(shouldKeepTabOpen) + await openProject.resetBrowserTabsForNextTest(shouldKeepTabOpen) - debug('resetting server state') + debug('resetting server state') - project.server.reset() - } + project.server.reset() + } - if (videoExists && !skippedSpec && endVideoCapture && !videoCaptureFailed) { - const ffmpegChaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests) - - await postProcessRecording( - videoName, - compressedVideoName, - videoCompression, - shouldUploadVideo, - quiet, - ffmpegChaptersConfig, - ) - .catch(warnVideoRecordingFailed) - } + if (videoExists && !skippedSpec && !videoCaptureFailed) { + const ffmpegChaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests) - return results - }) + await postProcessRecording( + videoName, + videoRecording.info.compressedVideoName, + videoCompression, + shouldUploadVideo, + quiet, + ffmpegChaptersConfig, + ) + .catch(warnVideoRecordingFailed) + } + + console.log('results done') + + return results } function screenshotMetadata (data, resp) { @@ -818,12 +820,11 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project: await runEvents.execute('before:spec', config, spec) - const videoRecordProps = await maybeStartVideoRecording({ + const videoRecording = options.video ? await startVideoRecording({ + project, spec, - browser, - video: options.video, videosFolder: options.videosFolder, - }) + }) : undefined // we know we're done running headlessly // when the renderer has connected and @@ -836,10 +837,7 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project: project, estimated, screenshots, - videoName: videoRecordProps?.videoName, - compressedVideoName: videoRecordProps?.compressedVideoName, - endVideoCapture: videoRecordProps?.endVideoCapture, - startedVideoCapture: videoRecordProps?.startedVideoCapture, + videoRecording, exit: options.exit, testingType: options.testingType, videoCompression: options.videoCompression, @@ -854,7 +852,7 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project: browser, screenshots, onError, - writeVideoFrame: videoRecordProps?.writeVideoFrame, + videoRecording, socketId: options.socketId, webSecurity: options.webSecurity, projectRoot: options.projectRoot, @@ -865,6 +863,8 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project: }), ]) + console.log('tests done, browser done') + return { results } } diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 9a015af115df..51d9528cf464 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -15,12 +15,15 @@ import { getSpecUrl } from './project_utils' 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' +import type { BrowserInstance } from './browsers/types' const debug = Debug('cypress:server:open_project') export class OpenProject { private projectBase: ProjectBase | null = null - relaunchBrowser: (() => Promise) | null = null + relaunchBrowser: (() => Promise) = () => { + throw new Error('bad relaunch') + } constructor () { return autoBindDebug(this) @@ -29,7 +32,9 @@ export class OpenProject { resetOpenProject () { this.projectBase?.__reset() this.projectBase = null - this.relaunchBrowser = null + this.relaunchBrowser = () => { + throw new Error('bad relaunch after reset') + } } reset () { diff --git a/packages/server/lib/util/process_profiler.ts b/packages/server/lib/util/process_profiler.ts index d228c7b30bd9..a2a01bb49084 100644 --- a/packages/server/lib/util/process_profiler.ts +++ b/packages/server/lib/util/process_profiler.ts @@ -51,7 +51,7 @@ 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 pids: number[] = instance.allPids ? instance.allPids : [instance.pid] + const pids: number[] = instance?.allPids ? instance.allPids : [instance?.pid] return (pids.includes(proc.pid)) || isParentProcessInGroup(proc, 'browser') diff --git a/packages/server/lib/video_capture.ts b/packages/server/lib/video_capture.ts index 0bdad7cfbe0a..6e5791c3670b 100644 --- a/packages/server/lib/video_capture.ts +++ b/packages/server/lib/video_capture.ts @@ -21,7 +21,7 @@ ffmpeg.setFfmpegPath(ffmpegPath) const deferredPromise = function () { let reject let resolve - const promise = new Bluebird((_resolve, _reject) => { + const promise = new Promise((_resolve, _reject) => { resolve = _resolve reject = _reject }) @@ -108,14 +108,16 @@ export function copy (src, dest) { }) } -type StartOptions = { +export type StartOptions = { + // Path to write video to. + videoName: string // If set, expect input frames as webm chunks. webmInput?: boolean // Callback for asynchronous errors in video processing/compression. onError?: (err: Error, stdout: string, stderr: string) => void } -export function start (name, options: StartOptions = {}) { +export function start (options: StartOptions) { const pt = new stream.PassThrough() const ended = deferredPromise() let done = false @@ -203,7 +205,7 @@ export function start (name, options: StartOptions = {}) { } const startCapturing = () => { - return new Bluebird((resolve) => { + return new Promise((resolve) => { const cmd = ffmpeg({ source: pt, priority: 20, @@ -256,7 +258,7 @@ export function start (name, options: StartOptions = {}) { .inputOptions('-use_wallclock_as_timestamps 1') } - return cmd.save(name) + return cmd.save(options.videoName) }) } diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index ce1540bb09b7..9f85569a6ede 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -4,11 +4,31 @@ import type { PlatformName } from './platform' export type WriteVideoFrame = (data: Buffer) => void +/** + * Interface yielded by the browser to control video recording. + */ +export type VideoController = { + endVideoCapture: () => Promise + startedVideoCapture: Date +} + +export type VideoBrowserOpt = { + onError: (err: Error) => void + videoName: string + compressedVideoName?: string + setVideoController: (videoController?: VideoController) => void + /** + * Registers a handler for project.on('capture:video:frames'). + */ + onProjectCaptureVideoFrames: (fn: (data: Buffer) => void) => void +} + export type OpenProjectLaunchOpts = { projectRoot: string shouldLaunchNewTab: boolean automationMiddleware: AutomationMiddleware writeVideoFrame?: WriteVideoFrame + video?: VideoBrowserOpt onWarning: (err: Error) => void onError: (err: Error) => void } From c3f69157dd1d66d0a1010aa9d92e76c9c25dbcbd Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Mon, 29 Aug 2022 22:25:28 -0400 Subject: [PATCH 16/29] fix snapshots --- packages/server/lib/makeDataContext.ts | 8 +++--- packages/server/lib/modes/run.ts | 35 +++++++++++--------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/server/lib/makeDataContext.ts b/packages/server/lib/makeDataContext.ts index eb2c4cb59688..093a4965b202 100644 --- a/packages/server/lib/makeDataContext.ts +++ b/packages/server/lib/makeDataContext.ts @@ -53,8 +53,8 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext { async focusActiveBrowserWindow () { return openProject.sendFocusBrowserMessage() }, - relaunchBrowser () { - return openProject.relaunchBrowser ? openProject.relaunchBrowser() : null + async relaunchBrowser () { + await openProject.relaunchBrowser() }, }, appApi: { @@ -75,8 +75,8 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext { }, }, projectApi: { - launchProject (browser: FoundBrowser, spec: Cypress.Spec, options: OpenProjectLaunchOpts) { - return openProject.launch({ ...browser }, spec, options) + async launchProject (browser: FoundBrowser, spec: Cypress.Spec, options: OpenProjectLaunchOpts) { + await openProject.launch({ ...browser }, spec, options) }, openProjectCreate (args: InitializeProjectOptions, options: OpenProjectLaunchOptions) { return openProject.create(args.projectRoot, args, options) diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 7070f6fef8cb..655b508b936f 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -309,15 +309,11 @@ async function postProcessRecording (name, cname, videoCompression, shouldUpload return continueProcessing(onProgress) } -function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, writeVideoFrame?: WriteVideoFrame, setScreenshotMetadata: SetScreenshotMetadata, project: Project, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void, videoRecording?: VideoRecording }) { - const { browser, spec, setScreenshotMetadata, project, screenshots, projectRoot, shouldLaunchNewTab, onError } = options +function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, writeVideoFrame?: WriteVideoFrame, setScreenshotMetadata: SetScreenshotMetadata, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void, videoRecording?: VideoRecording }) { + const { browser, spec, setScreenshotMetadata, screenshots, projectRoot, shouldLaunchNewTab, onError } = options const warnings = {} - if (options.writeVideoFrame && browser.family === 'firefox') { - project.on('capture:video:frames', options.writeVideoFrame) - } - const browserOpts: OpenProjectLaunchOpts = { projectRoot, shouldLaunchNewTab, @@ -501,7 +497,7 @@ function waitForBrowserToConnect (options: { project: Project, socketId: string, return wait() } -function waitForSocketConnection (project, id) { +function waitForSocketConnection (project: Project, id: string) { if (globalThis.CY_TEST_MOCK?.waitForSocketConnection) return debug('waiting for socket connection... %o', { id }) @@ -638,19 +634,20 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens if (videoExists && !skippedSpec && !videoCaptureFailed) { const ffmpegChaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests) - await postProcessRecording( - videoName, - videoRecording.info.compressedVideoName, - videoCompression, - shouldUploadVideo, - quiet, - ffmpegChaptersConfig, - ) - .catch(warnVideoRecordingFailed) + try { + await postProcessRecording( + videoName, + videoRecording.info.compressedVideoName, + videoCompression, + shouldUploadVideo, + quiet, + ffmpegChaptersConfig, + ) + } catch (err) { + warnVideoRecordingFailed(err) + } } - console.log('results done') - return results } @@ -863,8 +860,6 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project: }), ]) - console.log('tests done, browser done') - return { results } } From ce0a27f77d154756e1fb0be9cf9aab10fad9d2e0 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Tue, 30 Aug 2022 16:36:34 -0400 Subject: [PATCH 17/29] fix multi-spec videos? --- packages/server/lib/browsers/electron.ts | 2 +- packages/server/lib/modes/run.ts | 28 +++++++++++++----------- system-tests/test/headed_spec.ts | 19 +++++----------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 028ae3d5543e..bb803f71e158 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -453,7 +453,7 @@ export = { }) }, - async connectToNewSpec (browser, options, automation) { + async connectToNewSpec (browser: Browser, options: ElectronOpts, automation: Automation) { if (!options.url) throw new Error('Missing url in connectToNewSpec') await this.open(browser, options.url, options, automation) diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 655b508b936f..0a2b7dc20476 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -25,7 +25,6 @@ import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import * as printResults from '../util/print-run' -import pDefer from 'p-defer' type SetScreenshotMetadata = (data: TakeScreenshotProps) => void type ScreenshotMetadata = ReturnType @@ -226,7 +225,7 @@ async function trashAssets (config: Cfg) { type VideoRecording = { info: VideoBrowserOpt - promise: Promise + controller?: VideoController } async function startVideoRecording (options: { project: Project, spec: SpecWithRelativeRoot, videosFolder: string }): Promise { @@ -255,22 +254,22 @@ async function startVideoRecording (options: { project: Project, spec: SpecWithR onError(err) } - const videoPromise = pDefer() - - return { + const videoRecording: VideoRecording = { info: { onError, videoName, compressedVideoName, setVideoController (videoController) { - videoPromise.resolve(videoController) + videoRecording.controller = videoController }, onProjectCaptureVideoFrames (fn) { options.project.on('capture:video:frames', fn) }, }, - promise: videoPromise.promise.catch(onError), + controller: undefined, } + + return videoRecording } const warnVideoRecordingFailed = (err) => { @@ -530,13 +529,18 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens const results = await listenForProjectEnd(project, exit) + debug('received project end %o', results) + // https://github.com/cypress-io/cypress/issues/2370 // delay 1 second if we're recording a video to give // the browser padding to render the final frames // to avoid chopping off the end of the video - const videoController = await videoRecording?.promise + const videoController = videoRecording?.controller //await videoRecording?.promise + + debug('received videoController %o', { videoController }) if (videoController) { + debug('delaying to extend video %o', { DELAY_TO_LET_VIDEO_FINISH_MS }) await Bluebird.delay(DELAY_TO_LET_VIDEO_FINISH_MS) } @@ -576,6 +580,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens // if (endVideoCapture) { try { await videoController.endVideoCapture() + debug('ended video capture') } catch (err) { videoCaptureFailed = true // TODO; could this warn twice...? @@ -585,6 +590,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens } await runEvents.execute('after:spec', config, spec, results) + debug('executed after:spec') const videoName = videoRecording?.info.videoName const videoExists = videoName && await fs.pathExists(videoName) @@ -608,11 +614,6 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens printResults.displayResults(results, estimated) } - // TODO: not needed right? - // const project = openProject.getProject() - - if (!project) throw new Error('Missing project!') - // @ts-expect-error experimentalSingleTabRunMode only exists on the CT-specific config type const usingExperimentalSingleTabMode = testingType === 'component' && config.experimentalSingleTabRunMode @@ -635,6 +636,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens const ffmpegChaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests) try { + debug('post processing recording') await postProcessRecording( videoName, videoRecording.info.compressedVideoName, diff --git a/system-tests/test/headed_spec.ts b/system-tests/test/headed_spec.ts index 5d01cb2f3060..cf636c08b2f0 100644 --- a/system-tests/test/headed_spec.ts +++ b/system-tests/test/headed_spec.ts @@ -1,19 +1,12 @@ -import systemTests, { BrowserName } from '../lib/system-tests' +import systemTests from '../lib/system-tests' describe('e2e headed', function () { systemTests.setup() - const browserList: BrowserName[] = ['chrome', 'firefox', 'electron'] - - browserList.forEach(function (browser) { - it(`runs multiple specs in headed mode - [${browser}]`, async function () { - await systemTests.exec(this, { - project: 'e2e', - headed: true, - browser, - spec: 'a_record.cy.js,b_record.cy.js,simple_passing.cy.js', - expectedExitCode: 0, - }) - }) + systemTests.it(`runs multiple specs in headed mode`, { + project: 'e2e', + headed: true, + spec: 'a_record.cy.js,b_record.cy.js,simple_passing.cy.js', + expectedExitCode: 0, }) }) From e41dd7023c45e3e6e8ef2c1229ee18d8b8535b15 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Tue, 30 Aug 2022 17:28:48 -0400 Subject: [PATCH 18/29] webkit video recording works! --- .../server/lib/browsers/webkit-automation.ts | 32 ++++++++- packages/server/lib/browsers/webkit.ts | 4 +- packages/server/lib/modes/run.ts | 69 +++++++++---------- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index cccf6b52caa7..713fcf19b39e 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -3,6 +3,8 @@ import Debug from 'debug' import type playwright from 'playwright-webkit' import type { Automation } from '../automation' import { normalizeResourceType } from './cdp_automation' +import os from 'os' +import type { VideoBrowserOpt } from '@packages/types' const debug = Debug('cypress:server:browsers:webkit-automation') @@ -90,19 +92,22 @@ export class WebKitAutomation { private constructor (public automation: Automation, private browser: playwright.Browser) {} // static initializer to avoid "not definitively declared" - static async create (automation: Automation, browser: playwright.Browser, initialUrl: string) { + static async create (automation: Automation, browser: playwright.Browser, initialUrl: string, video?: VideoBrowserOpt) { const wkAutomation = new WebKitAutomation(automation, browser) - await wkAutomation.reset(initialUrl) + await wkAutomation.reset(initialUrl, video) return wkAutomation } - public async reset (newUrl?: string) { + public async reset (newUrl?: string, video?: VideoBrowserOpt) { debug('resetting playwright page + context %o', { newUrl }) // new context comes with new cache + storage const newContext = await this.browser.newContext({ ignoreHTTPSErrors: true, + recordVideo: video && { + dir: os.tmpdir(), + }, }) const oldPwPage = this.page @@ -110,6 +115,7 @@ export class WebKitAutomation { this.context = this.page.context() this.attachListeners(this.page) + if (video) this.recordVideo(video) let promises: Promise[] = [] @@ -120,6 +126,26 @@ export class WebKitAutomation { if (promises.length) await Promise.all(promises) } + private recordVideo (video: VideoBrowserOpt) { + const _this = this + + video.setVideoController({ + async endVideoCapture () { + const pwVideo = _this.page.video() + + if (!pwVideo) throw new Error('pw.page missing video in endVideoCapture, cannot save video') + + const p = pwVideo.saveAs(video.videoName) + + await Promise.all([ + _this.page.close(), + p, + ]) + }, + startedVideoCapture: new Date(), + }) + } + private attachListeners (page: playwright.Page) { // emit preRequest to proxy page.on('request', (request) => { diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index 0eb3399a64b1..56fdbb6a7f58 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -16,7 +16,7 @@ export async function connectToNewSpec (browser: Browser, options: BrowserNewTab automation.use(wkAutomation) wkAutomation.automation = automation await options.onInitializeNewBrowserTab() - await wkAutomation.reset(options.url) + await wkAutomation.reset(options.url, options.video) } export function connectToExisting () { @@ -36,7 +36,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc headless: browser.isHeadless, }) - wkAutomation = await WebKitAutomation.create(automation, pwBrowser, url) + wkAutomation = await WebKitAutomation.create(automation, pwBrowser, url, options.video) automation.use(wkAutomation) class WkInstance extends EventEmitter implements BrowserInstance { diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 0a2b7dc20476..05dae1cda434 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -40,7 +40,7 @@ let exitEarly = (err) => { earlyExitErr = err } let earlyExitErr: Error -let currentWriteVideoFrameCallback: WriteVideoFrame +// let currentWriteVideoFrameCallback: WriteVideoFrame let currentSetScreenshotMetadata: SetScreenshotMetadata const debug = Debug('cypress:server:run') @@ -260,6 +260,7 @@ async function startVideoRecording (options: { project: Project, spec: SpecWithR videoName, compressedVideoName, setVideoController (videoController) { + debug('setting videoController for videoRecording %o', videoRecording) videoRecording.controller = videoController }, onProjectCaptureVideoFrames (fn) { @@ -269,6 +270,8 @@ async function startVideoRecording (options: { project: Project, spec: SpecWithR controller: undefined, } + debug('created videoRecording %o', videoRecording) + return videoRecording } @@ -278,12 +281,6 @@ const warnVideoRecordingFailed = (err) => { errors.warning('VIDEO_POST_PROCESSING_FAILED', err) } -function navigateToNextSpec (spec) { - debug('navigating to next spec %s', spec) - - return openProject.changeUrlToSpec(spec) -} - async function postProcessRecording (name, cname, videoCompression, shouldUploadVideo, quiet, ffmpegChaptersConfig) { debug('ending the video recording %o', { name, videoCompression, shouldUploadVideo }) @@ -406,33 +403,33 @@ function listenForProjectEnd (project, exit): Bluebird { }) } -/** - * In CT mode, browser do not relaunch. - * In browser laucnh is where we wire the new video - * recording callback. - * This has the effect of always hitting the first specs - * video callback. - * - * This allows us, if we need to, to call a different callback - * in the same browser - */ -function writeVideoFrameCallback (data: Buffer) { - return currentWriteVideoFrameCallback(data) -} - -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, videoRecording?: VideoRecording }) { +// /** +// * In CT mode, browser do not relaunch. +// * In browser laucnh is where we wire the new video +// * recording callback. +// * This has the effect of always hitting the first specs +// * video callback. +// * +// * This allows us, if we need to, to call a different callback +// * in the same browser +// */ +// function writeVideoFrameCallback (data: Buffer) { +// return currentWriteVideoFrameCallback(data) +// } + +async 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, videoRecording?: VideoRecording }) { if (globalThis.CY_TEST_MOCK?.waitForBrowserToConnect) return Promise.resolve() - const { project, socketId, onError, writeVideoFrame, spec } = options + const { project, socketId, onError, spec } = options const browserTimeout = Number(process.env.CYPRESS_INTERNAL_BROWSER_CONNECT_TIMEOUT || 60000) let attempts = 0 - // short circuit current browser callback so that we - // can rewire it without relaunching the browser - if (writeVideoFrame) { - currentWriteVideoFrameCallback = writeVideoFrame - options.writeVideoFrame = writeVideoFrameCallback - } + // // short circuit current browser callback so that we + // // can rewire it without relaunching the browser + // if (writeVideoFrame) { + // currentWriteVideoFrameCallback = writeVideoFrame + // options.writeVideoFrame = writeVideoFrameCallback + // } // without this the run mode is only setting new spec // path for next spec in launch browser. @@ -451,12 +448,14 @@ function waitForBrowserToConnect (options: { project: Project, socketId: string, if (options.experimentalSingleTabRunMode && options.testingType === 'component' && !options.isFirstSpec) { // reset browser state to match default behavior when opening/closing a new tab - return openProject.resetBrowserState().then(() => { - // If we do not launch the browser, - // we tell it that we are ready - // to receive the next spec - return navigateToNextSpec(options.spec) - }) + await openProject.resetBrowserState() + + // If we do not launch the browser, + // we tell it that we are ready + // to receive the next spec + debug('navigating to next spec %s', spec) + + return openProject.changeUrlToSpec(spec) } const wait = async () => { From 645a8d7d269b1279263e56f57dc5cd9b7d2bb99b Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Tue, 30 Aug 2022 17:29:14 -0400 Subject: [PATCH 19/29] webkit system tests --- circle.yml | 34 +++++++++++++++++++++---- system-tests/lib/dep-installer/index.ts | 1 + system-tests/lib/normalizeStdout.ts | 4 +-- system-tests/lib/system-tests.ts | 6 ++--- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/circle.yml b/circle.yml index ac2a45859dbf..3a6e01cc0a3e 100644 --- a/circle.yml +++ b/circle.yml @@ -178,6 +178,14 @@ commands: mv ~/cypress/system-tests/node_modules /tmp/node_modules_cache/system-tests_node_modules mv ~/cypress/globbed_node_modules /tmp/node_modules_cache/globbed_node_modules + install-webkit-deps: + steps: + - run: + name: Install WebKit dependencies + command: | + npx playwright install webkit + npx playwright install-deps webkit + build-and-persist: description: Save entire folder as artifact for other jobs to run without reinstalling steps: @@ -462,6 +470,11 @@ commands: - install-chrome: channel: <> version: $(node ./scripts/get-browser-version.js chrome:<>) + - when: + condition: + equal: [ webkit, << parameters.browser >> ] + steps: + - install-webkit-deps - run: name: Run driver tests in Cypress environment: @@ -470,11 +483,6 @@ commands: echo Current working directory is $PWD echo Total containers $CIRCLE_NODE_TOTAL - if [[ "<>" = "webkit" ]]; then - npx playwright install webkit - npx playwright install-deps webkit - fi - if [[ -v MAIN_RECORD_KEY ]]; then # internal PR if <>; then @@ -610,6 +618,11 @@ commands: steps: - restore_cached_workspace - restore_cached_system_tests_deps + - when: + condition: + equal: [ webkit, << parameters.browser >> ] + steps: + - install-webkit-deps - run: name: Run system tests command: | @@ -1448,6 +1461,13 @@ jobs: - run-system-tests: browser: firefox + system-tests-webkit: + <<: *defaults + parallelism: 8 + steps: + - run-system-tests: + browser: webkit + system-tests-non-root: <<: *defaults steps: @@ -2363,6 +2383,10 @@ linux-x64-workflow: &linux-x64-workflow context: test-runner:performance-tracking requires: - system-tests-node-modules-install + - system-tests-webkit: + context: test-runner:performance-tracking + requires: + - system-tests-node-modules-install - system-tests-non-root: context: test-runner:performance-tracking executor: non-root-docker-user diff --git a/system-tests/lib/dep-installer/index.ts b/system-tests/lib/dep-installer/index.ts index e8aeaeefdb41..67babfbab1c4 100644 --- a/system-tests/lib/dep-installer/index.ts +++ b/system-tests/lib/dep-installer/index.ts @@ -291,6 +291,7 @@ export async function scaffoldCommonNodeModules () { 'jimp', 'lazy-ass', 'lodash', + 'playwright-webkit', 'proxyquire', 'semver', 'systeminformation', diff --git a/system-tests/lib/normalizeStdout.ts b/system-tests/lib/normalizeStdout.ts index 5d10e5eb43d7..1bac6aa44d2d 100644 --- a/system-tests/lib/normalizeStdout.ts +++ b/system-tests/lib/normalizeStdout.ts @@ -3,11 +3,11 @@ import _ from 'lodash' export const e2ePath = Fixtures.projectPath('e2e') -export const DEFAULT_BROWSERS = ['electron', 'chrome', 'firefox'] +export const DEFAULT_BROWSERS = ['electron', 'chrome', 'firefox', 'webkit'] export const pathUpToProjectName = Fixtures.projectPath('') -export const browserNameVersionRe = /(Browser\:\s+)(Custom |)(Electron|Chrome|Canary|Chromium|Firefox)(\s\d+)(\s\(\w+\))?(\s+)/ +export const browserNameVersionRe = /(Browser\:\s+)(Custom |)(Electron|Chrome|Canary|Chromium|Firefox|WebKit)(\s\d+)(\s\(\w+\))?(\s+)/ const stackTraceLinesRe = /(\n?[^\S\n\r]*).*?(@|\bat\b)(?:.*node:.*|.*\.(js|coffee|ts|html|jsx|tsx))\??(-\d+)?:\d+:\d+[\n\S\s]*?(\n\s*?\n|$)/g const availableBrowsersRe = /(Available browsers found on your system are:)([\s\S]+)/g diff --git a/system-tests/lib/system-tests.ts b/system-tests/lib/system-tests.ts index 2262716da5f1..1a94a240d3b5 100644 --- a/system-tests/lib/system-tests.ts +++ b/system-tests/lib/system-tests.ts @@ -40,8 +40,8 @@ require(`@packages/server/lib/project-base`) type CypressConfig = { [key: string]: any } -export type BrowserName = 'electron' | 'firefox' | 'chrome' -| '!electron' | '!chrome' | '!firefox' +export type BrowserName = 'electron' | 'firefox' | 'chrome' | 'webkit' +| '!electron' | '!chrome' | '!firefox' | '!webkit' type ExecResult = { code: number @@ -854,7 +854,7 @@ const systemTests = { const { browser } = options if (browser && !customBrowserPath) { - expect(_.capitalize(browser)).to.eq(browserName) + expect(String(browser).toLowerCase()).to.eq(browserName.toLowerCase()) } expect(parseFloat(version)).to.be.a.number From 059748bcf93c9ff644f29efce9136c3f76ed3c18 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 09:09:11 -0400 Subject: [PATCH 20/29] skip system-tests that won't be fixed in this PR ~60 tests skipped out of ~99: * ~6 skipped due to needing multidomain support * ~8 skipped due to missing before:browser:launch support * ~22 skipped due to broken stack traces --- system-tests/test/async_timeouts_spec.js | 1 + system-tests/test/before_browser_launch_spec.ts | 1 + system-tests/test/caught_uncaught_hook_errors_spec.js | 4 ++++ system-tests/test/commands_outside_of_test_spec.js | 2 ++ system-tests/test/cookies_spec.ts | 4 ++++ system-tests/test/cy_origin_error_spec.ts | 1 + system-tests/test/cy_origin_retries_spec.ts | 1 + system-tests/test/deprecated_spec.ts | 6 ++++++ system-tests/test/downloads_spec.ts | 2 ++ system-tests/test/error_ui_spec.ts | 2 ++ system-tests/test/fetch_polyfill_spec.js | 1 + system-tests/test/form_submissions_spec.js | 1 + system-tests/test/issue_173_spec.ts | 1 + system-tests/test/issue_674_spec.js | 1 + system-tests/test/js_error_handling_spec.js | 1 + system-tests/test/page_loading_spec.js | 1 + system-tests/test/promises_spec.js | 1 + system-tests/test/request_spec.ts | 1 + system-tests/test/runnable_execution_spec.ts | 2 ++ system-tests/test/screenshots_spec.js | 1 + system-tests/test/server_sent_events_spec.js | 1 + system-tests/test/session_spec.ts | 3 +++ system-tests/test/spec_isolation_spec.js | 2 ++ system-tests/test/stdout_spec.js | 1 + system-tests/test/subdomain_spec.ts | 1 + system-tests/test/testConfigOverrides_spec.ts | 1 + system-tests/test/uncaught_spec_errors_spec.js | 5 +++++ system-tests/test/user_agent_spec.js | 1 + system-tests/test/visit_spec.js | 6 ++++++ system-tests/test/web_security_spec.js | 2 ++ system-tests/test/websockets_spec.js | 1 + system-tests/test/xhr_spec.js | 2 ++ 32 files changed, 61 insertions(+) diff --git a/system-tests/test/async_timeouts_spec.js b/system-tests/test/async_timeouts_spec.js index 857d38cc30b9..cc22f18bbe67 100644 --- a/system-tests/test/async_timeouts_spec.js +++ b/system-tests/test/async_timeouts_spec.js @@ -4,6 +4,7 @@ describe('e2e async timeouts', () => { systemTests.setup() systemTests.it('failing1', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'async_timeouts.cy.js', snapshot: true, expectedExitCode: 2, diff --git a/system-tests/test/before_browser_launch_spec.ts b/system-tests/test/before_browser_launch_spec.ts index 514362358dcf..0b1273296e48 100644 --- a/system-tests/test/before_browser_launch_spec.ts +++ b/system-tests/test/before_browser_launch_spec.ts @@ -26,6 +26,7 @@ describe('e2e before:browser:launch', () => { }) systemTests.it('can add extensions', { + browser: '!webkit', // TODO(webkit): fix+unskip, or skip and add a test that this fails with WebKit spec: 'spec.cy.js', config: { video: false, diff --git a/system-tests/test/caught_uncaught_hook_errors_spec.js b/system-tests/test/caught_uncaught_hook_errors_spec.js index 8831628bd0e7..934a4750467e 100644 --- a/system-tests/test/caught_uncaught_hook_errors_spec.js +++ b/system-tests/test/caught_uncaught_hook_errors_spec.js @@ -9,24 +9,28 @@ describe('e2e caught and uncaught hooks errors', () => { }) systemTests.it('failing1', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'hook_caught_error_failing.cy.js', snapshot: true, expectedExitCode: 3, }) systemTests.it('failing2', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to different ReferenceError snapshot) spec: 'hook_uncaught_error_failing.cy.js', snapshot: true, expectedExitCode: 1, }) systemTests.it('failing3', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to different ReferenceError snapshot) spec: 'hook_uncaught_root_error_failing.cy.js', snapshot: true, expectedExitCode: 1, }) systemTests.it('failing4', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to different ReferenceError snapshot) spec: 'hook_uncaught_error_events_failing.cy.js', snapshot: true, expectedExitCode: 1, diff --git a/system-tests/test/commands_outside_of_test_spec.js b/system-tests/test/commands_outside_of_test_spec.js index b7c2f62ad21a..60ff96c9edde 100644 --- a/system-tests/test/commands_outside_of_test_spec.js +++ b/system-tests/test/commands_outside_of_test_spec.js @@ -4,12 +4,14 @@ describe('e2e commands outside of test', () => { systemTests.setup() systemTests.it('fails on cy commands', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'commands_outside_of_test.cy.js', snapshot: true, expectedExitCode: 1, }) systemTests.it('fails on failing assertions', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'assertions_failing_outside_of_test.cy.js', snapshot: true, expectedExitCode: 1, diff --git a/system-tests/test/cookies_spec.ts b/system-tests/test/cookies_spec.ts index 05776b0e9281..5bf8bf8b248a 100644 --- a/system-tests/test/cookies_spec.ts +++ b/system-tests/test/cookies_spec.ts @@ -198,6 +198,7 @@ describe('e2e cookies', () => { // once browsers are shipping with the options in FORCED_SAMESITE_ENV as default, // we can remove this extra test case it('with forced SameSite strictness', { + browser: '!webkit', // TODO(webkit): fix+unskip config: { baseUrl, env: { @@ -251,6 +252,7 @@ describe('e2e cookies', () => { ], ) => { it(`passes with baseurl: ${baseUrl}`, { + browser: '!webkit', // TODO(webkit): fix+unskip config: { baseUrl, env: { @@ -274,6 +276,7 @@ describe('e2e cookies', () => { }) it('passes with no baseurl', { + browser: '!webkit', // TODO(webkit): fix+unskip config: { env: { httpUrl, @@ -347,6 +350,7 @@ describe('cross-origin cookies, set:cookies', () => { // https://github.com/cypress-io/cypress/issues/6375 it('set:cookies', { + browser: '!webkit', // TODO(webkit): fix+unskip (needs multidomain support) config: { video: false, baseUrl: `http://127.0.0.3:${httpPort}`, diff --git a/system-tests/test/cy_origin_error_spec.ts b/system-tests/test/cy_origin_error_spec.ts index ce43ac567393..a465a1be6ce9 100644 --- a/system-tests/test/cy_origin_error_spec.ts +++ b/system-tests/test/cy_origin_error_spec.ts @@ -28,6 +28,7 @@ describe('e2e cy.origin errors', () => { }) systemTests.it('captures the stack trace correctly for errors in cross origins to point users to their "cy.origin" callback', { + browser: '!webkit', // TODO(webkit): fix+unskip (needs multidomain support) // keep the port the same to prevent issues with the snapshot port: PORT, spec: 'cy_origin_error.cy.ts', diff --git a/system-tests/test/cy_origin_retries_spec.ts b/system-tests/test/cy_origin_retries_spec.ts index 88d8e27ea67e..0502b8e26a64 100644 --- a/system-tests/test/cy_origin_retries_spec.ts +++ b/system-tests/test/cy_origin_retries_spec.ts @@ -26,6 +26,7 @@ describe('e2e cy.origin retries', () => { }) systemTests.it('Appropriately displays test retry errors without other side effects', { + browser: '!webkit', // TODO(webkit): fix+unskip (needs multidomain support) // keep the port the same to prevent issues with the snapshot port: PORT, spec: 'cy_origin_retries.cy.ts', diff --git a/system-tests/test/deprecated_spec.ts b/system-tests/test/deprecated_spec.ts index 425f6e7e72a0..3402ca796df1 100644 --- a/system-tests/test/deprecated_spec.ts +++ b/system-tests/test/deprecated_spec.ts @@ -18,6 +18,7 @@ describe('deprecated before:browser:launch args', () => { systemTests.setup() systemTests.it('fails when adding unknown properties to launchOptions', { + browser: '!webkit', // TODO(webkit): fix+unskip (add executeBeforeBrowserLaunch to WebKit) config: { video: false, env: { @@ -31,6 +32,7 @@ describe('deprecated before:browser:launch args', () => { }) systemTests.it('push and no return - warns user exactly once', { + browser: '!webkit', // TODO(webkit): fix+unskip (add executeBeforeBrowserLaunch to WebKit) config: { video: false, env: { @@ -44,6 +46,7 @@ describe('deprecated before:browser:launch args', () => { }) systemTests.it('using non-deprecated API - no warning', { + browser: '!webkit', // TODO(webkit): fix+unskip (add executeBeforeBrowserLaunch to WebKit) // TODO: implement webPreferences.additionalArgs here // once we decide if/what we're going to make the implemenation // SUGGESTION: add this to Cypress.browser.args which will capture @@ -61,6 +64,7 @@ describe('deprecated before:browser:launch args', () => { }) systemTests.it('concat return returns once', { + browser: '!webkit', // TODO(webkit): fix+unskip (add executeBeforeBrowserLaunch to WebKit) // TODO: implement webPreferences.additionalArgs here // once we decide if/what we're going to make the implemenation // SUGGESTION: add this to Cypress.browser.args which will capture @@ -109,6 +113,7 @@ describe('deprecated before:browser:launch args', () => { // printed. we should print that we are aborting the run because // the before:browser:launch handler threw an error / rejected systemTests.it('displays errors thrown and aborts the run', { + browser: '!webkit', // TODO(webkit): fix+unskip (add executeBeforeBrowserLaunch to WebKit) config: { video: false, env: { @@ -127,6 +132,7 @@ describe('deprecated before:browser:launch args', () => { // printed. we should print that we are aborting the run because // the before:browser:launch handler threw an error / rejected systemTests.it('displays promises rejected and aborts the run', { + browser: '!webkit', // TODO(webkit): fix+unskip (add executeBeforeBrowserLaunch to WebKit) config: { video: false, env: { diff --git a/system-tests/test/downloads_spec.ts b/system-tests/test/downloads_spec.ts index 03ee533e4a49..8c7fc6449967 100644 --- a/system-tests/test/downloads_spec.ts +++ b/system-tests/test/downloads_spec.ts @@ -10,6 +10,7 @@ describe('e2e downloads', () => { systemTests.setup() systemTests.it('handles various file downloads', { + browser: '!webkit', // TODO(webkit): fix+unskip (implement downloads support) project: 'downloads', spec: 'downloads.cy.ts', config: { @@ -22,6 +23,7 @@ describe('e2e downloads', () => { } systemTests.it('allows changing the downloads folder', { + browser: '!webkit', // TODO(webkit): fix+unskip (implement downloads support) project: 'downloads', spec: 'downloads.cy.ts', config: { diff --git a/system-tests/test/error_ui_spec.ts b/system-tests/test/error_ui_spec.ts index 8fa8bebc7b09..488667ab291f 100644 --- a/system-tests/test/error_ui_spec.ts +++ b/system-tests/test/error_ui_spec.ts @@ -24,6 +24,7 @@ describe('e2e error ui', function () { ] .forEach((project) => { systemTests.it(`handles sourcemaps in webpack for project: ${project}`, { + browser: '!webkit', // TODO(webkit): fix+unskip project, spec: 'failing.*', expectedExitCode: 1, @@ -35,6 +36,7 @@ describe('e2e error ui', function () { // https://github.com/cypress-io/cypress/issues/16255 systemTests.it('handles errors when test files are outside of project root', { + browser: '!webkit', // TODO(webkit): fix+unskip project: 'integration-outside-project-root/project-root', spec: '../../../e2e/failing.cy.js', expectedExitCode: 1, diff --git a/system-tests/test/fetch_polyfill_spec.js b/system-tests/test/fetch_polyfill_spec.js index e7ff0481e733..e70c938a971e 100644 --- a/system-tests/test/fetch_polyfill_spec.js +++ b/system-tests/test/fetch_polyfill_spec.js @@ -136,6 +136,7 @@ describe('e2e fetch polyfill', () => { }) systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'fetch.cy.js', snapshot: false, config: { diff --git a/system-tests/test/form_submissions_spec.js b/system-tests/test/form_submissions_spec.js index 701cc37b62b9..25f6c990fa2b 100644 --- a/system-tests/test/form_submissions_spec.js +++ b/system-tests/test/form_submissions_spec.js @@ -95,6 +95,7 @@ describe('e2e forms', () => { }) systemTests.it('failing', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'form_submission_failing.cy.js', snapshot: true, expectedExitCode: 1, diff --git a/system-tests/test/issue_173_spec.ts b/system-tests/test/issue_173_spec.ts index 8973f636f4bd..b7b3891f8a75 100644 --- a/system-tests/test/issue_173_spec.ts +++ b/system-tests/test/issue_173_spec.ts @@ -5,6 +5,7 @@ describe('e2e issue 173', () => { // https://github.com/cypress-io/cypress/issues/173 systemTests.it('failing', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'issue_173.cy.js', snapshot: true, expectedExitCode: 1, diff --git a/system-tests/test/issue_674_spec.js b/system-tests/test/issue_674_spec.js index 3cb7be05f2b2..ab2155f9aa2f 100644 --- a/system-tests/test/issue_674_spec.js +++ b/system-tests/test/issue_674_spec.js @@ -5,6 +5,7 @@ describe('e2e issue 674', () => { // https://github.com/cypress-io/cypress/issues/674 systemTests.it('fails', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'issue_674.cy.js', snapshot: true, expectedExitCode: 1, diff --git a/system-tests/test/js_error_handling_spec.js b/system-tests/test/js_error_handling_spec.js index 9807c776e8ce..503ae72959b9 100644 --- a/system-tests/test/js_error_handling_spec.js +++ b/system-tests/test/js_error_handling_spec.js @@ -46,6 +46,7 @@ describe('e2e js error handling', () => { }) systemTests.it('fails', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'js_error_handling_failing.cy.js', snapshot: true, expectedExitCode: 5, diff --git a/system-tests/test/page_loading_spec.js b/system-tests/test/page_loading_spec.js index 7d3f534ab01c..c1b4ba65c1db 100644 --- a/system-tests/test/page_loading_spec.js +++ b/system-tests/test/page_loading_spec.js @@ -78,6 +78,7 @@ describe('e2e page_loading', () => { // set we send an XHR which should not inject because its requested for JSON // but that another XHR which is requested for html should inject systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip (related to document.cookie issue?) spec: 'page_loading.cy.js', snapshot: true, }) diff --git a/system-tests/test/promises_spec.js b/system-tests/test/promises_spec.js index da52bf0f143e..f468b56e5428 100644 --- a/system-tests/test/promises_spec.js +++ b/system-tests/test/promises_spec.js @@ -4,6 +4,7 @@ describe('e2e promises', () => { systemTests.setup() systemTests.it('failing1', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'promises.cy.js', snapshot: true, expectedExitCode: 2, diff --git a/system-tests/test/request_spec.ts b/system-tests/test/request_spec.ts index 2fb64dfe5342..90fd26447c67 100644 --- a/system-tests/test/request_spec.ts +++ b/system-tests/test/request_spec.ts @@ -168,6 +168,7 @@ describe('e2e requests', () => { }) systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'request.cy.js', snapshot: true, config: { diff --git a/system-tests/test/runnable_execution_spec.ts b/system-tests/test/runnable_execution_spec.ts index a30e14c7c82a..62a22c67dd99 100644 --- a/system-tests/test/runnable_execution_spec.ts +++ b/system-tests/test/runnable_execution_spec.ts @@ -20,6 +20,7 @@ describe('e2e runnable execution', () => { // but throws correct error // https://github.com/cypress-io/cypress/issues/1987 systemTests.it('cannot navigate in before hook and test', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) project: 'hooks-after-rerun', spec: 'beforehook-and-test-navigation.cy.js', snapshot: true, @@ -33,6 +34,7 @@ describe('e2e runnable execution', () => { }) systemTests.it('runs correctly after top navigation with already ran suite', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'runnables_already_run_suite.cy.js', snapshot: true, expectedExitCode: 1, diff --git a/system-tests/test/screenshots_spec.js b/system-tests/test/screenshots_spec.js index d62583db35dc..1d24bd76e141 100644 --- a/system-tests/test/screenshots_spec.js +++ b/system-tests/test/screenshots_spec.js @@ -61,6 +61,7 @@ describe('e2e screenshots', () => { // and are also generated automatically on failure with // the test title as the file name systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing partially due to broken stack trace) spec: 'screenshots.cy.js', expectedExitCode: 5, snapshot: true, diff --git a/system-tests/test/server_sent_events_spec.js b/system-tests/test/server_sent_events_spec.js index f237e5da5521..9a130a96a65d 100644 --- a/system-tests/test/server_sent_events_spec.js +++ b/system-tests/test/server_sent_events_spec.js @@ -67,6 +67,7 @@ describe('e2e server sent events', () => { // https://github.com/cypress-io/cypress/issues/1440 systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'server_sent_events.cy.js', snapshot: true, }) diff --git a/system-tests/test/session_spec.ts b/system-tests/test/session_spec.ts index 20e3003c9411..493a4b808f9d 100644 --- a/system-tests/test/session_spec.ts +++ b/system-tests/test/session_spec.ts @@ -131,6 +131,7 @@ describe('e2e sessions', () => { }) it('session tests', { + browser: '!webkit', // TODO(webkit): fix+unskip (needs multidomain support) spec: 'session.cy.js', snapshot: true, config: { @@ -140,6 +141,7 @@ describe('e2e sessions', () => { }) it('sessions persist on reload, and clear between specs', { + browser: '!webkit', // TODO(webkit): fix+unskip (needs multidomain support) spec: 'session_persist_spec_1.cy.js,session_persist_spec_2.cy.js', snapshot: true, config: { @@ -149,6 +151,7 @@ describe('e2e sessions', () => { }) it('sessions recreated on reload in open mode', { + browser: '!webkit', // TODO(webkit): fix+unskip (needs multidomain support) spec: 'session_recreate_reload.cy.js', snapshot: true, config: { diff --git a/system-tests/test/spec_isolation_spec.js b/system-tests/test/spec_isolation_spec.js index 315952d6005c..643da5056210 100644 --- a/system-tests/test/spec_isolation_spec.js +++ b/system-tests/test/spec_isolation_spec.js @@ -21,6 +21,7 @@ describe('e2e spec_isolation', () => { systemTests.setup() it('fails', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: specs, outputPath, snapshot: false, @@ -51,6 +52,7 @@ describe('e2e spec_isolation', () => { }) it('failing with retries enabled', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'simple_failing_hook.cy.js,simple_retrying.cy.js', outputPath, snapshot: true, diff --git a/system-tests/test/stdout_spec.js b/system-tests/test/stdout_spec.js index 857c827d5155..d45474d43c8a 100644 --- a/system-tests/test/stdout_spec.js +++ b/system-tests/test/stdout_spec.js @@ -65,6 +65,7 @@ describe('e2e stdout', () => { }) systemTests.it('displays assertion errors', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'stdout_assertion_errors.cy.js', snapshot: true, expectedExitCode: 4, diff --git a/system-tests/test/subdomain_spec.ts b/system-tests/test/subdomain_spec.ts index 1d2d8f2fabb8..0abcf7991403 100644 --- a/system-tests/test/subdomain_spec.ts +++ b/system-tests/test/subdomain_spec.ts @@ -119,6 +119,7 @@ describe('e2e subdomain', () => { }) systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'subdomain.cy.js', snapshot: true, config: { diff --git a/system-tests/test/testConfigOverrides_spec.ts b/system-tests/test/testConfigOverrides_spec.ts index 429286af549c..30ee1fc8c6fa 100644 --- a/system-tests/test/testConfigOverrides_spec.ts +++ b/system-tests/test/testConfigOverrides_spec.ts @@ -11,6 +11,7 @@ describe('testConfigOverrides', () => { systemTests.setup() systemTests.it('fails when passing invalid config value browser', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'testConfigOverrides/invalid-browser.js', snapshot: true, expectedExitCode: 1, diff --git a/system-tests/test/uncaught_spec_errors_spec.js b/system-tests/test/uncaught_spec_errors_spec.js index 1117a537ff97..72199ce7c953 100644 --- a/system-tests/test/uncaught_spec_errors_spec.js +++ b/system-tests/test/uncaught_spec_errors_spec.js @@ -4,30 +4,35 @@ describe('e2e uncaught errors', () => { systemTests.setup() systemTests.it('failing1', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'uncaught_synchronous_before_tests_parsed.js', snapshot: true, expectedExitCode: 1, }) systemTests.it('failing2', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'uncaught_synchronous_during_hook.cy.js', snapshot: true, expectedExitCode: 1, }) systemTests.it('failing3', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'uncaught_during_test.cy.js', snapshot: true, expectedExitCode: 3, }) systemTests.it('failing4', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'uncaught_during_hook.cy.js', snapshot: true, expectedExitCode: 1, }) systemTests.it('failing5', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'caught_async_sync_test.cy.js', snapshot: true, expectedExitCode: 4, diff --git a/system-tests/test/user_agent_spec.js b/system-tests/test/user_agent_spec.js index fcf36431b10d..7f0f04120257 100644 --- a/system-tests/test/user_agent_spec.js +++ b/system-tests/test/user_agent_spec.js @@ -29,6 +29,7 @@ describe('e2e user agent', () => { }) systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'user_agent.cy.js', snapshot: true, }) diff --git a/system-tests/test/visit_spec.js b/system-tests/test/visit_spec.js index 6609fb03a8c8..145882546871 100644 --- a/system-tests/test/visit_spec.js +++ b/system-tests/test/visit_spec.js @@ -131,6 +131,7 @@ describe('e2e visit', () => { }) systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'visit.cy.js', snapshot: true, onRun (exec) { @@ -145,6 +146,7 @@ describe('e2e visit', () => { }) systemTests.it('passes with experimentalSourceRewriting', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'source_rewriting.cy.js', config: { experimentalSourceRewriting: true, @@ -169,6 +171,7 @@ describe('e2e visit', () => { }) systemTests.it('fails when server responds with 500', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'visit_http_500_response_failing.cy.js', snapshot: true, expectedExitCode: 1, @@ -182,6 +185,7 @@ describe('e2e visit', () => { }) systemTests.it('fails when content type isnt html', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'visit_non_html_content_type_failing.cy.js', snapshot: true, expectedExitCode: 1, @@ -207,6 +211,7 @@ describe('e2e visit', () => { }) systemTests.it('fails when response never ends', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'visit_response_never_ends_failing.cy.js', snapshot: true, expectedExitCode: 3, @@ -227,6 +232,7 @@ describe('e2e visit', () => { }) systemTests.it('fails when visit times out', { + browser: '!webkit', // TODO(webkit): fix+unskip (failing due to broken stack trace) spec: 'visit_http_timeout_failing.cy.js', snapshot: true, expectedExitCode: 2, diff --git a/system-tests/test/web_security_spec.js b/system-tests/test/web_security_spec.js index 4b9805329797..4cd70cba4f9a 100644 --- a/system-tests/test/web_security_spec.js +++ b/system-tests/test/web_security_spec.js @@ -72,6 +72,7 @@ describe('e2e web security', () => { context('when enabled', () => { systemTests.it('fails', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'web_security.cy.js', config: { experimentalSessionAndOrigin: true, @@ -98,6 +99,7 @@ describe('e2e web security', () => { systemTests.it('displays warning when firefox and chromeWebSecurity:false', { spec: 'simple_passing.cy.js', snapshot: true, + // TODO(webkit): run this test in webkit browser: 'firefox', config: { chromeWebSecurity: false, diff --git a/system-tests/test/websockets_spec.js b/system-tests/test/websockets_spec.js index 5f64b72120e6..8a41e7fa1ed8 100644 --- a/system-tests/test/websockets_spec.js +++ b/system-tests/test/websockets_spec.js @@ -37,6 +37,7 @@ describe('e2e websockets', () => { // https://github.com/cypress-io/cypress/issues/556 systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'websockets.cy.js', snapshot: true, }) diff --git a/system-tests/test/xhr_spec.js b/system-tests/test/xhr_spec.js index 12438445861a..f5b753899ca1 100644 --- a/system-tests/test/xhr_spec.js +++ b/system-tests/test/xhr_spec.js @@ -29,11 +29,13 @@ describe('e2e xhr', () => { }) systemTests.it('passes in global mode', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'xhr.cy.js', snapshot: true, }) systemTests.it('passes through CLI', { + browser: '!webkit', // TODO(webkit): fix+unskip spec: 'xhr.cy.js', snapshot: true, useCli: true, From 84554e4a7ff929fddfbfb9cfe3f154630cca4e44 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 11:19:46 -0400 Subject: [PATCH 21/29] fix single-tab mode --- packages/server/lib/browsers/chrome.ts | 9 +- packages/server/lib/browsers/electron.ts | 7 +- packages/server/lib/browsers/firefox-util.ts | 14 ++- packages/server/lib/browsers/firefox.ts | 6 +- .../server/lib/browsers/webkit-automation.ts | 6 + packages/server/lib/modes/run.ts | 107 ++++++++++-------- packages/server/lib/project-base.ts | 3 +- packages/server/lib/video_capture.ts | 3 + .../performance/cy_visit_performance_spec.js | 1 + packages/types/src/server.ts | 23 ++++ 10 files changed, 118 insertions(+), 61 deletions(-) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 5ca661cce792..33465823fc96 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -12,7 +12,6 @@ import type { Protocol } from 'devtools-protocol' import appData from '../util/app_data' import { fs } from '../util/fs' -import * as videoCapture from '../video_capture' import { CdpAutomation, screencastOpts } from './cdp_automation' import * as protocol from './protocol' import utils from './utils' @@ -252,11 +251,9 @@ const _disableRestorePagesPrompt = function (userDir) { async function _recordVideo (cdpAutomation: CdpAutomation, videoOptions: VideoBrowserOpt, browserMajorVersion: number) { const screencastOptions = browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1) - const videoController = await videoCapture.start(videoOptions) + const { writeVideoFrame } = await videoOptions.newFfmpegVideoController() - videoOptions.setVideoController(videoController) - - await cdpAutomation.startVideoRecording(videoController.writeVideoFrame, screencastOptions) + await cdpAutomation.startVideoRecording(writeVideoFrame, screencastOptions) } // a utility function that navigates to the given URL @@ -577,6 +574,8 @@ export = { async attachListeners (url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) { if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') + debug('attaching listeners to chrome %o', { url, options }) + const cdpAutomation = await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options) await pageCriClient.send('Page.enable') diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index bb803f71e158..06e63594a0c8 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -6,7 +6,6 @@ import menu from '../gui/menu' import * as Windows from '../gui/windows' import { CdpAutomation, screencastOpts, CdpCommand, CdpEvent } from './cdp_automation' import * as savedState from '../saved_state' -import * as videoCapture from '../video_capture' import utils from './utils' import * as errors from '../errors' import type { Browser, BrowserInstance } from './types' @@ -111,11 +110,9 @@ function _installExtensions (win: BrowserWindow, extensionPaths: string[], optio } async function recordVideo (cdpAutomation: CdpAutomation, videoOptions: VideoBrowserOpt) { - const videoController = await videoCapture.start(videoOptions) + const { writeVideoFrame } = await videoOptions.newFfmpegVideoController() - videoOptions.setVideoController(videoController) - - await cdpAutomation.startVideoRecording(videoController.writeVideoFrame) + await cdpAutomation.startVideoRecording(writeVideoFrame) } export = { diff --git a/packages/server/lib/browsers/firefox-util.ts b/packages/server/lib/browsers/firefox-util.ts index 6546a97964fc..4d3ea03e890a 100644 --- a/packages/server/lib/browsers/firefox-util.ts +++ b/packages/server/lib/browsers/firefox-util.ts @@ -134,12 +134,24 @@ async function connectToNewSpec (options, automation: Automation, browserCriClie await navigateToUrl(options.url) } -async function setupRemote (remotePort, automation, onError, options): Promise { +async function setupRemote (remotePort, automation: Automation, onError, options): Promise { const browserCriClient = await BrowserCriClient.create(remotePort, 'Firefox', onError) const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') await CdpAutomation.create(pageCriClient.send, pageCriClient.on, browserCriClient.resetBrowserTargets, automation, options.experimentalSessionAndOrigin) + const onRequest = automation.get('onRequest') + + automation.use({ + onRequest (eventName, data) { + if (eventName === 'remote:debugger:protocol') { + return pageCriClient.send(data.command, data.params) + } + + return onRequest?.(eventName, data) + }, + }) + return browserCriClient } diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 10f95d9b90a8..a35950570c2d 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -18,7 +18,6 @@ import mimeDb from 'mime-db' import { getRemoteDebuggingPort } from './protocol' import type { BrowserCriClient } from './browser-cri-client' import type { Automation } from '../automation' -import * as videoCapture from '../video_capture' import { getCtx } from '@packages/data-context' import { getError } from '@packages/errors' import type { BrowserLaunchOpts, BrowserNewTabOpts, VideoBrowserOpt } from '@packages/types' @@ -381,10 +380,9 @@ export function connectToExisting () { } async function recordVideo (videoOptions: VideoBrowserOpt) { - const videoController = await videoCapture.start({ ...videoOptions, webmInput: true }) + const { writeVideoFrame } = await videoOptions.newFfmpegVideoController({ webmInput: true }) - videoOptions.onProjectCaptureVideoFrames(videoController.writeVideoFrame) - videoOptions.setVideoController(videoController) + videoOptions.onProjectCaptureVideoFrames(writeVideoFrame) } export async function open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation): Promise { diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index 713fcf19b39e..989c7ee01f3d 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -142,6 +142,12 @@ export class WebKitAutomation { p, ]) }, + writeVideoFrame: () => { + throw new Error('writeVideoFrame called, but WebKit does not support streaming frame data.') + }, + async restart () { + throw new Error('Cannot restart WebKit video - WebKit cannot record video on multiple specs in single-tab mode.') + }, startedVideoCapture: new Date(), }) } diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 05dae1cda434..1d8e6325a323 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -21,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, WriteVideoFrame, VideoController, VideoBrowserOpt } from '@packages/types' +import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser, VideoController, VideoRecording } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import * as printResults from '../util/print-run' @@ -40,7 +40,6 @@ let exitEarly = (err) => { earlyExitErr = err } let earlyExitErr: Error -// let currentWriteVideoFrameCallback: WriteVideoFrame let currentSetScreenshotMetadata: SetScreenshotMetadata const debug = Debug('cypress:server:run') @@ -223,12 +222,7 @@ async function trashAssets (config: Cfg) { } } -type VideoRecording = { - info: VideoBrowserOpt - controller?: VideoController -} - -async function startVideoRecording (options: { project: Project, spec: SpecWithRelativeRoot, videosFolder: string }): Promise { +async function startVideoRecording (options: { previous?: VideoRecording, project: Project, spec: SpecWithRelativeRoot, videosFolder: string }): Promise { if (!options.videosFolder) throw new Error('Missing videoFolder for recording') function videoPath (suffix: string) { @@ -254,11 +248,49 @@ async function startVideoRecording (options: { project: Project, spec: SpecWithR onError(err) } + if (options.previous) { + debug('in single-tab mode, re-using previous videoController') + + Object.assign(options.previous.info, { + videoName, + compressedVideoName, + onError, + }) + + await options.previous.controller?.restart().catch(onError) + + return options.previous + } + + let ffmpegController: VideoController + let _ffmpegOpts: Pick + const videoRecording: VideoRecording = { info: { onError, videoName, compressedVideoName, + async newFfmpegVideoController (ffmpegOpts) { + _ffmpegOpts = ffmpegOpts || _ffmpegOpts + ffmpegController = await videoCapture.start({ ...videoRecording.info, ..._ffmpegOpts }) + + // This wrapper enables re-binding writeVideoFrame to a new video stream when running in single-tab mode. + const controllerWrap = { + ...ffmpegController, + writeVideoFrame: function writeVideoFrameWrap (data) { + if (!ffmpegController) throw new Error('missing ffmpegController in writeVideoFrameWrap') + + ffmpegController.writeVideoFrame(data) + }, + async restart () { + await videoRecording.info.newFfmpegVideoController(_ffmpegOpts) + }, + } + + videoRecording.info.setVideoController(controllerWrap) + + return controllerWrap + }, setVideoController (videoController) { debug('setting videoController for videoRecording %o', videoRecording) videoRecording.controller = videoController @@ -270,7 +302,9 @@ async function startVideoRecording (options: { project: Project, spec: SpecWithR controller: undefined, } - debug('created videoRecording %o', videoRecording) + options.project.videoRecording = videoRecording + + debug('created videoRecording %o', { videoRecording }) return videoRecording } @@ -305,7 +339,7 @@ async function postProcessRecording (name, cname, videoCompression, shouldUpload return continueProcessing(onProgress) } -function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, writeVideoFrame?: WriteVideoFrame, setScreenshotMetadata: SetScreenshotMetadata, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void, videoRecording?: VideoRecording }) { +function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, setScreenshotMetadata: SetScreenshotMetadata, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, onError: (err: Error) => void, videoRecording?: VideoRecording }) { const { browser, spec, setScreenshotMetadata, screenshots, projectRoot, shouldLaunchNewTab, onError } = options const warnings = {} @@ -403,34 +437,13 @@ function listenForProjectEnd (project, exit): Bluebird { }) } -// /** -// * In CT mode, browser do not relaunch. -// * In browser laucnh is where we wire the new video -// * recording callback. -// * This has the effect of always hitting the first specs -// * video callback. -// * -// * This allows us, if we need to, to call a different callback -// * in the same browser -// */ -// function writeVideoFrameCallback (data: Buffer) { -// return currentWriteVideoFrameCallback(data) -// } - -async 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, videoRecording?: VideoRecording }) { +async function waitForBrowserToConnect (options: { project: Project, socketId: string, onError: (err: Error) => void, spec: SpecWithRelativeRoot, isFirstSpec: boolean, testingType: string, experimentalSingleTabRunMode: boolean, browser: Browser, screenshots: ScreenshotMetadata[], projectRoot: string, shouldLaunchNewTab: boolean, webSecurity: boolean, videoRecording?: VideoRecording }) { if (globalThis.CY_TEST_MOCK?.waitForBrowserToConnect) return Promise.resolve() const { project, socketId, onError, spec } = options const browserTimeout = Number(process.env.CYPRESS_INTERNAL_BROWSER_CONNECT_TIMEOUT || 60000) let attempts = 0 - // // short circuit current browser callback so that we - // // can rewire it without relaunching the browser - // if (writeVideoFrame) { - // currentWriteVideoFrameCallback = writeVideoFrame - // options.writeVideoFrame = writeVideoFrameCallback - // } - // without this the run mode is only setting new spec // path for next spec in launch browser. // we need it to run on every spec even in single browser mode @@ -450,9 +463,7 @@ async function waitForBrowserToConnect (options: { project: Project, socketId: s // reset browser state to match default behavior when opening/closing a new tab await openProject.resetBrowserState() - // If we do not launch the browser, - // we tell it that we are ready - // to receive the next spec + // since we aren't re-launching the browser, we have to navigate to the next spec instead debug('navigating to next spec %s', spec) return openProject.changeUrlToSpec(spec) @@ -534,7 +545,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens // delay 1 second if we're recording a video to give // the browser padding to render the final frames // to avoid chopping off the end of the video - const videoController = videoRecording?.controller //await videoRecording?.promise + const videoController = videoRecording?.controller debug('received videoController %o', { videoController }) @@ -575,17 +586,13 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens Reporter.setVideoTimestamp(videoController.startedVideoCapture, attempts) } - // TODO: should always have endvideocapture? - // if (endVideoCapture) { try { await videoController.endVideoCapture() debug('ended video capture') } catch (err) { videoCaptureFailed = true - // TODO; could this warn twice...? warnVideoRecordingFailed(err) } - // } } await runEvents.execute('after:spec', config, spec, results) @@ -818,11 +825,21 @@ async function runSpec (config, spec: SpecWithRelativeRoot, options: { project: await runEvents.execute('before:spec', config, spec) - const videoRecording = options.video ? await startVideoRecording({ - project, - spec, - videosFolder: options.videosFolder, - }) : undefined + async function getVideoRecording () { + if (!options.video) return undefined + + const opts = { project, spec, videosFolder: options.videosFolder } + + if (config.experimentalSingleTabRunMode && !isFirstSpec && project.videoRecording) { + // in single-tab mode, only the first spec needs to create a videoRecording object + // which is then re-used between specs + return await startVideoRecording({ ...opts, previous: project.videoRecording }) + } + + return await startVideoRecording(opts) + } + + const videoRecording = await getVideoRecording() // we know we're done running headlessly // when the renderer has connected and diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index bdc4e0bb55c6..d7b7ede00459 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -19,7 +19,7 @@ import { SocketE2E } from './socket-e2e' import { ensureProp } from './util/class-helpers' import system from './util/system' -import type { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType } from '@packages/types' +import type { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording } from '@packages/types' import { DataContext, getCtx } from '@packages/data-context' import { createHmac } from 'crypto' @@ -60,6 +60,7 @@ export class ProjectBase extends EE { private _recordTests?: any = null private _isServerOpen: boolean = false + public videoRecording?: VideoRecording public browser: any public options: OpenProjectLaunchOptions public testingType: Cypress.TestingType diff --git a/packages/server/lib/video_capture.ts b/packages/server/lib/video_capture.ts index 6e5791c3670b..d5e30a484b97 100644 --- a/packages/server/lib/video_capture.ts +++ b/packages/server/lib/video_capture.ts @@ -270,6 +270,9 @@ export function start (options: StartOptions) { endVideoCapture, writeVideoFrame, startedVideoCapture, + restart: () => { + throw new Error('restart cannot be called on a plain ffmpeg stream') + }, } }) } diff --git a/packages/server/test/performance/cy_visit_performance_spec.js b/packages/server/test/performance/cy_visit_performance_spec.js index a94e794a5d3c..35cbd75f859d 100644 --- a/packages/server/test/performance/cy_visit_performance_spec.js +++ b/packages/server/test/performance/cy_visit_performance_spec.js @@ -24,6 +24,7 @@ context('cy.visit performance tests', function () { } systemTests.it('passes', { + browser: '!webkit', // TODO(webkit): does this really need to run in all browsers? currently it's broken in webkit because we are missing deps configFile: 'cypress-performance.config.js', onStdout, spec: 'fast_visit.cy.js', diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index 9f85569a6ede..ad4761d637cc 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -4,18 +4,41 @@ import type { PlatformName } from './platform' export type WriteVideoFrame = (data: Buffer) => void +export type VideoRecording = { + info: VideoBrowserOpt + controller?: VideoController +} + /** * Interface yielded by the browser to control video recording. */ export type VideoController = { + /** + * A function that resolves once the video is fully captured and flushed to disk. + */ endVideoCapture: () => Promise + /** + * Timestamp of when the video capture started - used for chapter timestamps. + */ startedVideoCapture: Date + /** + * Used in single-tab mode to restart the video capture to a new file without relaunching the browser. + */ + restart: () => Promise + writeVideoFrame: WriteVideoFrame } export type VideoBrowserOpt = { onError: (err: Error) => void videoName: string compressedVideoName?: string + /** + * Create+use a new VideoController that uses ffmpeg to stream frames from `writeVideoFrame` to disk. + */ + newFfmpegVideoController: (opts?: { webmInput?: boolean}) => Promise + /** + * Register a non-ffmpeg video controller. + */ setVideoController: (videoController?: VideoController) => void /** * Registers a handler for project.on('capture:video:frames'). From 2b9f33fab6b0eab109298c68f995e87f531488af Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 11:32:09 -0400 Subject: [PATCH 22/29] cleanup/api renames --- packages/server/lib/browsers/chrome.ts | 6 ++--- packages/server/lib/browsers/electron.ts | 12 +++++----- packages/server/lib/browsers/firefox.ts | 10 ++++----- .../server/lib/browsers/webkit-automation.ts | 22 ++++++++++--------- packages/server/lib/browsers/webkit.ts | 4 ++-- packages/server/lib/modes/run.ts | 22 +++++++++---------- packages/types/src/server.ts | 17 ++++++++------ 7 files changed, 49 insertions(+), 44 deletions(-) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 33465823fc96..91b4d34e5912 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -19,7 +19,7 @@ import type { Browser, BrowserInstance } from './types' import { BrowserCriClient } from './browser-cri-client' import type { CriClient } from './cri-client' import type { Automation } from '../automation' -import type { BrowserLaunchOpts, BrowserNewTabOpts, VideoBrowserOpt } from '@packages/types' +import type { BrowserLaunchOpts, BrowserNewTabOpts, RunModeVideoApi } from '@packages/types' const debug = debugModule('cypress:server:browsers:chrome') @@ -248,7 +248,7 @@ const _disableRestorePagesPrompt = function (userDir) { .catch(() => { }) } -async function _recordVideo (cdpAutomation: CdpAutomation, videoOptions: VideoBrowserOpt, browserMajorVersion: number) { +async function _recordVideo (cdpAutomation: CdpAutomation, videoOptions: RunModeVideoApi, browserMajorVersion: number) { const screencastOptions = browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1) const { writeVideoFrame } = await videoOptions.newFfmpegVideoController() @@ -583,7 +583,7 @@ export = { await options['onInitializeNewBrowserTab']?.() await Promise.all([ - options.video && _recordVideo(cdpAutomation, options.video, Number(options.browser.majorVersion)), + options.videoApi && _recordVideo(cdpAutomation, options.videoApi, Number(options.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 06e63594a0c8..049b05f7b1c7 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -11,7 +11,7 @@ import * as errors from '../errors' import type { Browser, BrowserInstance } from './types' import type { BrowserWindow, WebContents } from 'electron' import type { Automation } from '../automation' -import type { BrowserLaunchOpts, Preferences, VideoBrowserOpt } from '@packages/types' +import type { BrowserLaunchOpts, Preferences, RunModeVideoApi } from '@packages/types' // TODO: unmix these two types type ElectronOpts = Windows.WindowOptions & BrowserLaunchOpts @@ -109,8 +109,8 @@ function _installExtensions (win: BrowserWindow, extensionPaths: string[], optio })) } -async function recordVideo (cdpAutomation: CdpAutomation, videoOptions: VideoBrowserOpt) { - const { writeVideoFrame } = await videoOptions.newFfmpegVideoController() +async function recordVideo (cdpAutomation: CdpAutomation, videoApi: RunModeVideoApi) { + const { writeVideoFrame } = await videoApi.newFfmpegVideoController() await cdpAutomation.startVideoRecording(writeVideoFrame) } @@ -198,7 +198,7 @@ export = { win.maximize() } - const launched = await this._launch(win, url, automation, preferences, options.video) + const launched = await this._launch(win, url, automation, preferences, options.videoApi) automation.use(await _getAutomation(win, preferences, automation)) @@ -231,7 +231,7 @@ export = { return this._launch(win, url, automation, electronOptions) }, - async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoOptions?: VideoBrowserOpt) { + async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi) { if (options.show) { menu.set({ withInternalDevTools: true }) } @@ -279,7 +279,7 @@ export = { automation.use(cdpAutomation) await Promise.all([ - videoOptions && recordVideo(cdpAutomation, videoOptions), + videoApi && recordVideo(cdpAutomation, videoApi), this._handleDownloads(win, options.downloadsFolder, automation), ]) diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index a35950570c2d..030a37b0a6ff 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -20,7 +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, VideoBrowserOpt } from '@packages/types' +import type { BrowserLaunchOpts, BrowserNewTabOpts, RunModeVideoApi } from '@packages/types' const debug = Debug('cypress:server:browsers:firefox') @@ -379,10 +379,10 @@ 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'))) } -async function recordVideo (videoOptions: VideoBrowserOpt) { - const { writeVideoFrame } = await videoOptions.newFfmpegVideoController({ webmInput: true }) +async function recordVideo (videoApi: RunModeVideoApi) { + const { writeVideoFrame } = await videoApi.newFfmpegVideoController({ webmInput: true }) - videoOptions.onProjectCaptureVideoFrames(writeVideoFrame) + videoApi.onProjectCaptureVideoFrames(writeVideoFrame) } export async function open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation): Promise { @@ -462,7 +462,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc utils.ensureCleanCache(browser, options.isTextTerminal), utils.writeExtension(browser, options.isTextTerminal, options.proxyUrl, options.socketIoRoute), utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options), - options.video && recordVideo(options.video), + options.videoApi && recordVideo(options.videoApi), ]) if (Array.isArray(launchOptions.extensions)) { diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index 989c7ee01f3d..7abace334180 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -4,7 +4,7 @@ import type playwright from 'playwright-webkit' import type { Automation } from '../automation' import { normalizeResourceType } from './cdp_automation' import os from 'os' -import type { VideoBrowserOpt } from '@packages/types' +import type { RunModeVideoApi } from '@packages/types' const debug = Debug('cypress:server:browsers:webkit-automation') @@ -92,21 +92,22 @@ export class WebKitAutomation { private constructor (public automation: Automation, private browser: playwright.Browser) {} // static initializer to avoid "not definitively declared" - static async create (automation: Automation, browser: playwright.Browser, initialUrl: string, video?: VideoBrowserOpt) { + static async create (automation: Automation, browser: playwright.Browser, initialUrl: string, videoApi?: RunModeVideoApi) { const wkAutomation = new WebKitAutomation(automation, browser) - await wkAutomation.reset(initialUrl, video) + await wkAutomation.reset(initialUrl, videoApi) return wkAutomation } - public async reset (newUrl?: string, video?: VideoBrowserOpt) { + public async reset (newUrl?: string, videoApi?: RunModeVideoApi) { debug('resetting playwright page + context %o', { newUrl }) // new context comes with new cache + storage const newContext = await this.browser.newContext({ ignoreHTTPSErrors: true, - recordVideo: video && { + recordVideo: videoApi && { dir: os.tmpdir(), + size: { width: 1280, height: 720 }, }, }) const oldPwPage = this.page @@ -115,7 +116,7 @@ export class WebKitAutomation { this.context = this.page.context() this.attachListeners(this.page) - if (video) this.recordVideo(video) + if (videoApi) this.recordVideo(videoApi) let promises: Promise[] = [] @@ -126,20 +127,21 @@ export class WebKitAutomation { if (promises.length) await Promise.all(promises) } - private recordVideo (video: VideoBrowserOpt) { + private recordVideo (videoApi: RunModeVideoApi) { const _this = this - video.setVideoController({ + videoApi.setVideoController({ async endVideoCapture () { const pwVideo = _this.page.video() if (!pwVideo) throw new Error('pw.page missing video in endVideoCapture, cannot save video') - const p = pwVideo.saveAs(video.videoName) + debug('ending video capture, closing page...') await Promise.all([ + // pwVideo.saveAs will not resolve until the page closes, presumably we do want to close it _this.page.close(), - p, + pwVideo.saveAs(videoApi.videoName), ]) }, writeVideoFrame: () => { diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index 56fdbb6a7f58..d40840d654c6 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -16,7 +16,7 @@ export async function connectToNewSpec (browser: Browser, options: BrowserNewTab automation.use(wkAutomation) wkAutomation.automation = automation await options.onInitializeNewBrowserTab() - await wkAutomation.reset(options.url, options.video) + await wkAutomation.reset(options.url, options.videoApi) } export function connectToExisting () { @@ -36,7 +36,7 @@ export async function open (browser: Browser, url: string, options: BrowserLaunc headless: browser.isHeadless, }) - wkAutomation = await WebKitAutomation.create(automation, pwBrowser, url, options.video) + wkAutomation = await WebKitAutomation.create(automation, pwBrowser, url, options.videoApi) automation.use(wkAutomation) class WkInstance extends EventEmitter implements BrowserInstance { diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 1d8e6325a323..7dcf58033cdd 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -21,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, VideoController, VideoRecording } from '@packages/types' +import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser, BrowserVideoController, VideoRecording } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import * as printResults from '../util/print-run' @@ -251,7 +251,7 @@ async function startVideoRecording (options: { previous?: VideoRecording, projec if (options.previous) { debug('in single-tab mode, re-using previous videoController') - Object.assign(options.previous.info, { + Object.assign(options.previous.api, { videoName, compressedVideoName, onError, @@ -262,17 +262,17 @@ async function startVideoRecording (options: { previous?: VideoRecording, projec return options.previous } - let ffmpegController: VideoController + let ffmpegController: BrowserVideoController let _ffmpegOpts: Pick const videoRecording: VideoRecording = { - info: { + api: { onError, videoName, compressedVideoName, async newFfmpegVideoController (ffmpegOpts) { _ffmpegOpts = ffmpegOpts || _ffmpegOpts - ffmpegController = await videoCapture.start({ ...videoRecording.info, ..._ffmpegOpts }) + ffmpegController = await videoCapture.start({ ...videoRecording.api, ..._ffmpegOpts }) // This wrapper enables re-binding writeVideoFrame to a new video stream when running in single-tab mode. const controllerWrap = { @@ -283,11 +283,11 @@ async function startVideoRecording (options: { previous?: VideoRecording, projec ffmpegController.writeVideoFrame(data) }, async restart () { - await videoRecording.info.newFfmpegVideoController(_ffmpegOpts) + await videoRecording.api.newFfmpegVideoController(_ffmpegOpts) }, } - videoRecording.info.setVideoController(controllerWrap) + videoRecording.api.setVideoController(controllerWrap) return controllerWrap }, @@ -348,7 +348,7 @@ function launchBrowser (options: { browser: Browser, spec: SpecWithRelativeRoot, projectRoot, shouldLaunchNewTab, onError, - video: options.videoRecording?.info, + videoApi: options.videoRecording?.api, automationMiddleware: { onBeforeRequest (message, data) { if (message === 'take:screenshot') { @@ -579,7 +579,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens // if we have a video recording if (videoController) { - results.video = videoRecording!.info.videoName + results.video = videoRecording!.api.videoName if (tests && tests.length) { // always set the video timestamp on tests @@ -598,7 +598,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens await runEvents.execute('after:spec', config, spec, results) debug('executed after:spec') - const videoName = videoRecording?.info.videoName + const videoName = videoRecording?.api.videoName const videoExists = videoName && await fs.pathExists(videoName) if (!videoExists) { @@ -645,7 +645,7 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens debug('post processing recording') await postProcessRecording( videoName, - videoRecording.info.compressedVideoName, + videoRecording.api.compressedVideoName, videoCompression, shouldUploadVideo, quiet, diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index ad4761d637cc..603d7d014445 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -5,14 +5,14 @@ import type { PlatformName } from './platform' export type WriteVideoFrame = (data: Buffer) => void export type VideoRecording = { - info: VideoBrowserOpt - controller?: VideoController + api: RunModeVideoApi + controller?: BrowserVideoController } /** * Interface yielded by the browser to control video recording. */ -export type VideoController = { +export type BrowserVideoController = { /** * A function that resolves once the video is fully captured and flushed to disk. */ @@ -28,18 +28,21 @@ export type VideoController = { writeVideoFrame: WriteVideoFrame } -export type VideoBrowserOpt = { +/** + * Interface passed to the browser to enable capturing video. + */ +export type RunModeVideoApi = { onError: (err: Error) => void videoName: string compressedVideoName?: string /** * Create+use a new VideoController that uses ffmpeg to stream frames from `writeVideoFrame` to disk. */ - newFfmpegVideoController: (opts?: { webmInput?: boolean}) => Promise + newFfmpegVideoController: (opts?: { webmInput?: boolean}) => Promise /** * Register a non-ffmpeg video controller. */ - setVideoController: (videoController?: VideoController) => void + setVideoController: (videoController?: BrowserVideoController) => void /** * Registers a handler for project.on('capture:video:frames'). */ @@ -51,7 +54,7 @@ export type OpenProjectLaunchOpts = { shouldLaunchNewTab: boolean automationMiddleware: AutomationMiddleware writeVideoFrame?: WriteVideoFrame - video?: VideoBrowserOpt + videoApi?: RunModeVideoApi onWarning: (err: Error) => void onError: (err: Error) => void } From 115a07cebbaac689315e8343a192216d1742bbd6 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 11:49:59 -0400 Subject: [PATCH 23/29] fix more tests --- packages/server/lib/browsers/chrome.ts | 2 +- packages/server/test/integration/cypress_spec.js | 7 +++---- packages/server/test/integration/video_capture_spec.ts | 7 ++++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 91b4d34e5912..c27980e487eb 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -583,7 +583,7 @@ export = { await options['onInitializeNewBrowserTab']?.() await Promise.all([ - options.videoApi && _recordVideo(cdpAutomation, options.videoApi, Number(options.browser.majorVersion)), + options.videoApi && this._recordVideo(cdpAutomation, options.videoApi, Number(options.browser.majorVersion)), this._handleDownloads(pageCriClient, options.downloadsFolder, automation), ]) diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index 55b957073da4..a69d3aa3815b 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.js @@ -975,6 +975,7 @@ describe('lib/cypress', () => { // and only then navigates to that URL sinon.stub(chromeBrowser, '_navigateUsingCRI').resolves() sinon.stub(chromeBrowser, '_handleDownloads').resolves() + sinon.stub(chromeBrowser, '_recordVideo').resolves() sinon.stub(chromeBrowser, '_setAutomation').returns() @@ -1004,6 +1005,7 @@ describe('lib/cypress', () => { expect(chromeBrowser._navigateUsingCRI).to.have.been.calledOnce expect(chromeBrowser._setAutomation).to.have.been.calledOnce + expect(chromeBrowser._recordVideo).to.have.been.calledOnce expect(BrowserCriClient.create).to.have.been.calledOnce expect(browserCriClient.attachToTargetUrl).to.have.been.calledOnce @@ -1011,9 +1013,7 @@ describe('lib/cypress', () => { }) it('electron', function () { - const writeVideoFrame = sinon.stub() - - videoCapture.start.returns({ writeVideoFrame }) + videoCapture.start.returns() return cypress.start([ `--run-project=${this.pluginBrowser}`, @@ -1024,7 +1024,6 @@ describe('lib/cypress', () => { browser: 'electron', foo: 'bar', onNewWindow: sinon.match.func, - writeVideoFrame: sinon.match.func, }) this.expectExitWith(0) diff --git a/packages/server/test/integration/video_capture_spec.ts b/packages/server/test/integration/video_capture_spec.ts index 74c180ecdea5..451879aa09c2 100644 --- a/packages/server/test/integration/video_capture_spec.ts +++ b/packages/server/test/integration/video_capture_spec.ts @@ -4,10 +4,10 @@ import path from 'path' import fse from 'fs-extra' import os from 'os' -async function startSpiedVideoCapture (filename, options = {}) { - const props = await videoCapture.start(filename, options) +async function startSpiedVideoCapture (videoName, options = {}) { + const props = await videoCapture.start({ videoName, ...options }) - const END_OF_FILE_ERROR = `ffmpeg exited with code 1: Output #0, mp4, to '${filename}': + const END_OF_FILE_ERROR = `ffmpeg exited with code 1: Output #0, mp4, to '${videoName}': Output file #0 does not contain any stream\n` sinon.spy(props._pt, 'write') @@ -57,6 +57,7 @@ describe('Video Capture', () => { writeVideoFrameAsBuffer(''), ] + // @ts-ignore expect(_pt.write.lastCall).calledWith(buf2) await expect(endVideoCapture()).rejectedWith(END_OF_FILE_ERROR) From 4ad2279d0fbe3c9e37bc5a886a6e48819f207834 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 11:53:18 -0400 Subject: [PATCH 24/29] minimize diff, fix ff --- packages/server/lib/browsers/firefox-util.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/server/lib/browsers/firefox-util.ts b/packages/server/lib/browsers/firefox-util.ts index 4d3ea03e890a..6546a97964fc 100644 --- a/packages/server/lib/browsers/firefox-util.ts +++ b/packages/server/lib/browsers/firefox-util.ts @@ -134,24 +134,12 @@ async function connectToNewSpec (options, automation: Automation, browserCriClie await navigateToUrl(options.url) } -async function setupRemote (remotePort, automation: Automation, onError, options): Promise { +async function setupRemote (remotePort, automation, onError, options): Promise { const browserCriClient = await BrowserCriClient.create(remotePort, 'Firefox', onError) const pageCriClient = await browserCriClient.attachToTargetUrl('about:blank') await CdpAutomation.create(pageCriClient.send, pageCriClient.on, browserCriClient.resetBrowserTargets, automation, options.experimentalSessionAndOrigin) - const onRequest = automation.get('onRequest') - - automation.use({ - onRequest (eventName, data) { - if (eventName === 'remote:debugger:protocol') { - return pageCriClient.send(data.command, data.params) - } - - return onRequest?.(eventName, data) - }, - }) - return browserCriClient } From a449c8fcd58e5cb670751d6f37262ecab5d4c78d Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 12:26:37 -0400 Subject: [PATCH 25/29] fix unit tests --- packages/server/lib/browsers/chrome.ts | 2 ++ packages/server/lib/browsers/electron.ts | 4 +-- .../test/unit/browsers/cdp_automation_spec.ts | 27 +++++++++++++++++-- .../server/test/unit/browsers/chrome_spec.js | 17 ++---------- .../test/unit/browsers/electron_spec.js | 1 + 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index c27980e487eb..1c1b912e262e 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -572,6 +572,8 @@ export = { }, async attachListeners (url: string, pageCriClient: CriClient, automation: Automation, options: BrowserLaunchOpts | BrowserNewTabOpts) { + const browserCriClient = this._getBrowserCriClient() + if (!browserCriClient) throw new Error('Missing browserCriClient in attachListeners') debug('attaching listeners to chrome %o', { url, options }) diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 049b05f7b1c7..613849c9478b 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -185,7 +185,7 @@ export = { _getAutomation, - async _render (url: string, automation: Automation, preferences, options: ElectronOpts) { + async _render (url: string, automation: Automation, preferences, options: Pick) { const win = Windows.create(options.projectRoot, preferences) if (preferences.browser.isHeadless) { @@ -485,7 +485,7 @@ export = { debug('launching browser window to url: %s', url) - const win = await this._render(url, automation, preferences, electronOptions) + const win = await this._render(url, automation, preferences, _.pick(electronOptions, ['isTextTerminal', 'projectRoot'])) await _installExtensions(win, launchOptions.extensions, electronOptions) diff --git a/packages/server/test/unit/browsers/cdp_automation_spec.ts b/packages/server/test/unit/browsers/cdp_automation_spec.ts index 608726185f68..5e7c380fff2a 100644 --- a/packages/server/test/unit/browsers/cdp_automation_spec.ts +++ b/packages/server/test/unit/browsers/cdp_automation_spec.ts @@ -67,6 +67,8 @@ context('lib/browsers/cdp_automation', () => { }) context('.CdpAutomation', () => { + let cdpAutomation: CdpAutomation + beforeEach(async function () { this.sendDebuggerCommand = sinon.stub() this.onFn = sinon.stub() @@ -76,14 +78,35 @@ context('lib/browsers/cdp_automation', () => { onRequestEvent: sinon.stub(), } - this.cdpAutomation = await CdpAutomation.create(this.sendDebuggerCommand, this.onFn, this.sendCloseTargetCommand, this.automation, false) + cdpAutomation = await CdpAutomation.create(this.sendDebuggerCommand, this.onFn, this.sendCloseTargetCommand, this.automation, false) this.sendDebuggerCommand .throws(new Error('not stubbed')) .withArgs('Browser.getVersion') .resolves() - this.onRequest = this.cdpAutomation.onRequest + this.onRequest = cdpAutomation.onRequest + }) + + describe('.startVideoRecording', function () { + // https://github.com/cypress-io/cypress/issues/9265 + it('respond ACK after receiving new screenshot frame', async function () { + const writeVideoFrame = sinon.stub() + const frameMeta = { data: Buffer.from('foo'), sessionId: '1' } + + this.onFn.withArgs('Page.screencastFrame').callsFake((e, fn) => { + fn(frameMeta) + }) + + const startScreencast = this.sendDebuggerCommand.withArgs('Page.startScreencast').resolves() + const screencastFrameAck = this.sendDebuggerCommand.withArgs('Page.screencastFrameAck').resolves() + + await cdpAutomation.startVideoRecording(writeVideoFrame) + + expect(startScreencast).to.have.been.calledWith('Page.startScreencast') + expect(writeVideoFrame).to.have.been.calledWithMatch((arg) => Buffer.isBuffer(arg) && arg.length > 0) + expect(screencastFrameAck).to.have.been.calledWith('Page.screencastFrameAck', { sessionId: frameMeta.sessionId }) + }) }) describe('.onNetworkRequestWillBeSent', function () { diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 7f3faafb3259..428b0a6b6a20 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -323,20 +323,6 @@ describe('lib/browsers/chrome', () => { 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 - it('respond ACK after receiving new screenshot frame', function () { - const frameMeta = { data: Buffer.from('foo'), sessionId: '1' } - const write = sinon.stub() - 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.calledWithMatch((arg) => Buffer.isBuffer(arg) && arg.length > 0) - expect(this.pageCriClient.send).to.have.been.calledWith('Page.screencastFrameAck', { sessionId: frameMeta.sessionId }) - }) - }) - describe('downloads', function () { it('pushes create:download after download begins', function () { const downloadData = { @@ -520,7 +506,8 @@ describe('lib/browsers/chrome', () => { ...openOpts, url: 'https://www.google.com', downloadsFolder: '/tmp/folder', - writeVideoFrame: () => {}, + browser: {}, + videoApi: {}, onInitializeNewBrowserTab: () => { onInitializeNewBrowserTabCalled = true }, diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index 194bd1fecd3e..cd609ddfbe80 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -19,6 +19,7 @@ describe('lib/browsers/electron', () => { this.url = 'https://foo.com' this.state = {} this.options = { + isTextTerminal: false, some: 'var', projectRoot: '/foo/', onWarning: sinon.stub().returns(), From 2468f9fb513b241ea16daf8edeaa3e2ee5e5e94c Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 12:39:29 -0400 Subject: [PATCH 26/29] fix tests --- packages/server/lib/browsers/electron.ts | 4 ++-- packages/server/lib/modes/run.ts | 2 +- packages/server/test/unit/browsers/electron_spec.js | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 613849c9478b..049b05f7b1c7 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -185,7 +185,7 @@ export = { _getAutomation, - async _render (url: string, automation: Automation, preferences, options: Pick) { + async _render (url: string, automation: Automation, preferences, options: ElectronOpts) { const win = Windows.create(options.projectRoot, preferences) if (preferences.browser.isHeadless) { @@ -485,7 +485,7 @@ export = { debug('launching browser window to url: %s', url) - const win = await this._render(url, automation, preferences, _.pick(electronOptions, ['isTextTerminal', 'projectRoot'])) + const win = await this._render(url, automation, preferences, electronOptions) await _installExtensions(win, launchOptions.extensions, electronOptions) diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 7dcf58033cdd..12bd4581cb6b 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -275,7 +275,7 @@ async function startVideoRecording (options: { previous?: VideoRecording, projec ffmpegController = await videoCapture.start({ ...videoRecording.api, ..._ffmpegOpts }) // This wrapper enables re-binding writeVideoFrame to a new video stream when running in single-tab mode. - const controllerWrap = { + const controllerWrap: BrowserVideoController = { ...ffmpegController, writeVideoFrame: function writeVideoFrameWrap (data) { if (!ffmpegController) throw new Error('missing ffmpegController in writeVideoFrameWrap') diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index cd609ddfbe80..beab0a2cfe4f 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -100,10 +100,10 @@ describe('lib/browsers/electron', () => { expect(_.keys(options)).to.deep.eq(preferencesKeys) - expect(electron._render.firstCall.args[3]).to.deep.eql({ - projectRoot: this.options.projectRoot, - isTextTerminal: this.options.isTextTerminal, - }) + const electronOptionsArg = electron._render.firstCall.args[3] + + expect(electronOptionsArg.projectRoot).to.eq(this.options.projectRoot) + expect(electronOptionsArg.isTextTerminal).to.eq(this.options.isTextTerminal) expect(electron._render).to.be.calledWith( this.url, From d38ae2708abac3f6cc7e7f6bf4afea0908cf1ae3 Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Wed, 31 Aug 2022 13:52:19 -0400 Subject: [PATCH 27/29] cleanup --- .../server/lib/browsers/webkit-automation.ts | 14 ++++- packages/server/lib/modes/run.ts | 36 ++++++----- packages/server/lib/util/print-run.ts | 4 +- packages/server/lib/video_capture.ts | 36 +++++------ packages/types/src/index.ts | 2 + packages/types/src/server.ts | 48 +-------------- packages/types/src/video.ts | 60 +++++++++++++++++++ 7 files changed, 116 insertions(+), 84 deletions(-) create mode 100644 packages/types/src/video.ts diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index 7abace334180..575a81223c15 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -110,13 +110,14 @@ export class WebKitAutomation { size: { width: 1280, height: 720 }, }, }) + const contextStarted = new Date const oldPwPage = this.page this.page = await newContext.newPage() this.context = this.page.context() this.attachListeners(this.page) - if (videoApi) this.recordVideo(videoApi) + if (videoApi) this.recordVideo(videoApi, contextStarted) let promises: Promise[] = [] @@ -127,7 +128,7 @@ export class WebKitAutomation { if (promises.length) await Promise.all(promises) } - private recordVideo (videoApi: RunModeVideoApi) { + private recordVideo (videoApi: RunModeVideoApi, startedVideoCapture: Date) { const _this = this videoApi.setVideoController({ @@ -150,7 +151,14 @@ export class WebKitAutomation { async restart () { throw new Error('Cannot restart WebKit video - WebKit cannot record video on multiple specs in single-tab mode.') }, - startedVideoCapture: new Date(), + postProcessFfmpegOptions: { + // WebKit seems to record at the highest possible frame rate, so filter out duplicate frames before compressing + // otherwise compressing with all these dupe frames can take a really long time + // https://stackoverflow.com/q/37088517/3474615 + outputOptions: ['-vsync vfr'], + videoFilters: 'mpdecimate', + }, + startedVideoCapture, }) } diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 12bd4581cb6b..79675003e3f5 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -21,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, BrowserVideoController, VideoRecording } from '@packages/types' +import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts, FoundBrowser, BrowserVideoController, VideoRecording, ProcessOptions } from '@packages/types' import type { Cfg } from '../project-base' import type { Browser } from '../browsers/types' import * as printResults from '../util/print-run' @@ -315,26 +315,31 @@ const warnVideoRecordingFailed = (err) => { errors.warning('VIDEO_POST_PROCESSING_FAILED', err) } -async function postProcessRecording (name, cname, videoCompression, shouldUploadVideo, quiet, ffmpegChaptersConfig) { - debug('ending the video recording %o', { name, videoCompression, shouldUploadVideo }) +async function postProcessRecording (options: { quiet: boolean, videoCompression: number | boolean, shouldUploadVideo: boolean, processOptions: Omit }) { + debug('ending the video recording %o', options) // once this ended promises resolves // then begin processing the file // dont process anything if videoCompress is off // or we've been told not to upload the video - if (videoCompression === false || shouldUploadVideo === false) { + if (options.videoCompression === false || options.shouldUploadVideo === false) { return } + const processOptions: ProcessOptions = { + ...options.processOptions, + videoCompression: Number(options.videoCompression), + } + function continueProcessing (onProgress?: (progress: number) => void) { - return videoCapture.process(name, cname, videoCompression, ffmpegChaptersConfig, onProgress) + return videoCapture.process({ ...processOptions, onProgress }) } - if (quiet) { + if (options.quiet) { return continueProcessing() } - const { onProgress } = printResults.displayVideoProcessingProgress({ name, videoCompression }) + const { onProgress } = printResults.displayVideoProcessingProgress(processOptions) return continueProcessing(onProgress) } @@ -639,18 +644,21 @@ async function waitForTestsToFinishRunning (options: { project: Project, screens } if (videoExists && !skippedSpec && !videoCaptureFailed) { - const ffmpegChaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests) + const chaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests) try { debug('post processing recording') - await postProcessRecording( - videoName, - videoRecording.api.compressedVideoName, - videoCompression, + await postProcessRecording({ shouldUploadVideo, quiet, - ffmpegChaptersConfig, - ) + videoCompression, + processOptions: { + compressedVideoName: videoRecording.api.compressedVideoName, + videoName, + chaptersConfig, + ...(videoRecording.controller!.postProcessFfmpegOptions || {}), + }, + }) } catch (err) { warnVideoRecordingFailed(err) } diff --git a/packages/server/lib/util/print-run.ts b/packages/server/lib/util/print-run.ts index a3032156dc6d..36db19dea534 100644 --- a/packages/server/lib/util/print-run.ts +++ b/packages/server/lib/util/print-run.ts @@ -452,7 +452,7 @@ function displayScreenshots (screenshots: Screenshot[] = []) { console.log('') } -export function displayVideoProcessingProgress (opts: { name: string, videoCompression: number | false }) { +export function displayVideoProcessingProgress (opts: { videoName: string, videoCompression: number | false }) { console.log('') terminal.header('Video', { @@ -508,7 +508,7 @@ export function displayVideoProcessingProgress (opts: { name: string, videoCompr table.push([ gray('-'), gray('Finished processing:'), - `${formatPath(opts.name, getWidth(table, 2), 'cyan')}`, + `${formatPath(opts.videoName, getWidth(table, 2), 'cyan')}`, gray(dur), ]) diff --git a/packages/server/lib/video_capture.ts b/packages/server/lib/video_capture.ts index d5e30a484b97..616bae8981fd 100644 --- a/packages/server/lib/video_capture.ts +++ b/packages/server/lib/video_capture.ts @@ -7,7 +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' -import type { WriteVideoFrame } from '@packages/types' +import type { ProcessOptions, WriteVideoFrame } from '@packages/types' const debug = Debug('cypress:server:video') const debugVerbose = Debug('cypress-verbose:server:video') @@ -31,7 +31,7 @@ const deferredPromise = function () { export function generateFfmpegChaptersConfig (tests) { if (!tests) { - return null + return } const configString = tests.map((test) => { @@ -277,18 +277,14 @@ export function start (options: StartOptions) { }) } -// Progress callback called with percentage `0 <= p <= 1` of compression progress. -type OnProgress = (p: number) => void - -export async function process (name, cname, videoCompression, ffmpegchaptersConfig, onProgress: OnProgress = function () {}) { +export async function process (options: ProcessOptions) { let total = null - const metaFileName = `${name}.meta` - const addChaptersMeta = ffmpegchaptersConfig && await fs.writeFile(metaFileName, ffmpegchaptersConfig).then(() => true) + const metaFileName = `${options.videoName}.meta` + const addChaptersMeta = options.chaptersConfig && await fs.writeFile(metaFileName, options.chaptersConfig).then(() => true) - return new Bluebird((resolve, reject) => { - debug('processing video from %s to %s video compression %o', - name, cname, videoCompression) + return new Promise((resolve, reject) => { + debug('processing video %o', options) const command = ffmpeg({ priority: 20, @@ -324,12 +320,13 @@ export async function process (name, cname, videoCompression, ffmpegchaptersConf '-preset fast', // Compression Rate Factor is essentially the quality dial; 0 would be lossless // (big files), while 51 (the maximum) would lead to low quality (and small files). - `-crf ${videoCompression}`, + `-crf ${options.videoCompression}`, // Discussion of pixel formats is beyond the scope of these comments. See // https://en.wikipedia.org/wiki/Chroma_subsampling if you want the gritty details. // Short version: yuv420p is a standard video format supported everywhere. '-pix_fmt yuv420p', + ...(options.outputOptions || []), ] if (addChaptersMeta) { @@ -337,10 +334,13 @@ export async function process (name, cname, videoCompression, ffmpegchaptersConf outputOptions.push('-map_metadata 1') } - command.input(name) + let chain = command.input(options.videoName) .videoCodec('libx264') .outputOptions(outputOptions) - // .videoFilters("crop='floor(in_w/2)*2:floor(in_h/2)*2'") + + if (options.videoFilters) chain = chain.videoFilters(options.videoFilters) + + chain .on('start', (command) => { debug('compression started %o', { command }) }) @@ -366,7 +366,7 @@ export async function process (name, cname, videoCompression, ffmpegchaptersConf const percent = progressed / total if (percent < 1) { - return onProgress(percent) + return options.onProgress?.(percent) } }) .on('error', (err, stdout, stderr) => { @@ -378,10 +378,10 @@ export async function process (name, cname, videoCompression, ffmpegchaptersConf debug('compression ended') // we are done progressing - onProgress(1) + options.onProgress?.(1) // rename and obliterate the original - await fs.move(cname, name, { + await fs.move(options.compressedVideoName, options.videoName, { overwrite: true, }) @@ -390,6 +390,6 @@ export async function process (name, cname, videoCompression, ffmpegchaptersConf } resolve() - }).save(cname) + }).save(options.compressedVideoName) }) } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9be56583ac3b..e00a3d329c82 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -37,3 +37,5 @@ export * from './warning' export * from './modeOptions' export * from './git' + +export * from './video' diff --git a/packages/types/src/server.ts b/packages/types/src/server.ts index 603d7d014445..21cc280fccb4 100644 --- a/packages/types/src/server.ts +++ b/packages/types/src/server.ts @@ -1,53 +1,7 @@ import type { FoundBrowser } from './browser' import type { ReceivedCypressOptions } from './config' import type { PlatformName } from './platform' - -export type WriteVideoFrame = (data: Buffer) => void - -export type VideoRecording = { - api: RunModeVideoApi - controller?: BrowserVideoController -} - -/** - * Interface yielded by the browser to control video recording. - */ -export type BrowserVideoController = { - /** - * A function that resolves once the video is fully captured and flushed to disk. - */ - endVideoCapture: () => Promise - /** - * Timestamp of when the video capture started - used for chapter timestamps. - */ - startedVideoCapture: Date - /** - * Used in single-tab mode to restart the video capture to a new file without relaunching the browser. - */ - restart: () => Promise - writeVideoFrame: WriteVideoFrame -} - -/** - * Interface passed to the browser to enable capturing video. - */ -export type RunModeVideoApi = { - onError: (err: Error) => void - videoName: string - compressedVideoName?: string - /** - * Create+use a new VideoController that uses ffmpeg to stream frames from `writeVideoFrame` to disk. - */ - newFfmpegVideoController: (opts?: { webmInput?: boolean}) => Promise - /** - * Register a non-ffmpeg video controller. - */ - setVideoController: (videoController?: BrowserVideoController) => void - /** - * Registers a handler for project.on('capture:video:frames'). - */ - onProjectCaptureVideoFrames: (fn: (data: Buffer) => void) => void -} +import type { WriteVideoFrame, RunModeVideoApi } from './video' export type OpenProjectLaunchOpts = { projectRoot: string diff --git a/packages/types/src/video.ts b/packages/types/src/video.ts new file mode 100644 index 000000000000..703792f7e165 --- /dev/null +++ b/packages/types/src/video.ts @@ -0,0 +1,60 @@ +// Progress callback called with percentage `0 <= p <= 1` of compression progress. +type OnProgress = (p: number) => void + +export type ProcessOptions = { + videoName: string + compressedVideoName: string + videoCompression: number + chaptersConfig?: string + onProgress?: OnProgress + outputOptions?: string[] + videoFilters?: string +} + +export type WriteVideoFrame = (data: Buffer) => void + +export type VideoRecording = { + api: RunModeVideoApi + controller?: BrowserVideoController +} + +/** + * Interface yielded by the browser to control video recording. + */ +export type BrowserVideoController = { + /** + * A function that resolves once the video is fully captured and flushed to disk. + */ + endVideoCapture: () => Promise + /** + * Timestamp of when the video capture started - used for chapter timestamps. + */ + startedVideoCapture: Date + postProcessFfmpegOptions?: Partial + /** + * Used in single-tab mode to restart the video capture to a new file without relaunching the browser. + */ + restart: () => Promise + writeVideoFrame: WriteVideoFrame +} + +/** + * Interface passed to the browser to enable capturing video. + */ +export type RunModeVideoApi = { + onError: (err: Error) => void + videoName: string + compressedVideoName: string + /** + * Create+use a new VideoController that uses ffmpeg to stream frames from `writeVideoFrame` to disk. + */ + newFfmpegVideoController: (opts?: { webmInput?: boolean}) => Promise + /** + * Register a non-ffmpeg video controller. + */ + setVideoController: (videoController?: BrowserVideoController) => void + /** + * Registers a handler for project.on('capture:video:frames'). + */ + onProjectCaptureVideoFrames: (fn: (data: Buffer) => void) => void +} From 22ddb9485504a5e793d091876bf37ae608db7bea Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Fri, 2 Sep 2022 13:04:44 -0400 Subject: [PATCH 28/29] clean up wantsWrite logic --- packages/server/lib/video_capture.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/video_capture.ts b/packages/server/lib/video_capture.ts index 616bae8981fd..c673da2d237e 100644 --- a/packages/server/lib/video_capture.ts +++ b/packages/server/lib/video_capture.ts @@ -190,8 +190,10 @@ export function start (options: StartOptions) { debugFrames('writing video frame') if (wantsWrite) { - if (!(wantsWrite = pt.write(data))) { - return pt.once('drain', () => { + wantsWrite = pt.write(data) + if (!wantsWrite) { + // ffmpeg stream isn't accepting data, so drop frames until the stream is ready to accept data + pt.once('drain', () => { debugFrames('video stream drained') wantsWrite = true From 45e9b8f8e04b075f37966ef157f6d8b2f7020a2c Mon Sep 17 00:00:00 2001 From: Zach Bloomquist Date: Tue, 6 Sep 2022 11:56:22 -0400 Subject: [PATCH 29/29] Clean up setVideoController/newFfmpegVideoController naming --- packages/server/lib/browsers/chrome.ts | 2 +- packages/server/lib/browsers/electron.ts | 2 +- packages/server/lib/browsers/firefox.ts | 2 +- packages/server/lib/browsers/webkit-automation.ts | 2 +- packages/server/lib/modes/run.ts | 8 ++++---- packages/types/src/video.ts | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 1c1b912e262e..6fcb3c6e47ce 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -251,7 +251,7 @@ const _disableRestorePagesPrompt = function (userDir) { async function _recordVideo (cdpAutomation: CdpAutomation, videoOptions: RunModeVideoApi, browserMajorVersion: number) { const screencastOptions = browserMajorVersion >= CHROME_VERSION_WITH_FPS_INCREASE ? screencastOpts() : screencastOpts(1) - const { writeVideoFrame } = await videoOptions.newFfmpegVideoController() + const { writeVideoFrame } = await videoOptions.useFfmpegVideoController() await cdpAutomation.startVideoRecording(writeVideoFrame, screencastOptions) } diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index e5f4f9241b25..421e58297d0b 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -110,7 +110,7 @@ function _installExtensions (win: BrowserWindow, extensionPaths: string[], optio } async function recordVideo (cdpAutomation: CdpAutomation, videoApi: RunModeVideoApi) { - const { writeVideoFrame } = await videoApi.newFfmpegVideoController() + const { writeVideoFrame } = await videoApi.useFfmpegVideoController() await cdpAutomation.startVideoRecording(writeVideoFrame) } diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 030a37b0a6ff..d1fb393d7ea0 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -380,7 +380,7 @@ export function connectToExisting () { } async function recordVideo (videoApi: RunModeVideoApi) { - const { writeVideoFrame } = await videoApi.newFfmpegVideoController({ webmInput: true }) + const { writeVideoFrame } = await videoApi.useFfmpegVideoController({ webmInput: true }) videoApi.onProjectCaptureVideoFrames(writeVideoFrame) } diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index 575a81223c15..be20e2bbb10c 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -131,7 +131,7 @@ export class WebKitAutomation { private recordVideo (videoApi: RunModeVideoApi, startedVideoCapture: Date) { const _this = this - videoApi.setVideoController({ + videoApi.useVideoController({ async endVideoCapture () { const pwVideo = _this.page.video() diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 447b9d56db78..8d7843ecc1b3 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -270,7 +270,7 @@ async function startVideoRecording (options: { previous?: VideoRecording, projec onError, videoName, compressedVideoName, - async newFfmpegVideoController (ffmpegOpts) { + async useFfmpegVideoController (ffmpegOpts) { _ffmpegOpts = ffmpegOpts || _ffmpegOpts ffmpegController = await videoCapture.start({ ...videoRecording.api, ..._ffmpegOpts }) @@ -283,15 +283,15 @@ async function startVideoRecording (options: { previous?: VideoRecording, projec ffmpegController.writeVideoFrame(data) }, async restart () { - await videoRecording.api.newFfmpegVideoController(_ffmpegOpts) + await videoRecording.api.useFfmpegVideoController(_ffmpegOpts) }, } - videoRecording.api.setVideoController(controllerWrap) + videoRecording.api.useVideoController(controllerWrap) return controllerWrap }, - setVideoController (videoController) { + useVideoController (videoController) { debug('setting videoController for videoRecording %o', videoRecording) videoRecording.controller = videoController }, diff --git a/packages/types/src/video.ts b/packages/types/src/video.ts index 703792f7e165..7bc3fcf63688 100644 --- a/packages/types/src/video.ts +++ b/packages/types/src/video.ts @@ -48,11 +48,11 @@ export type RunModeVideoApi = { /** * Create+use a new VideoController that uses ffmpeg to stream frames from `writeVideoFrame` to disk. */ - newFfmpegVideoController: (opts?: { webmInput?: boolean}) => Promise + useFfmpegVideoController: (opts?: { webmInput?: boolean}) => Promise /** * Register a non-ffmpeg video controller. */ - setVideoController: (videoController?: BrowserVideoController) => void + useVideoController: (videoController?: BrowserVideoController) => void /** * Registers a handler for project.on('capture:video:frames'). */