diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 6fcb3c6e47ce..5bea2301576f 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -271,7 +271,7 @@ const _navigateUsingCRI = async function (client, url) { await client.send('Page.navigate', { url }) } -const _handleDownloads = async function (client, dir, automation) { +const _handleDownloads = async function (client, downloadsFolder: string, automation) { client.on('Page.downloadWillBegin', (data) => { const downloadItem = { id: data.guid, @@ -282,7 +282,7 @@ const _handleDownloads = async function (client, dir, automation) { if (filename) { // @ts-ignore - downloadItem.filePath = path.join(dir, data.suggestedFilename) + downloadItem.filePath = path.join(downloadsFolder, data.suggestedFilename) // @ts-ignore downloadItem.mime = mime.getType(data.suggestedFilename) } @@ -300,7 +300,7 @@ const _handleDownloads = async function (client, dir, automation) { await client.send('Page.setDownloadBehavior', { behavior: 'allow', - downloadPath: dir, + downloadPath: downloadsFolder, }) } diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index be20e2bbb10c..bc6b21e94726 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -5,6 +5,8 @@ import type { Automation } from '../automation' import { normalizeResourceType } from './cdp_automation' import os from 'os' import type { RunModeVideoApi } from '@packages/types' +import path from 'path' +import mime from 'mime' const debug = Debug('cypress:server:browsers:webkit-automation') @@ -84,6 +86,7 @@ const _cookieMatches = (cookie: any, filter: Record) => { let requestIdCounter = 1 const requestIdMap = new WeakMap() +let downloadIdCounter = 1 export class WebKitAutomation { private context!: playwright.BrowserContext @@ -92,20 +95,20 @@ 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, videoApi?: RunModeVideoApi) { + static async create (automation: Automation, browser: playwright.Browser, initialUrl: string, downloadsFolder: string, videoApi?: RunModeVideoApi) { const wkAutomation = new WebKitAutomation(automation, browser) - await wkAutomation.reset(initialUrl, videoApi) + await wkAutomation.reset({ downloadsFolder, newUrl: initialUrl, videoApi }) return wkAutomation } - public async reset (newUrl?: string, videoApi?: RunModeVideoApi) { - debug('resetting playwright page + context %o', { newUrl }) + public async reset (options: { downloadsFolder?: string, newUrl?: string, videoApi?: RunModeVideoApi }) { + debug('resetting playwright page + context %o', options) // new context comes with new cache + storage const newContext = await this.browser.newContext({ ignoreHTTPSErrors: true, - recordVideo: videoApi && { + recordVideo: options.videoApi && { dir: os.tmpdir(), size: { width: 1280, height: 720 }, }, @@ -116,14 +119,17 @@ export class WebKitAutomation { this.page = await newContext.newPage() this.context = this.page.context() - this.attachListeners(this.page) - if (videoApi) this.recordVideo(videoApi, contextStarted) + this.handleRequestEvents() + + if (options.downloadsFolder) this.handleDownloadEvents(options.downloadsFolder) + + if (options.videoApi) this.recordVideo(options.videoApi, contextStarted) let promises: Promise[] = [] if (oldPwPage) promises.push(oldPwPage.context().close()) - if (newUrl) promises.push(this.page.goto(newUrl)) + if (options.newUrl) promises.push(this.page.goto(options.newUrl)) if (promises.length) await Promise.all(promises) } @@ -162,9 +168,30 @@ export class WebKitAutomation { }) } - private attachListeners (page: playwright.Page) { + private handleDownloadEvents (downloadsFolder: string) { + this.page.on('download', async (download) => { + const id = downloadIdCounter++ + const suggestedFilename = download.suggestedFilename() + const filePath = path.join(downloadsFolder, suggestedFilename) + + this.automation.push('create:download', { + id, + url: download.url(), + filePath, + mime: mime.getType(suggestedFilename), + }) + + // NOTE: WebKit does have a `downloadsPath` option, but it is trashed after each run + // Cypress trashes before runs - so we have to use `.saveAs` to move it + await download.saveAs(filePath) + + this.automation.push('complete:download', { id }) + }) + } + + private handleRequestEvents () { // emit preRequest to proxy - page.on('request', (request) => { + this.page.on('request', (request) => { // ignore socket.io events // TODO: use config.socketIoRoute here instead if (request.url().includes('/__socket') || request.url().includes('/__cypress')) return @@ -188,7 +215,7 @@ export class WebKitAutomation { this.automation.onBrowserPreRequest?.(browserPreRequest) }) - page.on('requestfinished', async (request) => { + this.page.on('requestfinished', async (request) => { const requestId = requestIdMap.get(request) if (!requestId) return @@ -274,9 +301,11 @@ export class WebKitAutomation { case 'focus:browser:window': return await this.context.pages[0]?.bringToFront() case 'reset:browser:state': + debug('stubbed reset:browser:state') + return case 'reset:browser:tabs:for:next:test': - if (data.shouldKeepTabOpen) return await this.reset() + if (data.shouldKeepTabOpen) return await this.reset({}) return await this.context.browser()?.close() default: diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index d40840d654c6..ac359b40a44c 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -5,6 +5,7 @@ import type { Browser, BrowserInstance } from './types' import type { Automation } from '../automation' import { WebKitAutomation } from './webkit-automation' import type { BrowserLaunchOpts, BrowserNewTabOpts } from '@packages/types' +import utils from './utils' const debug = Debug('cypress:server:browsers:webkit') @@ -16,7 +17,11 @@ export async function connectToNewSpec (browser: Browser, options: BrowserNewTab automation.use(wkAutomation) wkAutomation.automation = automation await options.onInitializeNewBrowserTab() - await wkAutomation.reset(options.url, options.videoApi) + await wkAutomation.reset({ + newUrl: options.url, + downloadsFolder: options.downloadsFolder, + videoApi: options.videoApi, + }) } export function connectToExisting () { @@ -26,22 +31,47 @@ export function connectToExisting () { 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 + const pw = await import(pwModulePath) as typeof playwright - const pwBrowser = await pw.webkit.launch({ - proxy: { - server: options.proxyServer, + const defaultLaunchOptions = { + preferences: { + proxy: { + server: options.proxyServer, + }, + headless: browser.isHeadless, }, - downloadsPath: options.downloadsFolder, - headless: browser.isHeadless, - }) + extensions: [], + args: [], + } + + const launchOptions = await utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options) + + if (launchOptions.extensions.length) options.onWarning?.(new Error('WebExtensions not supported in WebKit, but extensions were passed in before:browser:launch.')) + + launchOptions.preferences.args = [...launchOptions.args, ...(launchOptions.preferences.args || [])] + + const pwServer = await pw.webkit.launchServer(launchOptions.preferences) + + /** + * Playwright adds an `exit` event listener to run a cleanup process. It tries to use the current binary to run a Node script by passing it as argv[1]. + * However, the Electron binary does not support an entrypoint, leading Cypress to think it's being opened in global mode (no args) when this fn is called. + * Solution is to filter out the problematic function. + * TODO(webkit): do we want to run this cleanup script another way? + * @see https://github.com/microsoft/playwright/blob/7e2aec7454f596af452b51a2866e86370291ac8b/packages/playwright-core/src/utils/processLauncher.ts#L191-L203 + */ + const killProcessAndCleanup = process.rawListeners('exit').find((fn) => fn.name === 'killProcessAndCleanup') + + // @ts-expect-error Electron's Process types override those of @types/node, leading to `exit` not being recognized as an event + if (killProcessAndCleanup) process.removeListener('exit', killProcessAndCleanup) + else debug('did not find killProcessAndCleanup, which may cause interactive mode to unexpectedly open') + + const pwBrowser = await pw.webkit.connect(pwServer.wsEndpoint()) - wkAutomation = await WebKitAutomation.create(automation, pwBrowser, url, options.videoApi) + wkAutomation = await WebKitAutomation.create(automation, pwBrowser, url, options.downloadsFolder, options.videoApi) automation.use(wkAutomation) class WkInstance extends EventEmitter implements BrowserInstance { - // TODO: how to obtain launched process PID from PW? this is used for process_profiler - pid = NaN + pid = pwServer.process().pid constructor () { super() diff --git a/packages/server/lib/cypress.js b/packages/server/lib/cypress.js index c529565d0f56..95ba5e7a2e41 100644 --- a/packages/server/lib/cypress.js +++ b/packages/server/lib/cypress.js @@ -248,9 +248,6 @@ module.exports = { case 'interactive': return this.runElectron(mode, options) - case 'openProject': - throw new Error('Unused') - default: throw new Error(`Cannot start. Invalid mode: '${mode}'`) } diff --git a/packages/server/package.json b/packages/server/package.json index 3294a546cd70..ad7c0456671a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -151,6 +151,7 @@ "@types/chrome": "0.0.101", "@types/chrome-remote-interface": "0.31.4", "@types/http-proxy": "1.17.4", + "@types/mime": "3.0.1", "@types/node": "14.14.31", "babel-loader": "8.1.0", "chai-as-promised": "7.1.1", diff --git a/system-tests/test/deprecated_spec.ts b/system-tests/test/deprecated_spec.ts index 3402ca796df1..36ec1b5aabf9 100644 --- a/system-tests/test/deprecated_spec.ts +++ b/system-tests/test/deprecated_spec.ts @@ -46,11 +46,11 @@ 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 // whatever args we use to launch the browser + browser: '!webkit', // throws in WebKit since it rejects unsupported arguments config: { video: false, env: { @@ -64,11 +64,11 @@ 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 // whatever args we use to launch the browser + browser: '!webkit', // throws in WebKit since it rejects unsupported arguments config: { video: false, env: { diff --git a/yarn.lock b/yarn.lock index d6de58179fa6..10546d39d8b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6648,6 +6648,11 @@ resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73" integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM= +"@types/mime@3.0.1": + version "3.0.1" + resolved "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" + integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"