diff --git a/packages/launcher/index.ts b/packages/launcher/index.ts index 2a98e6ff42f..bd18c4fefac 100644 --- a/packages/launcher/index.ts +++ b/packages/launcher/index.ts @@ -1,6 +1,6 @@ import { detect, detectByPath } from './lib/detect' -import { launch } from './lib/browsers' +import { launch } from './lib/launch' export { detect, diff --git a/packages/launcher/lib/browsers.ts b/packages/launcher/lib/browsers.ts deleted file mode 100644 index 3a53865e3ae..00000000000 --- a/packages/launcher/lib/browsers.ts +++ /dev/null @@ -1,54 +0,0 @@ -import Debug from 'debug' -import type * as cp from 'child_process' -import { utils } from './utils' -import type { FoundBrowser } from '@packages/types' -import type { Readable } from 'stream' - -export const debug = Debug('cypress:launcher:browsers') - -/** starts a found browser and opens URL if given one */ -export type LaunchedBrowser = cp.ChildProcessByStdio - -// NOTE: For Firefox, geckodriver is used to launch the browser -export function launch ( - browser: FoundBrowser, - url: string, - debuggingPort: number, - args: string[] = [], - browserEnv = {}, -) { - debug('launching browser %o', { browser, url }) - - if (!browser.path) { - throw new Error(`Browser ${browser.name} is missing path`) - } - - if (url) { - args = [url].concat(args) - } - - const spawnOpts: cp.SpawnOptionsWithStdioTuple = { - stdio: ['ignore', 'pipe', 'pipe'], - // allow setting default env vars - // but only if it's not already set by the environment - env: { ...browserEnv, ...process.env }, - } - - debug('spawning browser with opts %o', { browser, url, spawnOpts }) - - const proc = utils.spawnWithArch(browser.path, args, spawnOpts) - - proc.stdout.on('data', (buf) => { - debug('%s stdout: %s', browser.name, String(buf).trim()) - }) - - proc.stderr.on('data', (buf) => { - debug('%s stderr: %s', browser.name, String(buf).trim()) - }) - - proc.on('exit', (code, signal) => { - debug('%s exited: %o', browser.name, { code, signal }) - }) - - return proc -} diff --git a/packages/launcher/lib/darwin/index.ts b/packages/launcher/lib/darwinHelpers/index.ts similarity index 100% rename from packages/launcher/lib/darwin/index.ts rename to packages/launcher/lib/darwinHelpers/index.ts diff --git a/packages/launcher/lib/darwin/util.ts b/packages/launcher/lib/darwinHelpers/util.ts similarity index 100% rename from packages/launcher/lib/darwin/util.ts rename to packages/launcher/lib/darwinHelpers/util.ts diff --git a/packages/launcher/lib/detect.ts b/packages/launcher/lib/detect.ts index cba982d644f..734fd80b3d1 100644 --- a/packages/launcher/lib/detect.ts +++ b/packages/launcher/lib/detect.ts @@ -3,7 +3,7 @@ import _, { compact, extend, find } from 'lodash' import os from 'os' import { removeDuplicateBrowsers } from '@packages/data-context/src/sources/BrowserDataSource' import { knownBrowsers } from './known-browsers' -import * as darwinHelper from './darwin' +import * as darwinHelper from './darwinHelpers' import { notDetectedAtPathErr } from './errors' import * as linuxHelper from './linux' import Debug from 'debug' diff --git a/packages/launcher/lib/launch.ts b/packages/launcher/lib/launch.ts new file mode 100644 index 00000000000..3d4c33db97b --- /dev/null +++ b/packages/launcher/lib/launch.ts @@ -0,0 +1,28 @@ +import Debug from 'debug' +import type * as cp from 'child_process' +import type { FoundBrowser } from '@packages/types' +import type { Readable } from 'stream' +import { PlatformFactory } from './platforms/PlatformFactory' + +export const debug = Debug('cypress:launcher:browsers') + +/** starts a found browser and opens URL if given one */ +export type LaunchedBrowser = cp.ChildProcessByStdio + +// NOTE: For Firefox, geckodriver is used to launch the browser +export function launch ( + browser: FoundBrowser, + url: string, + args: string[] = [], + browserEnv = {}, +) { + debug('launching browser %o', { browser, url }) + + // We shouldn't need to check this, because FoundBrowser.path is + // not optional. + if (!browser.path) { + throw new Error(`Browser ${browser.name} is missing path`) + } + + return PlatformFactory.select().launch(browser, url, args, browserEnv) +} diff --git a/packages/launcher/lib/platforms/Darwin.ts b/packages/launcher/lib/platforms/Darwin.ts new file mode 100644 index 00000000000..0e14f7a020d --- /dev/null +++ b/packages/launcher/lib/platforms/Darwin.ts @@ -0,0 +1,29 @@ +// this file is named XDarwin because intellisense gets confused with '../darwin/' +import { ChildProcess, spawn } from 'child_process' +import { Platform } from './Platform' +import type { FoundBrowser } from '@packages/types' +import os from 'os' + +export class Darwin extends Platform { + launch (browser: FoundBrowser, url: string, args: string[], env: Record = {}): ChildProcess { + if (os.arch() === 'arm64') { + const proc = spawn( + 'arch', + [browser.path, url, ...args], { + ...Platform.defaultSpawnOpts, + env: { + ARCHPREFERENCE: 'arm64,x86_64', + ...Platform.defaultSpawnOpts.env, + ...env, + }, + }, + ) + + this.addDebugListeners(proc, browser) + + return proc + } + + return super.launch(browser, url, args, env) + } +} diff --git a/packages/launcher/lib/platforms/Linux.ts b/packages/launcher/lib/platforms/Linux.ts new file mode 100644 index 00000000000..c476837199e --- /dev/null +++ b/packages/launcher/lib/platforms/Linux.ts @@ -0,0 +1,4 @@ +import { Platform } from './Platform' + +export class Linux extends Platform { +} diff --git a/packages/launcher/lib/platforms/Platform.ts b/packages/launcher/lib/platforms/Platform.ts new file mode 100644 index 00000000000..3e0bb77907c --- /dev/null +++ b/packages/launcher/lib/platforms/Platform.ts @@ -0,0 +1,46 @@ +import type { FoundBrowser } from '@packages/types' +import { ChildProcess, spawn, SpawnOptions } from 'child_process' +import Debug from 'debug' + +export const debug = Debug('cypress:launcher:browsers') + +export abstract class Platform { + launch (browser: FoundBrowser, url: string, args: string[], env: Record = {}): ChildProcess { + debug('launching browser %o', { browser, url, args, env }) + + const proc = spawn(browser.path, [url, ...args], { + ...Platform.defaultSpawnOpts, + env: { + ...Platform.defaultSpawnOpts.env, + ...env, + }, + }) + + this.addDebugListeners(proc, browser) + + return proc + } + + protected addDebugListeners (proc: ChildProcess, browser: FoundBrowser) { + proc.stdout?.on('data', (buf) => { + debug('%s stdout: %s', browser.name, String(buf).trim()) + }) + + proc.stderr?.on('data', (buf) => { + debug('%s stderr: %s', browser.name, String(buf).trim()) + }) + + proc.on('exit', (code, signal) => { + debug('%s exited: %o', browser.name, { code, signal }) + }) + } + + static get defaultSpawnOpts (): SpawnOptions { + return { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + }, + } + } +} diff --git a/packages/launcher/lib/platforms/PlatformFactory.ts b/packages/launcher/lib/platforms/PlatformFactory.ts new file mode 100644 index 00000000000..489717005b5 --- /dev/null +++ b/packages/launcher/lib/platforms/PlatformFactory.ts @@ -0,0 +1,20 @@ +import os from 'os' +import type { Platform } from './Platform' +import { Darwin } from './Darwin' +import { Linux } from './Linux' +import { Windows } from './Windows' + +export class PlatformFactory { + static select (): Platform { + switch (os.platform()) { + case 'darwin': + return new Darwin() + case 'linux': + return new Linux() + case 'win32': + return new Windows() + default: + throw new Error(`Unsupported platform: ${os.platform()} ${os.arch()}`) + } + } +} diff --git a/packages/launcher/lib/platforms/Windows.ts b/packages/launcher/lib/platforms/Windows.ts new file mode 100644 index 00000000000..34e3511a413 --- /dev/null +++ b/packages/launcher/lib/platforms/Windows.ts @@ -0,0 +1,4 @@ +import { Platform } from './Platform' + +export class Windows extends Platform { +} diff --git a/packages/launcher/test/unit/darwin.spec.ts b/packages/launcher/test/unit/darwin.spec.ts index 71d1467cd60..e117343f797 100644 --- a/packages/launcher/test/unit/darwin.spec.ts +++ b/packages/launcher/test/unit/darwin.spec.ts @@ -4,9 +4,9 @@ import cp from 'child_process' import fs from 'fs-extra' import { PassThrough } from 'stream' import { FoundBrowser } from '@packages/types' -import * as darwinHelper from '../../lib/darwin' +import * as darwinHelper from '../../lib/darwinHelpers' import * as linuxHelper from '../../lib/linux' -import * as darwinUtil from '../../lib/darwin/util' +import * as darwinUtil from '../../lib/darwinHelpers/util' import { launch } from '../../lib/browsers' import { knownBrowsers } from '../../lib/known-browsers' diff --git a/packages/launcher/test/unit/detect.spec.ts b/packages/launcher/test/unit/detect.spec.ts index 8ef8f44714c..bef8e77628c 100644 --- a/packages/launcher/test/unit/detect.spec.ts +++ b/packages/launcher/test/unit/detect.spec.ts @@ -7,7 +7,7 @@ import { goalBrowsers } from '../fixtures' import os from 'os' import { log } from '../log' import { detect as linuxDetect } from '../../lib/linux' -import { detect as darwinDetect } from '../../lib/darwin' +import { detect as darwinDetect } from '../../lib/darwinHelpers' import { detect as windowsDetect } from '../../lib/windows' import type { Browser } from '@packages/types' @@ -33,7 +33,7 @@ vi.mock('../../lib/linux', async (importActual) => { } }) -vi.mock('../../lib/darwin', async (importActual) => { +vi.mock('../../lib/darwinHelpers', async (importActual) => { const actual = await importActual() return { @@ -63,7 +63,7 @@ describe('detect', () => { vi.resetAllMocks() const { detect: linuxDetectActual } = await vi.importActual('../../lib/linux') - const { detect: darwinDetectActual } = await vi.importActual('../../lib/darwin') + const { detect: darwinDetectActual } = await vi.importActual('../../lib/darwinHelpers') const { detect: windowsDetectActual } = await vi.importActual('../../lib/windows') vi.mocked(linuxDetect).mockImplementation(linuxDetectActual) diff --git a/packages/launcher/test/unit/launch.spec.ts b/packages/launcher/test/unit/launch.spec.ts new file mode 100644 index 00000000000..938ee6ee4f7 --- /dev/null +++ b/packages/launcher/test/unit/launch.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, Mocked } from 'vitest' +import type { FoundBrowser } from '@packages/types' +import { launch } from '../../lib/launch' +import os from 'os' +import { spawn, ChildProcess } from 'child_process' +import EventEmitter from 'events' + +vi.mock('os', async (importActual) => { + const actual: typeof os = await importActual() + + return { + default: { + ...actual, + platform: vi.fn(), + arch: vi.fn(), + }, + } +}) + +vi.mock('child_process', async (importActual) => { + const actual = await importActual() + + return { + // @ts-expect-error + ...actual, + spawn: vi.fn(), + } +}) + +describe('launch', () => { + let browser: FoundBrowser + let url: string + let args: string[] + let browserEnv: Record + let launchedBrowser: Mocked + + let arch: ReturnType + let platform: ReturnType + + beforeEach(() => { + browser = { + name: 'chrome', + version: '100.0.0', + path: 'chrome', + family: 'chromium', + channel: 'stable', + displayName: 'Chrome', + } + + url = 'https://www.somedomain.test' + args = ['--headless'] + browserEnv = {} + + launchedBrowser = { + on: vi.fn() as any, + // these are streams, but we don't need to test + // stream logic - they do need to implement event + // emission though, because of addDebugListeners + // @ts-expect-error + stdout: new EventEmitter(), + // @ts-expect-error + stderr: new EventEmitter(), + kill: vi.fn(), + } + + vi.mocked(os.arch).mockImplementation(() => arch) + vi.mocked(os.platform).mockImplementation(() => platform) + + vi.mocked(spawn).mockReturnValue(launchedBrowser) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('throws when browser.path is missing', () => { + browser.path = undefined + + expect(() => launch(browser, url, args, browserEnv)).toThrow('Browser chrome is missing path') + }) + + describe('when darwin arm64', () => { + beforeEach(() => { + arch = 'arm64' + platform = 'darwin' + }) + + it('launches a browser', () => { + const proc = launch(browser, url, args, browserEnv) + + expect(spawn).toHaveBeenCalledWith( + 'arch', + [browser.path, url, ...args], + expect.objectContaining({ + stdio: ['ignore', 'pipe', 'pipe'], + env: expect.objectContaining({ + ...browserEnv, + ARCHPREFERENCE: 'arm64,x86_64', + }), + }), + ) + + expect(proc).toBe(launchedBrowser) + }) + }) + + for (const [testArch, testPlatform] of [ + ['x64', 'darwin'], + ['x64', 'linux'], + ['arm64', 'linux'], + ['x64', 'win32'], + ['arm64', 'win32'], + ]) { + describe(`when ${testPlatform} ${testArch}`, () => { + beforeEach(() => { + arch = testArch as typeof arch + platform = testPlatform as typeof platform + }) + + it('launches a browser', () => { + const proc = launch(browser, url, args, browserEnv) + + expect(spawn).toHaveBeenCalledWith( + browser.path, + [url, ...args], + expect.objectContaining({ + stdio: ['ignore', 'pipe', 'pipe'], + env: expect.any(Object), + }), + ) + + expect(proc).toBe(launchedBrowser) + }) + }) + } +}) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index fcecf12596f..ce02145096f 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -634,7 +634,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, launchOptions.env) as unknown as BrowserInstance & { browserCriClient: BrowserCriClient } + const launchedBrowser = await launch(browser, 'about:blank', args, launchOptions.env) as unknown as BrowserInstance & { browserCriClient: BrowserCriClient } la(launchedBrowser, 'did not get launched browser instance') diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index a3c665c6845..1fb4398b8b5 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -5,7 +5,7 @@ import Debug from 'debug' import getPort from 'get-port' import path from 'path' import urlUtil from 'url' -import { debug as launcherDebug } from '@packages/launcher/lib/browsers' +import { debug as launcherDebug } from '@packages/launcher/lib/launch' import { doubleEscape } from '@packages/launcher/lib/windows' import FirefoxProfile from 'firefox-profile' import * as errors from '../errors' diff --git a/packages/types/src/browser.ts b/packages/types/src/browser.ts index 7f73d5b1c01..7dcd40dd256 100644 --- a/packages/types/src/browser.ts +++ b/packages/types/src/browser.ts @@ -50,7 +50,7 @@ export type Browser = { /** * Represents a real browser that exists on the user's system. */ -export type FoundBrowser = Omit & { +export interface FoundBrowser extends Omit { path: string version: string majorVersion?: string | null