From f824afe4422d02db58e2ad289eaabc2442178ccf Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Sun, 22 Sep 2024 02:02:52 +0900 Subject: [PATCH 01/13] Update tsconfig to use bundler mode for moduleResolution --- package.json | 3 ++- src/cli.ts | 8 ++++---- src/marp-cli.ts | 6 ++---- src/utils/yargs.ts | 13 +++++++++++++ src/watcher.ts | 6 +++--- test/converter.ts | 7 ++++--- test/templates/watch.ts | 8 ++++---- tsconfig.json | 17 ++++++----------- yarn.lock | 13 +++++++++---- 9 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 src/utils/yargs.ts diff --git a/package.json b/package.json index f0411fd8..6a509308 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-url": "^8.0.2", - "@tsconfig/node14": "^14.1.2", + "@tsconfig/node20": "^20.1.4", + "@tsconfig/recommended": "^1.0.7", "@types/cheerio": "^0.22.35", "@types/dom-view-transitions": "^1.0.5", "@types/express": "^4.17.21", diff --git a/src/cli.ts b/src/cli.ts index 6cb3cd3b..17de59c9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import chalk, { supportsColorStderr } from 'chalk' import stripAnsi from 'strip-ansi' import wrapAnsi from 'wrap-ansi' -import { terminalWidth } from 'yargs' +import { terminalWidth } from './utils/yargs' const { has16m, has256 } = { ...supportsColorStderr } // Workaround for type error @@ -9,15 +9,15 @@ interface CLIOption { singleLine?: boolean } -const width: number = terminalWidth() || 80 - const messageBlock = ( kind: string, message: string, opts: CLIOption ): string => { const indent = stripAnsi(kind).length + 1 - const target = opts.singleLine ? message : wrapAnsi(message, width - indent) + const target = opts.singleLine + ? message + : wrapAnsi(message, terminalWidth() - indent) return `${kind} ${target.split('\n').join(`\n${' '.repeat(indent)}`)}` } diff --git a/src/marp-cli.ts b/src/marp-cli.ts index 49839ae7..d21f471b 100644 --- a/src/marp-cli.ts +++ b/src/marp-cli.ts @@ -1,6 +1,4 @@ import chalk from 'chalk' -import { Argv } from 'yargs' -import yargs from 'yargs/yargs' import * as cli from './cli' import fromArguments from './config' import { Converter, ConvertedCallback, ConvertType } from './converter' @@ -13,6 +11,7 @@ import { isOfficialDockerImage } from './utils/container' import { resetExecutablePath } from './utils/puppeteer' import version from './version' import watcher, { Watcher, notifier } from './watcher' +import { createYargs } from './utils/yargs' enum OptionGroup { Basic = 'Basic Options:', @@ -51,8 +50,7 @@ export const marpCli = async ( let watcherInstance: Watcher | undefined try { - const base: Argv = yargs(argv) - const program = base + const program = createYargs(argv) .parserConfiguration({ 'dot-notation': false }) .usage(usage) .help(false) diff --git a/src/utils/yargs.ts b/src/utils/yargs.ts new file mode 100644 index 00000000..a8d9bc6f --- /dev/null +++ b/src/utils/yargs.ts @@ -0,0 +1,13 @@ +import yargs from 'yargs/yargs' +import type { Argv } from 'yargs' + +let currentYargsRef: WeakRef | null = null + +export const createYargs = (...opts: Parameters) => { + const currentYargs = yargs(...opts) + currentYargsRef = new WeakRef(currentYargs) + return currentYargs +} + +export const terminalWidth = () => + currentYargsRef?.deref()?.terminalWidth() ?? 80 diff --git a/src/watcher.ts b/src/watcher.ts index f5d82add..21ab7ecb 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -3,7 +3,7 @@ import crypto from 'node:crypto' import path from 'node:path' import chokidar, { type FSWatcher } from 'chokidar' import { getPortPromise } from 'portfinder' -import { Server as WSServer, ServerOptions } from 'ws' +import WS, { type ServerOptions } from 'ws' import { Converter, ConvertedCallback } from './converter' import { isError } from './error' import { File, FileType } from './file' @@ -83,7 +83,7 @@ export class Watcher { export class WatchNotifier { listeners = new Map>() - private wss?: WSServer + private wss?: WS.Server private portNumber?: number async port() { @@ -113,7 +113,7 @@ export class WatchNotifier { } async start(opts: ServerOptions = {}) { - this.wss = new WSServer({ ...opts, port: await this.port() }) + this.wss = new WS.Server({ ...opts, port: await this.port() }) this.wss.on('connection', (ws, sock) => { if (sock.url) { const [, identifier] = sock.url.split('/') diff --git a/test/converter.ts b/test/converter.ts index 7722b848..70bff673 100644 --- a/test/converter.ts +++ b/test/converter.ts @@ -9,7 +9,6 @@ import { load } from 'cheerio' import { imageSize } from 'image-size' import { PDFDocument, PDFDict, PDFName, PDFHexString, PDFNumber } from 'pdf-lib' import { TimeoutError } from 'puppeteer-core' -import { CdpPage as Page } from 'puppeteer-core/lib/cjs/puppeteer/cdp/Page' import yauzl from 'yauzl' import { Converter, ConvertType, ConverterOption } from '../src/converter' import { CLIError } from '../src/error' @@ -18,6 +17,8 @@ import { bare as bareTpl } from '../src/templates' import { ThemeSet } from '../src/theme' import { WatchNotifier } from '../src/watcher' +const { CdpPage } = require('puppeteer-core/lib/cjs/puppeteer/cdp/Page') + const puppeteerTimeoutMs = 60000 let mkdirSpy: jest.SpiedFunction @@ -969,7 +970,7 @@ describe('Converter', () => { it( 'converts markdown file into PPTX', async () => { - const setViewport = jest.spyOn(Page.prototype, 'setViewport') + const setViewport = jest.spyOn(CdpPage.prototype, 'setViewport') await converter().convertFile(new File(onePath)) expect(write).toHaveBeenCalled() @@ -996,7 +997,7 @@ describe('Converter', () => { it( 'assigns meta info thorugh PptxGenJs', async () => { - const setViewport = jest.spyOn(Page.prototype, 'setViewport') + const setViewport = jest.spyOn(CdpPage.prototype, 'setViewport') await converter({ imageScale: 1, diff --git a/test/templates/watch.ts b/test/templates/watch.ts index e52d67df..fa547411 100644 --- a/test/templates/watch.ts +++ b/test/templates/watch.ts @@ -3,7 +3,7 @@ * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} */ import { getPortPromise } from 'portfinder' -import { Server } from 'ws' +import ws from 'ws' import watch from '../../src/templates/watch/watch' beforeEach(() => { @@ -12,16 +12,16 @@ beforeEach(() => { }) describe('Watch mode notifier on browser context', () => { - let server: Server + let server: ws.Server let infoSpy: jest.SpyInstance let warnSpy: jest.SpyInstance const createWSServer = async () => { const port = await getPortPromise({ port: 37717 }) - return new Promise((res, rej) => { + return new Promise((res, rej) => { try { - const createdServer = new Server({ port }, () => res(createdServer)) + const createdServer = new ws.Server({ port }, () => res(createdServer)) } catch (e) { rej(e) } diff --git a/tsconfig.json b/tsconfig.json index 4ef04192..32bc8be9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,15 @@ { - "extends": "@tsconfig/node14/tsconfig.json", + "extends": [ + "@tsconfig/recommended/tsconfig.json", + "@tsconfig/node20/tsconfig.json" + ], "compilerOptions": { "jsx": "react", - "lib": [ - "es2019", - "es2020.promise", - "es2020.bigint", - "es2020.string", - "dom" - ], "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "noImplicitAny": false, "resolveJsonModule": true, - "sourceMap": true, - "target": "es2019" + "sourceMap": true }, "include": ["src"], "exclude": ["**/__mocks__"] diff --git a/yarn.lock b/yarn.lock index 474b2e0d..273f0e1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1642,10 +1642,15 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== -"@tsconfig/node14@^14.1.2": - version "14.1.2" - resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-14.1.2.tgz#ed84879e927a2f12ae8bb020baa990bd4cc3dabb" - integrity sha512-1vncsbfCZ3TBLPxesRYz02Rn7SNJfbLoDVkcZ7F/ixOV6nwxwgdhD1mdPcc5YQ413qBJ8CvMxXMFfJ7oawjo7Q== +"@tsconfig/node20@^20.1.4": + version "20.1.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.4.tgz#3457d42eddf12d3bde3976186ab0cd22b85df928" + integrity sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg== + +"@tsconfig/recommended@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@tsconfig/recommended/-/recommended-1.0.7.tgz#fdd95fc2c8d643c8b4a8ca45fd68eea248512407" + integrity sha512-xiNMgCuoy4mCL4JTywk9XFs5xpRUcKxtWEcMR6FNMtsgewYTIgIR+nvlP4A4iRCAzRsHMnPhvTRrzp4AGcRTEA== "@types/babel__core@^7.1.14": version "7.20.5" From d07ee910d51d9eb136af3aec4b55bddf90b2de59 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Sun, 22 Sep 2024 07:58:05 +0900 Subject: [PATCH 02/13] Introduce new browser finder, including custom order and Firefox support --- package.json | 1 + src/browser/browser.ts | 17 ++++ src/browser/browsers/chrome-cdp.ts | 6 ++ src/browser/browsers/chrome.ts | 6 ++ src/browser/browsers/firefox.ts | 6 ++ src/browser/finder.ts | 104 +++++++++++++++++++++++++ src/browser/finders/chrome.ts | 58 ++++++++++++++ src/browser/finders/edge.ts | 95 +++++++++++++++++++++++ src/browser/finders/firefox.ts | 119 ++++++++++++++++++++++++++++ src/browser/finders/utils.ts | 120 +++++++++++++++++++++++++++++ src/browser/manager.ts | 35 +++++++++ src/error.ts | 19 +++-- src/marp-cli.ts | 2 +- src/utils/debug.ts | 5 ++ src/utils/yargs.ts | 2 +- test/converter.ts | 2 +- yarn.lock | 2 +- 17 files changed, 587 insertions(+), 12 deletions(-) create mode 100644 src/browser/browser.ts create mode 100644 src/browser/browsers/chrome-cdp.ts create mode 100644 src/browser/browsers/chrome.ts create mode 100644 src/browser/browsers/firefox.ts create mode 100644 src/browser/finder.ts create mode 100644 src/browser/finders/chrome.ts create mode 100644 src/browser/finders/edge.ts create mode 100644 src/browser/finders/firefox.ts create mode 100644 src/browser/finders/utils.ts create mode 100644 src/browser/manager.ts create mode 100644 src/utils/debug.ts diff --git a/package.json b/package.json index 6a509308..b5a91c33 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "chrome-launcher": "^1.1.2", "css.escape": "^1.5.1", "cssnano": "^7.0.6", + "debug": "^4.3.7", "eslint": "^9.10.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-exports": "^1.0.0-beta.5", diff --git a/src/browser/browser.ts b/src/browser/browser.ts new file mode 100644 index 00000000..036f8188 --- /dev/null +++ b/src/browser/browser.ts @@ -0,0 +1,17 @@ +export type BrowserKind = 'chrome' | 'firefox' +export type BrowserProtocol = 'webdriver-bidi' | 'cdp' +export type BrowserPurpose = 'convert' | 'preview' + +export interface BrowserOptions { + purpose: BrowserPurpose +} + +export abstract class Browser { + abstract kind: BrowserKind + abstract protocol: BrowserProtocol + purpose: BrowserPurpose + + constructor(opts: BrowserOptions) { + this.purpose = opts.purpose + } +} diff --git a/src/browser/browsers/chrome-cdp.ts b/src/browser/browsers/chrome-cdp.ts new file mode 100644 index 00000000..95d5440c --- /dev/null +++ b/src/browser/browsers/chrome-cdp.ts @@ -0,0 +1,6 @@ +import { Browser } from '../browser' + +export class ChromeCdpBrowser extends Browser { + kind = 'chrome' as const + protocol = 'cdp' as const +} diff --git a/src/browser/browsers/chrome.ts b/src/browser/browsers/chrome.ts new file mode 100644 index 00000000..b274b92a --- /dev/null +++ b/src/browser/browsers/chrome.ts @@ -0,0 +1,6 @@ +import { Browser } from '../browser' + +export class ChromeBrowser extends Browser { + kind = 'chrome' as const + protocol = 'webdriver-bidi' as const +} diff --git a/src/browser/browsers/firefox.ts b/src/browser/browsers/firefox.ts new file mode 100644 index 00000000..bc2424ff --- /dev/null +++ b/src/browser/browsers/firefox.ts @@ -0,0 +1,6 @@ +import { Browser } from '../browser' + +export class FirefoxBrowser extends Browser { + kind = 'firefox' as const + protocol = 'webdriver-bidi' as const +} diff --git a/src/browser/finder.ts b/src/browser/finder.ts new file mode 100644 index 00000000..c8c28f80 --- /dev/null +++ b/src/browser/finder.ts @@ -0,0 +1,104 @@ +import { CLIError, CLIErrorCode } from '../error' +import { debugBrowserFinder } from '../utils/debug' +import type { Browser } from './browser' +import { chromeFinder as chrome } from './finders/chrome' +import { edgeFinder as edge } from './finders/edge' +import { firefoxFinder as firefox } from './finders/firefox' +import { isExecutable, normalizeDarwinAppPath } from './finders/utils' + +export interface BrowserFinderResult { + path: string + acceptedBrowsers: (typeof Browser)[] +} + +export interface BrowserFinderOptions { + preferredPath?: string +} + +export type BrowserFinder = ( + opts: BrowserFinderOptions +) => Promise + +const finderMap = { chrome, edge, firefox } as const + +export const autoFinders = ['chrome', 'edge', 'firefox'] as const + +export const findBrowser = async ( + finders: readonly (keyof typeof finderMap)[] = autoFinders, + opts: BrowserFinderOptions = {} +) => { + const finderCount = finders.length + const normalizedOpts = { + preferredPath: await (async () => { + if (opts.preferredPath) { + const normalized = await normalizeDarwinAppPath(opts.preferredPath) + if (isExecutable(normalized)) return normalized + } + return undefined + })(), + } + + if (finderCount === 0) { + debugBrowserFinder('No browser finder specified.') + + if (normalizedOpts.preferredPath) { + debugBrowserFinder( + 'Use preferred path as Chrome: %s', + normalizedOpts.preferredPath + ) + + return await chrome(normalizedOpts) + } + + throw new CLIError( + 'No suitable browser found.', + CLIErrorCode.NOT_FOUND_BROWSER + ) + } + + debugBrowserFinder( + `Start finding browser from ${finders.join(', ')} (%o)`, + normalizedOpts + ) + + return new Promise((res, rej) => { + const results = Array(finderCount) + const resolved = Array(finderCount) + + finders.forEach((finderName, index) => { + const finder = finderMap[finderName] + + finder(normalizedOpts) + .then((ret) => { + debugBrowserFinder(`Found ${finderName}: %o`, ret) + results[index] = ret + resolved[index] = true + }) + .catch((e) => { + debugBrowserFinder(`Finder ${finderName} was failed: %o`, e) + resolved[index] = false + }) + .finally(() => { + let target: number | undefined + + for (let i = finderCount - 1; i >= 0; i -= 1) { + if (resolved[i] !== false) target = i + } + + if (target === undefined) { + rej( + new CLIError( + `No suitable browser found. Please ensure one of the following browsers is installed: ${finders.join(', ')}`, + CLIErrorCode.NOT_FOUND_BROWSER + ) + ) + } else if (resolved[target]) { + res(results[target]) + } + }) + }) + }).then((result) => { + debugBrowserFinder('Use browser: %o', result) + return result + }) +} diff --git a/src/browser/finders/chrome.ts b/src/browser/finders/chrome.ts new file mode 100644 index 00000000..43034cc3 --- /dev/null +++ b/src/browser/finders/chrome.ts @@ -0,0 +1,58 @@ +import { + darwinFast, + linux, + win32, + wsl, +} from 'chrome-launcher/dist/chrome-finder' +import { error, CLIErrorCode } from '../../error' +import { ChromeBrowser } from '../browsers/chrome' +import { ChromeCdpBrowser } from '../browsers/chrome-cdp' +import type { BrowserFinder, BrowserFinderResult } from '../finder' +import { getPlatform, isExecutable, which } from './utils' + +const chrome = (path: string): BrowserFinderResult => ({ + path, + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], +}) + +export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => { + if (preferredPath) return chrome(preferredPath) + + const platform = await getPlatform() + const installation = (() => { + switch (platform) { + case 'darwin': + return darwinFast() + case 'linux': + return linux()[0] + case 'win32': + return win32()[0] + // CI cannot test against WSL environment + /* c8 ignore start */ + case 'wsl1': + return wsl()[0] + } + return fallback() + /* c8 ignore stop */ + })() + + if (installation) return chrome(installation) + + error('Chrome browser could not be found.', CLIErrorCode.NOT_FOUND_BROWSER) +} + +const fallbackExecutableNames = [ + 'google-chrome-stable', + 'google-chrome', + 'chrome', // FreeBSD Chromium + 'chromium-browser', + 'chromium', +] as const + +const fallback = () => { + for (const executableName of fallbackExecutableNames) { + const executablePath = which(executableName) + if (executablePath && isExecutable(executablePath)) return executablePath + } + return undefined +} diff --git a/src/browser/finders/edge.ts b/src/browser/finders/edge.ts new file mode 100644 index 00000000..b833eb4e --- /dev/null +++ b/src/browser/finders/edge.ts @@ -0,0 +1,95 @@ +import path from 'node:path' +import { error, CLIErrorCode } from '../../error' +import { + resolveWSLPathToGuestSync, + resolveWindowsEnvSync, +} from '../../utils/wsl' +import { ChromeBrowser } from '../browsers/chrome' +import { ChromeCdpBrowser } from '../browsers/chrome-cdp' +import type { BrowserFinder, BrowserFinderResult } from '../finder' +import { getPlatform, isExecutable } from './utils' + +const edge = (path: string): BrowserFinderResult => ({ + path, + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], +}) + +const findExecutable = (paths: string[]): string | undefined => + paths.find((p) => isExecutable(p)) + +export const edgeFinder: BrowserFinder = async ({ preferredPath } = {}) => { + if (preferredPath) return edge(preferredPath) + + const platform = await getPlatform() + const installation = (() => { + switch (platform) { + case 'darwin': + return edgeFinderDarwin() + case 'linux': + return edgeFinderLinux() + case 'win32': + return edgeFinderWin32() + // CI cannot test against WSL environment + /* c8 ignore start */ + case 'wsl1': + return edgeFinderWSL1() + } + return undefined + /* c8 ignore stop */ + })() + + if (installation) return edge(installation) + + error('Edge browser could not be found.', CLIErrorCode.NOT_FOUND_BROWSER) +} + +const edgeFinderDarwin = () => + findExecutable([ + '/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary', + '/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev', + '/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ]) + +const edgeFinderLinux = () => + findExecutable([ + '/opt/microsoft/msedge-canary/msedge', + '/opt/microsoft/msedge-dev/msedge', + '/opt/microsoft/msedge-beta/msedge', + '/opt/microsoft/msedge/msedge', + ]) + +const edgeFinderWin32 = ({ + programFiles = process.env.PROGRAMFILES, + programFilesX86 = process.env['PROGRAMFILES(X86)'], + localAppData = process.env.LOCALAPPDATA, +}: { + programFiles?: string + programFilesX86?: string + localAppData?: string +} = {}): string | undefined => { + const paths: string[] = [] + + for (const prefix of [programFiles, programFilesX86, localAppData]) { + if (!prefix) continue + + paths.push( + path.join(prefix, 'Microsoft', 'Edge SxS', 'Application', 'msedge.exe'), + path.join(prefix, 'Microsoft', 'Edge Dev', 'Application', 'msedge.exe'), + path.join(prefix, 'Microsoft', 'Edge Beta', 'Application', 'msedge.exe'), + path.join(prefix, 'Microsoft', 'Edge', 'Application', 'msedge.exe') + ) + } + + return findExecutable(paths) +} + +const edgeFinderWSL1 = () => { + const localAppData = resolveWindowsEnvSync('LOCALAPPDATA') + + return edgeFinderWin32({ + programFiles: '/mnt/c/Program Files', + programFilesX86: '/mnt/c/Program Files (x86)', + localAppData: localAppData ? resolveWSLPathToGuestSync(localAppData) : '', + }) +} diff --git a/src/browser/finders/firefox.ts b/src/browser/finders/firefox.ts new file mode 100644 index 00000000..f5a6262a --- /dev/null +++ b/src/browser/finders/firefox.ts @@ -0,0 +1,119 @@ +import path from 'node:path' +import { error, CLIErrorCode } from '../../error' +import { FirefoxBrowser } from '../browsers/firefox' +import type { BrowserFinder, BrowserFinderResult } from '../finder' +import { getPlatform, isExecutable, which } from './utils' + +const firefox = (path: string): BrowserFinderResult => ({ + path, + acceptedBrowsers: [FirefoxBrowser], +}) + +const findExecutable = (paths: string[]): string | undefined => + paths.find((p) => isExecutable(p)) + +export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => { + if (preferredPath) return firefox(preferredPath) + + const platform = await getPlatform() + const installation = (() => { + switch (platform) { + case 'darwin': + return firefoxFinderDarwin() + case 'win32': + return firefoxFinderWin32() + // CI cannot test against WSL environment + /* c8 ignore start */ + case 'wsl1': + return firefoxFinderWSL1() + /* c8 ignore stop */ + } + return firefoxFinderFallback() + })() + + if (installation) return firefox(installation) + + error('Firefox browser could not be found.', CLIErrorCode.NOT_FOUND_BROWSER) +} + +const firefoxFinderDarwin = () => + findExecutable([ + '/Applications/Firefox Nightly.app/Contents/MacOS/firefox', + '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox', + '/Applications/Firefox.app/Contents/MacOS/firefox', // Firefox stable, ESR, and beta + ]) + +const winDriveMatcher = /^[a-z]:\\/i +const winPossibleDrives = () => { + const possibleDriveSet = new Set() + const pathEnvs = process.env.PATH?.split(';') ?? ['c:\\'] + + for (const pathEnv of pathEnvs) { + if (winDriveMatcher.test(pathEnv)) { + possibleDriveSet.add(pathEnv[0].toLowerCase()) + } + } + + return Array.from(possibleDriveSet).sort() +} + +const firefoxFinderWin32 = () => { + const prefixes: string[] = [] + + for (const drive of winPossibleDrives()) { + for (const prefix of [ + process.env.PROGRAMFILES, + process.env['PROGRAMFILES(X86)'], + ]) { + if (!prefix) continue + prefixes.push(`${drive}${prefix.slice(1)}`) + } + } + + return findExecutable( + prefixes.flatMap((prefix) => [ + path.join(prefix, 'Nightly', 'firefox.exe'), + path.join(prefix, 'Firefox Nightly', 'firefox.exe'), + path.join(prefix, 'Firefox Developer Edition', 'firefox.exe'), + path.join(prefix, 'Mozilla Firefox', 'firefox.exe'), // Firefox stable, ESR, and beta + ]) + ) +} + +const firefoxFinderWSL1 = () => { + const prefixes: string[] = [] + + for (const drive of winPossibleDrives()) { + prefixes.push(`/mnt/${drive}/Program Files`) + prefixes.push(`/mnt/${drive}/Program Files (x86)`) + } + + return findExecutable( + prefixes.flatMap((prefix) => [ + path.join(prefix, 'Nightly', 'firefox.exe'), + path.join(prefix, 'Firefox Nightly', 'firefox.exe'), + path.join(prefix, 'Firefox Developer Edition', 'firefox.exe'), + path.join(prefix, 'Mozilla Firefox', 'firefox.exe'), // Firefox stable, ESR, and beta + ]) + ) +} + +// In Linux, Firefox must have only an executable name `firefox` in every +// editions, but some packages may provide different executable names. +const fallbackExecutableNames = [ + 'firefox-nightly', + 'firefox-developer-edition', + 'firefox-developer', + 'firefox-dev', + 'firefox-beta', + 'firefox', + 'firefox-esr', +] as const + +const firefoxFinderFallback = () => { + for (const executableName of fallbackExecutableNames) { + const executablePath = which(executableName) + if (executablePath && isExecutable(executablePath)) return executablePath + } + return undefined +} diff --git a/src/browser/finders/utils.ts b/src/browser/finders/utils.ts new file mode 100644 index 00000000..02e074ab --- /dev/null +++ b/src/browser/finders/utils.ts @@ -0,0 +1,120 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { parse as parsePlist } from 'fast-plist' +import { debugBrowserFinder } from '../../utils/debug' +import { isWSL } from '../../utils/wsl' + +// Common +export const getPlatform = async () => + (await isWSL()) === 1 ? 'wsl1' : process.platform + +export const isAccessible = (path: string, mode?: number) => { + try { + fs.accessSync(path, mode) + return true + } catch { + return false + } +} + +export const isExecutable = (path: string) => + isAccessible(path, fs.constants.X_OK) + +// Linux +export const isSnapBrowser = async (executablePath: string) => { + if (process.platform !== 'linux') return false + + // Snap binary + if (executablePath.startsWith('/snap/')) return true + + // Check the content of shebang script (Chrome for Linux has an alias script to call the snap binary) + if (isShebang(executablePath)) { + const scriptContent = await fs.promises.readFile(executablePath) + if (scriptContent.includes('/snap/')) return true + } + + return false +} + +export const which = (command: string) => { + if (process.platform === 'win32') { + debugBrowserFinder( + '"which %s" command is not available on Windows.', + command + ) + return undefined + } + + try { + const [ret] = execFileSync('which', [command], { stdio: 'pipe' }) + .toString() + .split(/\r?\n/) + + return ret + } catch { + return undefined + } +} + +const isShebang = (path: string) => { + let fd: number | null = null + + try { + fd = fs.openSync(path, 'r') + + const shebangBuffer = Buffer.alloc(2) + fs.readSync(fd, shebangBuffer, 0, 2, 0) + + if (shebangBuffer[0] === 0x23 && shebangBuffer[1] === 0x21) return true + } catch { + // no ops + } finally { + if (fd !== null) fs.closeSync(fd) + } + return false +} + +// Darwin +const darwinAppDirectoryMatcher = /.app\/?$/ + +export const normalizeDarwinAppPath = async ( + executablePath: string +): Promise => { + if (process.platform !== 'darwin') return executablePath + if (!darwinAppDirectoryMatcher.test(executablePath)) return executablePath + + debugBrowserFinder(`Maybe macOS app bundle path: ${executablePath}`) + + try { + const appDirStat = await fs.promises.stat(executablePath) + + if (appDirStat.isDirectory()) { + const manifestPath = path.join(executablePath, 'Contents', 'Info.plist') + const manifestBody = await fs.promises.readFile(manifestPath) + const manifest = parsePlist(manifestBody.toString()) + + if ( + manifest.CFBundlePackageType == 'APPL' && + manifest.CFBundleExecutable + ) { + const normalizedPath = path.join( + executablePath, + 'Contents', + 'MacOS', + manifest.CFBundleExecutable + ) + + debugBrowserFinder( + `macOS app bundle has been confirmed. Use normalized executable path: ${normalizedPath}` + ) + + return normalizedPath + } + } + } catch { + // ignore + } + + return executablePath +} diff --git a/src/browser/manager.ts b/src/browser/manager.ts new file mode 100644 index 00000000..e5f17aa5 --- /dev/null +++ b/src/browser/manager.ts @@ -0,0 +1,35 @@ +import type { + Browser, + BrowserKind, + BrowserProtocol, + BrowserPurpose, +} from './browser' + +export interface BrowserManagerQuery { + browser?: Browser + kind?: BrowserKind + protocol?: BrowserProtocol + purpose?: BrowserPurpose +} + +export class BrowserManager { + private browsers = new Set() + + register(browser: Browser): void { + this.browsers.add(browser) + } + + findBy(query: BrowserManagerQuery): Browser | undefined { + for (const browser of this.browsers) { + if (query.browser && browser !== query.browser) continue + if (query.kind && browser.kind !== query.kind) continue + if (query.protocol && browser.protocol !== query.protocol) continue + if (query.purpose && browser.purpose !== query.purpose) continue + + return browser + } + } +} + +export const browserManager = new BrowserManager() +export default browserManager diff --git a/src/error.ts b/src/error.ts index c303b5e8..236260da 100644 --- a/src/error.ts +++ b/src/error.ts @@ -3,7 +3,7 @@ export class CLIError extends Error { readonly message: string readonly name = 'CLIError' - constructor(message: string, errorCode = 1) { + constructor(message: string, errorCode: number = CLIErrorCode.GENERAL_ERROR) { super() this.message = message this.errorCode = errorCode @@ -14,16 +14,19 @@ export class CLIError extends Error { } } -export enum CLIErrorCode { - GENERAL_ERROR = 1, - NOT_FOUND_CHROMIUM = 2, - LISTEN_PORT_IS_ALREADY_USED = 3, - CANNOT_SPAWN_SNAP_CHROMIUM = 4, -} +export const CLIErrorCode = { + GENERAL_ERROR: 1, + NOT_FOUND_BROWSER: 2, + LISTEN_PORT_IS_ALREADY_USED: 3, + CANNOT_SPAWN_SNAP_CHROMIUM: 4, + + /** @deprecated NOT_FOUND_CHROMIUM is renamed to NOT_FOUND_BROWSER. */ + NOT_FOUND_CHROMIUM: 2, +} as const export function error( msg: string, - errorCode = CLIErrorCode.GENERAL_ERROR + errorCode: number = CLIErrorCode.GENERAL_ERROR ): never { throw new CLIError(msg, errorCode) } diff --git a/src/marp-cli.ts b/src/marp-cli.ts index d21f471b..40312a43 100644 --- a/src/marp-cli.ts +++ b/src/marp-cli.ts @@ -9,9 +9,9 @@ import { Server } from './server' import templates from './templates' import { isOfficialDockerImage } from './utils/container' import { resetExecutablePath } from './utils/puppeteer' +import { createYargs } from './utils/yargs' import version from './version' import watcher, { Watcher, notifier } from './watcher' -import { createYargs } from './utils/yargs' enum OptionGroup { Basic = 'Basic Options:', diff --git a/src/utils/debug.ts b/src/utils/debug.ts new file mode 100644 index 00000000..fd60a18f --- /dev/null +++ b/src/utils/debug.ts @@ -0,0 +1,5 @@ +import dbg from 'debug' + +export const debug = dbg('marp-cli') +export const debugBrowser = dbg('marp-cli:browser') +export const debugBrowserFinder = dbg('marp-cli:browser:finder') diff --git a/src/utils/yargs.ts b/src/utils/yargs.ts index a8d9bc6f..a8754701 100644 --- a/src/utils/yargs.ts +++ b/src/utils/yargs.ts @@ -1,5 +1,5 @@ -import yargs from 'yargs/yargs' import type { Argv } from 'yargs' +import yargs from 'yargs/yargs' let currentYargsRef: WeakRef | null = null diff --git a/test/converter.ts b/test/converter.ts index 70bff673..026c567c 100644 --- a/test/converter.ts +++ b/test/converter.ts @@ -17,7 +17,7 @@ import { bare as bareTpl } from '../src/templates' import { ThemeSet } from '../src/theme' import { WatchNotifier } from '../src/watcher' -const { CdpPage } = require('puppeteer-core/lib/cjs/puppeteer/cdp/Page') +const { CdpPage } = require('puppeteer-core/lib/cjs/puppeteer/cdp/Page') // eslint-disable-line @typescript-eslint/no-require-imports -- Puppeteer's internal module const puppeteerTimeoutMs = 60000 diff --git a/yarn.lock b/yarn.lock index 273f0e1d..a67db740 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3190,7 +3190,7 @@ debug@2.6.9, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== From d68aae36d5abd7e8a2d034f36b5e83c28cdbbb6a Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Sun, 22 Sep 2024 10:50:48 +0900 Subject: [PATCH 03/13] Make faster browser detection by asynchronization --- src/browser/finder.ts | 2 +- src/browser/finders/chrome.ts | 29 ++++------ src/browser/finders/edge.ts | 33 +++++------ src/browser/finders/firefox.ts | 60 +++++++++----------- src/browser/finders/utils.ts | 100 ++++++++++++++++++++++++--------- 5 files changed, 127 insertions(+), 97 deletions(-) diff --git a/src/browser/finder.ts b/src/browser/finder.ts index c8c28f80..f42647f7 100644 --- a/src/browser/finder.ts +++ b/src/browser/finder.ts @@ -32,7 +32,7 @@ export const findBrowser = async ( preferredPath: await (async () => { if (opts.preferredPath) { const normalized = await normalizeDarwinAppPath(opts.preferredPath) - if (isExecutable(normalized)) return normalized + if (await isExecutable(normalized)) return normalized } return undefined })(), diff --git a/src/browser/finders/chrome.ts b/src/browser/finders/chrome.ts index 43034cc3..dcf64390 100644 --- a/src/browser/finders/chrome.ts +++ b/src/browser/finders/chrome.ts @@ -8,7 +8,7 @@ import { error, CLIErrorCode } from '../../error' import { ChromeBrowser } from '../browsers/chrome' import { ChromeCdpBrowser } from '../browsers/chrome-cdp' import type { BrowserFinder, BrowserFinderResult } from '../finder' -import { getPlatform, isExecutable, which } from './utils' +import { findExecutableBinary, getPlatform } from './utils' const chrome = (path: string): BrowserFinderResult => ({ path, @@ -19,7 +19,7 @@ export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => { if (preferredPath) return chrome(preferredPath) const platform = await getPlatform() - const installation = (() => { + const installation = await (async () => { switch (platform) { case 'darwin': return darwinFast() @@ -32,7 +32,7 @@ export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => { case 'wsl1': return wsl()[0] } - return fallback() + return await fallback() /* c8 ignore stop */ })() @@ -41,18 +41,11 @@ export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => { error('Chrome browser could not be found.', CLIErrorCode.NOT_FOUND_BROWSER) } -const fallbackExecutableNames = [ - 'google-chrome-stable', - 'google-chrome', - 'chrome', // FreeBSD Chromium - 'chromium-browser', - 'chromium', -] as const - -const fallback = () => { - for (const executableName of fallbackExecutableNames) { - const executablePath = which(executableName) - if (executablePath && isExecutable(executablePath)) return executablePath - } - return undefined -} +const fallback = async () => + await findExecutableBinary([ + 'google-chrome-stable', + 'google-chrome', + 'chrome', // FreeBSD Chromium + 'chromium-browser', + 'chromium', + ]) diff --git a/src/browser/finders/edge.ts b/src/browser/finders/edge.ts index b833eb4e..154e29ce 100644 --- a/src/browser/finders/edge.ts +++ b/src/browser/finders/edge.ts @@ -7,32 +7,29 @@ import { import { ChromeBrowser } from '../browsers/chrome' import { ChromeCdpBrowser } from '../browsers/chrome-cdp' import type { BrowserFinder, BrowserFinderResult } from '../finder' -import { getPlatform, isExecutable } from './utils' +import { findExecutable, getPlatform } from './utils' const edge = (path: string): BrowserFinderResult => ({ path, acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], }) -const findExecutable = (paths: string[]): string | undefined => - paths.find((p) => isExecutable(p)) - export const edgeFinder: BrowserFinder = async ({ preferredPath } = {}) => { if (preferredPath) return edge(preferredPath) const platform = await getPlatform() - const installation = (() => { + const installation = await (async () => { switch (platform) { case 'darwin': - return edgeFinderDarwin() + return await edgeFinderDarwin() case 'linux': - return edgeFinderLinux() + return await edgeFinderLinux() case 'win32': - return edgeFinderWin32() + return await edgeFinderWin32() // CI cannot test against WSL environment /* c8 ignore start */ case 'wsl1': - return edgeFinderWSL1() + return await edgeFinderWSL1() } return undefined /* c8 ignore stop */ @@ -43,23 +40,23 @@ export const edgeFinder: BrowserFinder = async ({ preferredPath } = {}) => { error('Edge browser could not be found.', CLIErrorCode.NOT_FOUND_BROWSER) } -const edgeFinderDarwin = () => - findExecutable([ +const edgeFinderDarwin = async () => + await findExecutable([ '/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary', '/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev', '/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', ]) -const edgeFinderLinux = () => - findExecutable([ +const edgeFinderLinux = async () => + await findExecutable([ '/opt/microsoft/msedge-canary/msedge', '/opt/microsoft/msedge-dev/msedge', '/opt/microsoft/msedge-beta/msedge', '/opt/microsoft/msedge/msedge', ]) -const edgeFinderWin32 = ({ +const edgeFinderWin32 = async ({ programFiles = process.env.PROGRAMFILES, programFilesX86 = process.env['PROGRAMFILES(X86)'], localAppData = process.env.LOCALAPPDATA, @@ -67,7 +64,7 @@ const edgeFinderWin32 = ({ programFiles?: string programFilesX86?: string localAppData?: string -} = {}): string | undefined => { +} = {}): Promise => { const paths: string[] = [] for (const prefix of [programFiles, programFilesX86, localAppData]) { @@ -81,13 +78,13 @@ const edgeFinderWin32 = ({ ) } - return findExecutable(paths) + return await findExecutable(paths) } -const edgeFinderWSL1 = () => { +const edgeFinderWSL1 = async () => { const localAppData = resolveWindowsEnvSync('LOCALAPPDATA') - return edgeFinderWin32({ + return await edgeFinderWin32({ programFiles: '/mnt/c/Program Files', programFilesX86: '/mnt/c/Program Files (x86)', localAppData: localAppData ? resolveWSLPathToGuestSync(localAppData) : '', diff --git a/src/browser/finders/firefox.ts b/src/browser/finders/firefox.ts index f5a6262a..967d3aff 100644 --- a/src/browser/finders/firefox.ts +++ b/src/browser/finders/firefox.ts @@ -2,33 +2,30 @@ import path from 'node:path' import { error, CLIErrorCode } from '../../error' import { FirefoxBrowser } from '../browsers/firefox' import type { BrowserFinder, BrowserFinderResult } from '../finder' -import { getPlatform, isExecutable, which } from './utils' +import { getPlatform, findExecutable, findExecutableBinary } from './utils' const firefox = (path: string): BrowserFinderResult => ({ path, acceptedBrowsers: [FirefoxBrowser], }) -const findExecutable = (paths: string[]): string | undefined => - paths.find((p) => isExecutable(p)) - export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => { if (preferredPath) return firefox(preferredPath) const platform = await getPlatform() - const installation = (() => { + const installation = await (async () => { switch (platform) { case 'darwin': - return firefoxFinderDarwin() + return await firefoxFinderDarwin() case 'win32': - return firefoxFinderWin32() + return await firefoxFinderWin32() // CI cannot test against WSL environment /* c8 ignore start */ case 'wsl1': - return firefoxFinderWSL1() + return await firefoxFinderWSL1() /* c8 ignore stop */ } - return firefoxFinderFallback() + return await firefoxFinderFallback() })() if (installation) return firefox(installation) @@ -36,8 +33,8 @@ export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => { error('Firefox browser could not be found.', CLIErrorCode.NOT_FOUND_BROWSER) } -const firefoxFinderDarwin = () => - findExecutable([ +const firefoxFinderDarwin = async () => + await findExecutable([ '/Applications/Firefox Nightly.app/Contents/MacOS/firefox', '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox', '/Applications/Firefox.app/Contents/MacOS/firefox', // Firefox stable, ESR, and beta @@ -57,7 +54,7 @@ const winPossibleDrives = () => { return Array.from(possibleDriveSet).sort() } -const firefoxFinderWin32 = () => { +const firefoxFinderWin32 = async () => { const prefixes: string[] = [] for (const drive of winPossibleDrives()) { @@ -70,7 +67,7 @@ const firefoxFinderWin32 = () => { } } - return findExecutable( + return await findExecutable( prefixes.flatMap((prefix) => [ path.join(prefix, 'Nightly', 'firefox.exe'), path.join(prefix, 'Firefox Nightly', 'firefox.exe'), @@ -80,7 +77,7 @@ const firefoxFinderWin32 = () => { ) } -const firefoxFinderWSL1 = () => { +const firefoxFinderWSL1 = async () => { const prefixes: string[] = [] for (const drive of winPossibleDrives()) { @@ -88,7 +85,7 @@ const firefoxFinderWSL1 = () => { prefixes.push(`/mnt/${drive}/Program Files (x86)`) } - return findExecutable( + return await findExecutable( prefixes.flatMap((prefix) => [ path.join(prefix, 'Nightly', 'firefox.exe'), path.join(prefix, 'Firefox Nightly', 'firefox.exe'), @@ -98,22 +95,17 @@ const firefoxFinderWSL1 = () => { ) } -// In Linux, Firefox must have only an executable name `firefox` in every -// editions, but some packages may provide different executable names. -const fallbackExecutableNames = [ - 'firefox-nightly', - 'firefox-developer-edition', - 'firefox-developer', - 'firefox-dev', - 'firefox-beta', - 'firefox', - 'firefox-esr', -] as const - -const firefoxFinderFallback = () => { - for (const executableName of fallbackExecutableNames) { - const executablePath = which(executableName) - if (executablePath && isExecutable(executablePath)) return executablePath - } - return undefined -} +const firefoxFinderFallback = async () => + await findExecutableBinary( + // In Linux, Firefox must have only an executable name `firefox` in every + // editions, but some packages may provide different executable names. + [ + 'firefox-nightly', + 'firefox-developer-edition', + 'firefox-developer', + 'firefox-dev', + 'firefox-beta', + 'firefox', + 'firefox-esr', + ] + ) diff --git a/src/browser/finders/utils.ts b/src/browser/finders/utils.ts index 02e074ab..69efd479 100644 --- a/src/browser/finders/utils.ts +++ b/src/browser/finders/utils.ts @@ -1,25 +1,69 @@ -import { execFileSync } from 'node:child_process' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' import fs from 'node:fs' import path from 'node:path' import { parse as parsePlist } from 'fast-plist' import { debugBrowserFinder } from '../../utils/debug' import { isWSL } from '../../utils/wsl' -// Common +const execFilePromise = promisify(execFile) + export const getPlatform = async () => (await isWSL()) === 1 ? 'wsl1' : process.platform -export const isAccessible = (path: string, mode?: number) => { +export const isAccessible = async (path: string, mode?: number) => { try { - fs.accessSync(path, mode) + await fs.promises.access(path, mode) return true } catch { return false } } -export const isExecutable = (path: string) => - isAccessible(path, fs.constants.X_OK) +export const isExecutable = async (path: string) => + await isAccessible(path, fs.constants.X_OK) + +const findFirst = async ( + paths: string[], + predicate: (path: string) => Promise +) => { + const pathsCount = paths.length + + return new Promise((resolve) => { + const result = Array(pathsCount) + const resolved = Array(pathsCount) + + paths.forEach((p, index) => { + predicate(p) + .then((ret) => { + result[index] = ret + resolved[index] = !!ret + }) + .catch((e) => { + debugBrowserFinder('%o', e) + resolved[index] = false + }) + .finally(() => { + let target: number | undefined + + for (let i = pathsCount - 1; i >= 0; i -= 1) { + if (resolved[i] !== false) target = i + } + + if (target === undefined) { + resolve(undefined) + } else if (resolved[target]) { + resolve(result[target]) + } + }) + }) + }) +} + +export const findExecutable = async (paths: string[]) => + await findFirst(paths, async (path) => + (await isExecutable(path)) ? path : undefined + ) // Linux export const isSnapBrowser = async (executablePath: string) => { @@ -37,26 +81,6 @@ export const isSnapBrowser = async (executablePath: string) => { return false } -export const which = (command: string) => { - if (process.platform === 'win32') { - debugBrowserFinder( - '"which %s" command is not available on Windows.', - command - ) - return undefined - } - - try { - const [ret] = execFileSync('which', [command], { stdio: 'pipe' }) - .toString() - .split(/\r?\n/) - - return ret - } catch { - return undefined - } -} - const isShebang = (path: string) => { let fd: number | null = null @@ -75,6 +99,30 @@ const isShebang = (path: string) => { return false } +export const findExecutableBinary = async (binaries: string[]) => + await findFirst(binaries, async (binary) => { + const binaryPath = await which(binary) + if (binaryPath && (await isExecutable(binaryPath))) return binaryPath + return undefined + }) + +const which = async (command: string) => { + if (process.platform === 'win32') { + debugBrowserFinder( + '"which %s" command is not available on Windows.', + command + ) + return undefined + } + + try { + const { stdout } = await execFilePromise('which', [command]) + return stdout.split(/\r?\n/)[0] + } catch { + return undefined + } +} + // Darwin const darwinAppDirectoryMatcher = /.app\/?$/ From 7ece34bac2bca8978896d2bf941ac626cc821cfa Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Sun, 22 Sep 2024 18:54:45 +0900 Subject: [PATCH 04/13] Fix duplicated check for WSL --- src/browser/browser.ts | 7 +++- src/browser/browsers/chrome-cdp.ts | 4 +- src/browser/browsers/chrome.ts | 4 +- src/browser/browsers/firefox.ts | 4 +- src/utils/wsl.ts | 59 ++++++++++++++++++------------ 5 files changed, 46 insertions(+), 32 deletions(-) diff --git a/src/browser/browser.ts b/src/browser/browser.ts index 036f8188..a06b03df 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -7,8 +7,11 @@ export interface BrowserOptions { } export abstract class Browser { - abstract kind: BrowserKind - abstract protocol: BrowserProtocol + static readonly kind: BrowserKind + static readonly protocol: BrowserProtocol + + // --- + purpose: BrowserPurpose constructor(opts: BrowserOptions) { diff --git a/src/browser/browsers/chrome-cdp.ts b/src/browser/browsers/chrome-cdp.ts index 95d5440c..80946ebf 100644 --- a/src/browser/browsers/chrome-cdp.ts +++ b/src/browser/browsers/chrome-cdp.ts @@ -1,6 +1,6 @@ import { Browser } from '../browser' export class ChromeCdpBrowser extends Browser { - kind = 'chrome' as const - protocol = 'cdp' as const + static readonly kind = 'chrome' as const + static readonly protocol = 'cdp' as const } diff --git a/src/browser/browsers/chrome.ts b/src/browser/browsers/chrome.ts index b274b92a..2f17f438 100644 --- a/src/browser/browsers/chrome.ts +++ b/src/browser/browsers/chrome.ts @@ -1,6 +1,6 @@ import { Browser } from '../browser' export class ChromeBrowser extends Browser { - kind = 'chrome' as const - protocol = 'webdriver-bidi' as const + static readonly kind = 'chrome' as const + static readonly protocol = 'webdriver-bidi' as const } diff --git a/src/browser/browsers/firefox.ts b/src/browser/browsers/firefox.ts index bc2424ff..cfc33517 100644 --- a/src/browser/browsers/firefox.ts +++ b/src/browser/browsers/firefox.ts @@ -1,6 +1,6 @@ import { Browser } from '../browser' export class FirefoxBrowser extends Browser { - kind = 'firefox' as const - protocol = 'webdriver-bidi' as const + static readonly kind = 'firefox' as const + static readonly protocol = 'webdriver-bidi' as const } diff --git a/src/utils/wsl.ts b/src/utils/wsl.ts index 2737f3e8..88cb720c 100644 --- a/src/utils/wsl.ts +++ b/src/utils/wsl.ts @@ -1,7 +1,8 @@ import { execFile, spawnSync } from 'node:child_process' -import { readFileSync } from 'node:fs' +import fs from 'node:fs' +import { debug } from './debug' -let isWsl: number | undefined +let isWsl: number | Promise | undefined export const resolveWSLPathToHost = async (path: string): Promise => await new Promise((res, rej) => { @@ -30,31 +31,41 @@ export const resolveWindowsEnvSync = (key: string): string | undefined => { return ret.startsWith(`${key}=`) ? ret.slice(key.length + 1) : undefined } +const wsl2VerMatcher = /microsoft-standard-wsl2/i + export const isWSL = async (): Promise => { if (isWsl === undefined) { - if ((await import('is-wsl')).default) { - // Detect whether WSL version is 2 - // https://github.com/microsoft/WSL/issues/4555#issuecomment-700213318 - const isWSL2 = (() => { - if (process.env.WSL_DISTRO_NAME && process.env.WSL_INTEROP) return true - - try { - const verStr = readFileSync('/proc/version', 'utf8').toLowerCase() - if (verStr.includes('microsoft-standard-wsl2')) return true - - const gccMatched = verStr.match(/gcc[^,]+?(\d+)\.\d+\.\d+/) - if (gccMatched && Number.parseInt(gccMatched[1], 10) >= 8) return true - } catch { - // no ops - } - })() - - isWsl = isWSL2 ? 2 : 1 - } else { - isWsl = 0 - } + isWsl = (async () => { + if ((await import('is-wsl')).default) { + // Detect whether WSL version is 2 + // https://github.com/microsoft/WSL/issues/4555#issuecomment-700213318 + const isWSL2 = await (async () => { + if (process.env.WSL_DISTRO_NAME && process.env.WSL_INTEROP) + return true + + try { + const verStr = await fs.promises.readFile('/proc/version', 'utf8') + if (wsl2VerMatcher.test(verStr)) return true + + const gccMatched = verStr.match(/gcc[^,]+?(\d+)\.\d+\.\d+/) + if (gccMatched && Number.parseInt(gccMatched[1], 10) >= 8) + return true + } catch { + // no ops + } + })() + + const wslVersion = isWSL2 ? 2 : 1 + debug('Detected WSL version: %s', wslVersion) + + return wslVersion + } else { + return 0 + } + })().then((correctedIsWsl) => (isWsl = correctedIsWsl)) } - return isWsl + + return await isWsl } export const isChromeInWSLHost = async (chromePath: string | undefined) => From d22b562ea11ed667a20621abe1eb1b366ab9714f Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Sun, 22 Sep 2024 19:13:33 +0900 Subject: [PATCH 05/13] Fix Firefox WSL1 resolver --- src/browser/browser.ts | 8 ++++++ src/browser/finders/firefox.ts | 45 ++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/browser/browser.ts b/src/browser/browser.ts index a06b03df..e49de74a 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -17,4 +17,12 @@ export abstract class Browser { constructor(opts: BrowserOptions) { this.purpose = opts.purpose } + + get kind() { + return (this.constructor as typeof Browser).kind + } + + get protocol() { + return (this.constructor as typeof Browser).protocol + } } diff --git a/src/browser/finders/firefox.ts b/src/browser/finders/firefox.ts index 967d3aff..689b6e85 100644 --- a/src/browser/finders/firefox.ts +++ b/src/browser/finders/firefox.ts @@ -40,23 +40,23 @@ const firefoxFinderDarwin = async () => '/Applications/Firefox.app/Contents/MacOS/firefox', // Firefox stable, ESR, and beta ]) -const winDriveMatcher = /^[a-z]:\\/i -const winPossibleDrives = () => { - const possibleDriveSet = new Set() - const pathEnvs = process.env.PATH?.split(';') ?? ['c:\\'] - - for (const pathEnv of pathEnvs) { - if (winDriveMatcher.test(pathEnv)) { - possibleDriveSet.add(pathEnv[0].toLowerCase()) - } - } - - return Array.from(possibleDriveSet).sort() -} - const firefoxFinderWin32 = async () => { const prefixes: string[] = [] + const winDriveMatcher = /^[a-z]:\\/i + const winPossibleDrives = () => { + const possibleDriveSet = new Set() + const pathEnvs = process.env.PATH?.split(';') ?? ['c:\\'] + + for (const pathEnv of pathEnvs) { + if (winDriveMatcher.test(pathEnv)) { + possibleDriveSet.add(pathEnv[0].toLowerCase()) + } + } + + return Array.from(possibleDriveSet).sort() + } + for (const drive of winPossibleDrives()) { for (const prefix of [ process.env.PROGRAMFILES, @@ -80,6 +80,20 @@ const firefoxFinderWin32 = async () => { const firefoxFinderWSL1 = async () => { const prefixes: string[] = [] + const winDriveMatcher = /^\/mnt\/[a-z]\//i + const winPossibleDrives = () => { + const possibleDriveSet = new Set() + const pathEnvs = process.env.PATH?.split(':') ?? ['/mnt/c/'] + + for (const pathEnv of pathEnvs) { + if (winDriveMatcher.test(pathEnv)) { + possibleDriveSet.add(pathEnv[5].toLowerCase()) + } + } + + return Array.from(possibleDriveSet).sort() + } + for (const drive of winPossibleDrives()) { prefixes.push(`/mnt/${drive}/Program Files`) prefixes.push(`/mnt/${drive}/Program Files (x86)`) @@ -98,7 +112,8 @@ const firefoxFinderWSL1 = async () => { const firefoxFinderFallback = async () => await findExecutableBinary( // In Linux, Firefox must have only an executable name `firefox` in every - // editions, but some packages may provide different executable names. + // editions, but some distributions may have provided different executable + // names. [ 'firefox-nightly', 'firefox-developer-edition', From 4108fe9df478981ad302e1476083fb22529048c8 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Sun, 22 Sep 2024 23:13:10 +0900 Subject: [PATCH 06/13] Add test for new browser finder --- src/browser/finders/utils.ts | 2 +- test/.eslintrc.js | 6 + test/browser/finder.ts | 115 ++++++++++++++++++ test/utils/_executable_mocks/empty | 0 test/utils/_executable_mocks/non-executable | 0 test/utils/_executable_mocks/shebang-chromium | 0 .../_executable_mocks/shebang-snapd-chromium | 0 .../Invalid.app/Contents/MacOS/Invalid app | 0 .../Valid.app/Contents/MacOS/Valid app | 0 test/utils/wsl.ts | 45 ++++--- 10 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 test/browser/finder.ts mode change 100644 => 100755 test/utils/_executable_mocks/empty create mode 100644 test/utils/_executable_mocks/non-executable mode change 100644 => 100755 test/utils/_executable_mocks/shebang-chromium mode change 100644 => 100755 test/utils/_executable_mocks/shebang-snapd-chromium mode change 100644 => 100755 test/utils/_mac_bundles/Invalid.app/Contents/MacOS/Invalid app mode change 100644 => 100755 test/utils/_mac_bundles/Valid.app/Contents/MacOS/Valid app diff --git a/src/browser/finders/utils.ts b/src/browser/finders/utils.ts index 69efd479..7867c60b 100644 --- a/src/browser/finders/utils.ts +++ b/src/browser/finders/utils.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process' -import { promisify } from 'node:util' import fs from 'node:fs' import path from 'node:path' +import { promisify } from 'node:util' import { parse as parsePlist } from 'fast-plist' import { debugBrowserFinder } from '../../utils/debug' import { isWSL } from '../../utils/wsl' diff --git a/test/.eslintrc.js b/test/.eslintrc.js index fd08d52a..9b1655c4 100644 --- a/test/.eslintrc.js +++ b/test/.eslintrc.js @@ -1,4 +1,10 @@ module.exports = { plugins: ['jest'], extends: ['plugin:jest/recommended', 'plugin:jest/style'], + rules: { + 'jest/no-standalone-expect': [ + 'error', + { additionalTestBlockFunctions: ['itOnlyWin', 'itExceptWin'] }, + ], + }, } diff --git a/test/browser/finder.ts b/test/browser/finder.ts new file mode 100644 index 00000000..df06741f --- /dev/null +++ b/test/browser/finder.ts @@ -0,0 +1,115 @@ +import path from 'node:path' +import { ChromeBrowser } from '../../src/browser/browsers/chrome' +import { ChromeCdpBrowser } from '../../src/browser/browsers/chrome-cdp' +import { FirefoxBrowser } from '../../src/browser/browsers/firefox' +import { autoFinders, findBrowser } from '../../src/browser/finder' +import { CLIError } from '../../src/error' + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +const itOnlyWin = process.platform === 'win32' ? it : it.skip +const itExceptWin = process.platform === 'win32' ? it.skip : it + +const executableMock = (name: string) => + path.join(__dirname, `../utils/_executable_mocks`, name) + +const macBundle = (name: string) => + path.join(__dirname, `../utils/_mac_bundles`, name) + +describe('Browser finder', () => { + describe('#findBrowser', () => { + it('rejects when no finder is specified', async () => { + await expect(findBrowser([])).rejects.toThrow(CLIError) + }) + + it('resolves as Chrome browser when no finder is specified with preferred executable path', async () => { + const browser = await findBrowser([], { + preferredPath: executableMock('empty'), + }) + + expect(browser).toStrictEqual({ + path: expect.stringMatching(/\bempty$/), + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + + itExceptWin( + 'rejects when no finder is specified with preferred non-executable path in non-Windows platforms', + async () => { + await expect( + findBrowser([], { + preferredPath: executableMock('non-executable'), + }) + ).rejects.toThrow(CLIError) + } + ) + + itOnlyWin( + 'resolves as Chrome browser when no finder is specified with preferred non-executable path in Windows', + async () => { + const browser = await findBrowser([], { + preferredPath: executableMock('non-executable'), + }) + + expect(browser).toStrictEqual({ + path: expect.stringMatching(/\bnon-executable$/), + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + } + ) + + it('resolves as Firefox browser when Firefox finder was preferred with preferred executable path', async () => { + const browser = await findBrowser(['firefox', 'chrome'], { + preferredPath: executableMock('empty'), + }) + + expect(browser).toStrictEqual({ + path: expect.stringMatching(/\bempty$/), + acceptedBrowsers: [FirefoxBrowser], + }) + }) + + describe('with macOS', () => { + let originalPlatform: string | undefined + + beforeEach(() => { + originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin' }) + }) + + afterEach(() => { + if (originalPlatform !== undefined) { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }) + } + originalPlatform = undefined + }) + + it('normalizes executable path if preferred path was pointed to valid app bundle', async () => { + const browser = await findBrowser(autoFinders, { + preferredPath: macBundle('Valid.app'), + }) + + expect(browser).toStrictEqual({ + path: macBundle('Valid.app/Contents/MacOS/Valid app'), + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + + it('does not normalize executable path if preferred path was pointed to invalid app bundle', async () => { + const browser = await findBrowser(autoFinders, { + preferredPath: macBundle('Invalid.app'), + }) + + expect(browser).toStrictEqual({ + path: macBundle('Invalid.app'), + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + }) + }) +}) diff --git a/test/utils/_executable_mocks/empty b/test/utils/_executable_mocks/empty old mode 100644 new mode 100755 diff --git a/test/utils/_executable_mocks/non-executable b/test/utils/_executable_mocks/non-executable new file mode 100644 index 00000000..e69de29b diff --git a/test/utils/_executable_mocks/shebang-chromium b/test/utils/_executable_mocks/shebang-chromium old mode 100644 new mode 100755 diff --git a/test/utils/_executable_mocks/shebang-snapd-chromium b/test/utils/_executable_mocks/shebang-snapd-chromium old mode 100644 new mode 100755 diff --git a/test/utils/_mac_bundles/Invalid.app/Contents/MacOS/Invalid app b/test/utils/_mac_bundles/Invalid.app/Contents/MacOS/Invalid app old mode 100644 new mode 100755 diff --git a/test/utils/_mac_bundles/Valid.app/Contents/MacOS/Valid app b/test/utils/_mac_bundles/Valid.app/Contents/MacOS/Valid app old mode 100644 new mode 100755 diff --git a/test/utils/wsl.ts b/test/utils/wsl.ts index ab1b8936..ba8c6c26 100644 --- a/test/utils/wsl.ts +++ b/test/utils/wsl.ts @@ -109,69 +109,68 @@ describe('#isWSL', () => { it('returns 1 if running on WSL 1', async () => { jest.doMock('is-wsl', () => true) - const readFileSync = jest - .spyOn(fs, 'readFileSync') - .mockImplementation( - () => 'Linux version 4.5.6-12345-Microsoft (gcc version 5.4.0 (GCC) )' + const readFile = jest + .spyOn(fs.promises, 'readFile') + .mockResolvedValue( + 'Linux version 4.5.6-12345-Microsoft (gcc version 5.4.0 (GCC) )' ) expect(await wsl().isWSL()).toBe(1) - expect(readFileSync).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledTimes(1) // Returns cached result to prevent excessive file I/O await wsl().isWSL() - expect(readFileSync).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledTimes(1) }) it('returns 2 if running on WSL 2 (Fast check by environment values)', async () => { jest.doMock('is-wsl', () => true) - const readFileSync = jest.spyOn(fs, 'readFileSync') + const readFile = jest.spyOn(fs.promises, 'readFile') // WSL 2 has WSL_DISTRO_NAME and WSL_INTEROP process.env.WSL_DISTRO_NAME = 'Ubuntu' process.env.WSL_INTEROP = '/run/WSL/11_interop' expect(await wsl().isWSL()).toBe(2) - expect(readFileSync).not.toHaveBeenCalled() + expect(readFile).not.toHaveBeenCalled() }) it('returns 2 if running on WSL 2 (Check WSL2 annotation in /proc/version string)', async () => { jest.doMock('is-wsl', () => true) - const readFileSync = jest - .spyOn(fs, 'readFileSync') - .mockImplementation(() => 'Linux version 4.5.6-Microsoft-Standard-WSL2') + const readFile = jest + .spyOn(fs.promises, 'readFile') + .mockResolvedValue('Linux version 4.5.6-Microsoft-Standard-WSL2') expect(await wsl().isWSL()).toBe(2) - expect(readFileSync).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledTimes(1) }) it('returns 2 if running on WSL 2 (Check gcc version in /proc/version string)', async () => { jest.doMock('is-wsl', () => true) - const readFileSync = jest - .spyOn(fs, 'readFileSync') - .mockImplementation( - () => 'Linux version 4.5.6-12345-Microsoft (gcc version 8.4.0 (GCC) )' + const readFile = jest + .spyOn(fs.promises, 'readFile') + .mockResolvedValue( + 'Linux version 4.5.6-12345-Microsoft (gcc version 8.4.0 (GCC) )' ) expect(await wsl().isWSL()).toBe(2) - expect(readFileSync).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledTimes(1) }) it('returns 2 if running on WSL 2 (The latest format of /proc/version string)', async () => { jest.doMock('is-wsl', () => true) - const readFileSync = jest - .spyOn(fs, 'readFileSync') - .mockImplementation( - () => - 'Linux version 5.10.74.3 (x86_64-msft-linux-gcc (GCC) 9.3.0, GNU ld (GNU Binutils) 2.34.0.20200220)' + const readFile = jest + .spyOn(fs.promises, 'readFile') + .mockResolvedValue( + 'Linux version 5.10.74.3 (x86_64-msft-linux-gcc (GCC) 9.3.0, GNU ld (GNU Binutils) 2.34.0.20200220)' ) expect(await wsl().isWSL()).toBe(2) - expect(readFileSync).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledTimes(1) }) }) From 415cee663b3f4ff7748e7ede97830d217f93b29a Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Mon, 23 Sep 2024 02:27:30 +0900 Subject: [PATCH 07/13] Cover test for each finders and utils --- package.json | 2 + src/browser/finders/chrome.ts | 3 - src/browser/finders/edge.ts | 23 ++- src/browser/finders/firefox.ts | 44 +++-- src/browser/finders/utils.ts | 24 +-- test/browser/finder.ts | 58 ++++++ test/browser/finders/chrome.ts | 179 +++++++++++++++++ test/browser/finders/edge.ts | 282 +++++++++++++++++++++++++++ test/browser/finders/firefox.ts | 330 ++++++++++++++++++++++++++++++++ test/browser/finders/utils.ts | 84 ++++++++ yarn.lock | 17 ++ 11 files changed, 992 insertions(+), 54 deletions(-) create mode 100644 test/browser/finders/chrome.ts create mode 100644 test/browser/finders/edge.ts create mode 100644 test/browser/finders/firefox.ts create mode 100644 test/browser/finders/utils.ts diff --git a/package.json b/package.json index b5a91c33..5c6b98d4 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@types/node": "~16.18.108", "@types/pug": "^2.0.10", "@types/supertest": "^6.0.2", + "@types/which": "^3.0.4", "@types/ws": "^8.5.12", "@types/yargs": "^17.0.33", "@typescript-eslint/eslint-plugin": "^8.5.0", @@ -149,6 +150,7 @@ "typed-emitter": "^2.1.0", "typescript": "^5.6.2", "vhtml": "^2.2.0", + "which": "^4.0.0", "wrap-ansi": "^9.0.0", "yauzl": "^3.1.3", "zip-stream": "^6.0.1" diff --git a/src/browser/finders/chrome.ts b/src/browser/finders/chrome.ts index dcf64390..30c2d48f 100644 --- a/src/browser/finders/chrome.ts +++ b/src/browser/finders/chrome.ts @@ -27,13 +27,10 @@ export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => { return linux()[0] case 'win32': return win32()[0] - // CI cannot test against WSL environment - /* c8 ignore start */ case 'wsl1': return wsl()[0] } return await fallback() - /* c8 ignore stop */ })() if (installation) return chrome(installation) diff --git a/src/browser/finders/edge.ts b/src/browser/finders/edge.ts index 154e29ce..8c511288 100644 --- a/src/browser/finders/edge.ts +++ b/src/browser/finders/edge.ts @@ -26,13 +26,10 @@ export const edgeFinder: BrowserFinder = async ({ preferredPath } = {}) => { return await edgeFinderLinux() case 'win32': return await edgeFinderWin32() - // CI cannot test against WSL environment - /* c8 ignore start */ case 'wsl1': return await edgeFinderWSL1() } return undefined - /* c8 ignore stop */ })() if (installation) return edge(installation) @@ -67,15 +64,17 @@ const edgeFinderWin32 = async ({ } = {}): Promise => { const paths: string[] = [] - for (const prefix of [programFiles, programFilesX86, localAppData]) { - if (!prefix) continue + const suffixes = [ + ['Microsoft', 'Edge SxS', 'Application', 'msedge.exe'], + ['Microsoft', 'Edge Dev', 'Application', 'msedge.exe'], + ['Microsoft', 'Edge Beta', 'Application', 'msedge.exe'], + ['Microsoft', 'Edge', 'Application', 'msedge.exe'], + ] - paths.push( - path.join(prefix, 'Microsoft', 'Edge SxS', 'Application', 'msedge.exe'), - path.join(prefix, 'Microsoft', 'Edge Dev', 'Application', 'msedge.exe'), - path.join(prefix, 'Microsoft', 'Edge Beta', 'Application', 'msedge.exe'), - path.join(prefix, 'Microsoft', 'Edge', 'Application', 'msedge.exe') - ) + for (const suffix of suffixes) { + for (const prefix of [localAppData, programFiles, programFilesX86]) { + if (prefix) paths.push(path.join(prefix, ...suffix)) + } } return await findExecutable(paths) @@ -87,6 +86,6 @@ const edgeFinderWSL1 = async () => { return await edgeFinderWin32({ programFiles: '/mnt/c/Program Files', programFilesX86: '/mnt/c/Program Files (x86)', - localAppData: localAppData ? resolveWSLPathToGuestSync(localAppData) : '', + localAppData: localAppData && resolveWSLPathToGuestSync(localAppData), }) } diff --git a/src/browser/finders/firefox.ts b/src/browser/finders/firefox.ts index 689b6e85..813fbbbe 100644 --- a/src/browser/finders/firefox.ts +++ b/src/browser/finders/firefox.ts @@ -9,6 +9,11 @@ const firefox = (path: string): BrowserFinderResult => ({ acceptedBrowsers: [FirefoxBrowser], }) +const winFirefoxNightly = ['Nightly', 'firefox.exe'] +const winFirefoxNightlyAlt = ['Firefox Nightly', 'firefox.exe'] +const winFirefoxDevEdition = ['Firefox Developer Edition', 'firefox.exe'] +const winFirefoxDefault = ['Mozilla Firefox', 'firefox.exe'] // Firefox stable, ESR, and beta + export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => { if (preferredPath) return firefox(preferredPath) @@ -19,11 +24,8 @@ export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => { return await firefoxFinderDarwin() case 'win32': return await firefoxFinderWin32() - // CI cannot test against WSL environment - /* c8 ignore start */ case 'wsl1': return await firefoxFinderWSL1() - /* c8 ignore stop */ } return await firefoxFinderFallback() })() @@ -45,8 +47,8 @@ const firefoxFinderWin32 = async () => { const winDriveMatcher = /^[a-z]:\\/i const winPossibleDrives = () => { - const possibleDriveSet = new Set() - const pathEnvs = process.env.PATH?.split(';') ?? ['c:\\'] + const possibleDriveSet = new Set(['c']) + const pathEnvs = process.env.PATH?.split(';') ?? [] for (const pathEnv of pathEnvs) { if (winDriveMatcher.test(pathEnv)) { @@ -68,12 +70,14 @@ const firefoxFinderWin32 = async () => { } return await findExecutable( - prefixes.flatMap((prefix) => [ - path.join(prefix, 'Nightly', 'firefox.exe'), - path.join(prefix, 'Firefox Nightly', 'firefox.exe'), - path.join(prefix, 'Firefox Developer Edition', 'firefox.exe'), - path.join(prefix, 'Mozilla Firefox', 'firefox.exe'), // Firefox stable, ESR, and beta - ]) + [ + winFirefoxNightly, + winFirefoxNightlyAlt, + winFirefoxDevEdition, + winFirefoxDefault, + ].flatMap((suffix) => + prefixes.map((prefix) => path.join(prefix, ...suffix)) + ) ) } @@ -82,8 +86,8 @@ const firefoxFinderWSL1 = async () => { const winDriveMatcher = /^\/mnt\/[a-z]\//i const winPossibleDrives = () => { - const possibleDriveSet = new Set() - const pathEnvs = process.env.PATH?.split(':') ?? ['/mnt/c/'] + const possibleDriveSet = new Set(['c']) + const pathEnvs = process.env.PATH?.split(':') ?? [] for (const pathEnv of pathEnvs) { if (winDriveMatcher.test(pathEnv)) { @@ -100,12 +104,14 @@ const firefoxFinderWSL1 = async () => { } return await findExecutable( - prefixes.flatMap((prefix) => [ - path.join(prefix, 'Nightly', 'firefox.exe'), - path.join(prefix, 'Firefox Nightly', 'firefox.exe'), - path.join(prefix, 'Firefox Developer Edition', 'firefox.exe'), - path.join(prefix, 'Mozilla Firefox', 'firefox.exe'), // Firefox stable, ESR, and beta - ]) + [ + winFirefoxNightly, + winFirefoxNightlyAlt, + winFirefoxDevEdition, + winFirefoxDefault, + ].flatMap((suffix) => + prefixes.map((prefix) => path.join(prefix, ...suffix)) + ) ) } diff --git a/src/browser/finders/utils.ts b/src/browser/finders/utils.ts index 7867c60b..e803f82c 100644 --- a/src/browser/finders/utils.ts +++ b/src/browser/finders/utils.ts @@ -1,13 +1,10 @@ -import { execFile } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' -import { promisify } from 'node:util' import { parse as parsePlist } from 'fast-plist' +import nodeWhich from 'which' import { debugBrowserFinder } from '../../utils/debug' import { isWSL } from '../../utils/wsl' -const execFilePromise = promisify(execFile) - export const getPlatform = async () => (await isWSL()) === 1 ? 'wsl1' : process.platform @@ -28,6 +25,7 @@ const findFirst = async ( predicate: (path: string) => Promise ) => { const pathsCount = paths.length + if (pathsCount === 0) return undefined return new Promise((resolve) => { const result = Array(pathsCount) @@ -106,22 +104,8 @@ export const findExecutableBinary = async (binaries: string[]) => return undefined }) -const which = async (command: string) => { - if (process.platform === 'win32') { - debugBrowserFinder( - '"which %s" command is not available on Windows.', - command - ) - return undefined - } - - try { - const { stdout } = await execFilePromise('which', [command]) - return stdout.split(/\r?\n/)[0] - } catch { - return undefined - } -} +export const which = async (command: string) => + (await nodeWhich(command, { nothrow: true })) ?? undefined // Darwin const darwinAppDirectoryMatcher = /.app\/?$/ diff --git a/test/browser/finder.ts b/test/browser/finder.ts index df06741f..591751b8 100644 --- a/test/browser/finder.ts +++ b/test/browser/finder.ts @@ -72,6 +72,64 @@ describe('Browser finder', () => { }) }) + it('resolves a secondary browser if the first finder throws an error', async () => { + let $findBrowser!: typeof findBrowser + let $ChromeBrowser!: typeof ChromeBrowser + let $ChromeCdpBrowser!: typeof ChromeCdpBrowser + + await jest.isolateModulesAsync(async () => { + jest + .spyOn( + await import('../../src/browser/finders/firefox'), + 'firefoxFinder' + ) + .mockRejectedValue(new Error('Test error')) + + $findBrowser = await import('../../src/browser/finder').then( + (m) => m.findBrowser + ) + + $ChromeBrowser = await import('../../src/browser/browsers/chrome').then( + (m) => m.ChromeBrowser + ) + + $ChromeCdpBrowser = await import( + '../../src/browser/browsers/chrome-cdp' + ).then((m) => m.ChromeCdpBrowser) + }) + + const browser = await $findBrowser(['firefox', 'chrome'], { + preferredPath: executableMock('empty'), + }) + + expect(browser).toStrictEqual({ + path: expect.stringMatching(/\bempty$/), + acceptedBrowsers: [$ChromeBrowser, $ChromeCdpBrowser], + }) + }) + + it('rejects when finders are rejected', async () => { + let $findBrowser!: typeof findBrowser + let $CLIError!: typeof CLIError + + await jest.isolateModulesAsync(async () => { + jest + .spyOn( + await import('../../src/browser/finders/chrome'), + 'chromeFinder' + ) + .mockRejectedValue(new Error('Test error')) + + $findBrowser = await import('../../src/browser/finder').then( + (m) => m.findBrowser + ) + + $CLIError = await import('../../src/error').then((m) => m.CLIError) + }) + + await expect($findBrowser(['chrome'])).rejects.toThrow($CLIError) + }) + describe('with macOS', () => { let originalPlatform: string | undefined diff --git a/test/browser/finders/chrome.ts b/test/browser/finders/chrome.ts new file mode 100644 index 00000000..5b436776 --- /dev/null +++ b/test/browser/finders/chrome.ts @@ -0,0 +1,179 @@ +import path from 'node:path' +import * as chromeFinderModule from 'chrome-launcher/dist/chrome-finder' +import { ChromeBrowser } from '../../../src/browser/browsers/chrome' +import { ChromeCdpBrowser } from '../../../src/browser/browsers/chrome-cdp' +import { chromeFinder } from '../../../src/browser/finders/chrome' +import * as utils from '../../../src/browser/finders/utils' +import { CLIError } from '../../../src/error' + +jest.mock('chrome-launcher/dist/chrome-finder') + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +const executableMock = (name: string) => + path.join(__dirname, `../../utils/_executable_mocks`, name) + +describe('Chrome finder', () => { + describe('with preferred path', () => { + it('returns the preferred path as chrome', async () => { + const chrome = await chromeFinder({ + preferredPath: '/test/preferred/chrome', + }) + + expect(chrome).toStrictEqual({ + path: '/test/preferred/chrome', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + }) + + describe('with Linux', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('linux') + jest + .spyOn(chromeFinderModule, 'linux') + .mockReturnValue(['/test/chrome', '/test/chromium']) + }) + + it('calls linux() in chrome-finder module and returns the first installation path', async () => { + const chrome = await chromeFinder({}) + + expect(chrome).toStrictEqual({ + path: '/test/chrome', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(chromeFinderModule.linux).toHaveBeenCalled() + }) + + it('throws error if linux() was returned empty array', async () => { + jest.spyOn(chromeFinderModule, 'linux').mockReturnValue([]) + + await expect(chromeFinder({})).rejects.toThrow(CLIError) + expect(chromeFinderModule.linux).toHaveBeenCalled() + }) + }) + + describe('with macOS', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('darwin') + jest + .spyOn(chromeFinderModule, 'darwinFast') + .mockReturnValue( + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + ) + }) + + it('calls darwinFast() in chrome-finder module and returns the installation path', async () => { + const chrome = await chromeFinder({}) + + expect(chrome).toStrictEqual({ + path: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(chromeFinderModule.darwinFast).toHaveBeenCalled() + }) + + it('throws error if darwinFast() was returned undefined', async () => { + jest.spyOn(chromeFinderModule, 'darwinFast').mockReturnValue(undefined) + + await expect(chromeFinder({})).rejects.toThrow(CLIError) + expect(chromeFinderModule.darwinFast).toHaveBeenCalled() + }) + }) + + describe('with Windows', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('win32') + jest + .spyOn(chromeFinderModule, 'win32') + .mockReturnValue([ + 'C:\\Program Files\\Google Chrome\\chrome.exe', + 'C:\\Program Files\\Chromium\\chromium.exe', + ]) + }) + + it('calls win32() in chrome-finder module and returns the first installation path', async () => { + const chrome = await chromeFinder({}) + + expect(chrome).toStrictEqual({ + path: 'C:\\Program Files\\Google Chrome\\chrome.exe', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(chromeFinderModule.win32).toHaveBeenCalled() + }) + + it('throws error if win32() was returned empty array', async () => { + jest.spyOn(chromeFinderModule, 'win32').mockReturnValue([]) + + await expect(chromeFinder({})).rejects.toThrow(CLIError) + expect(chromeFinderModule.win32).toHaveBeenCalled() + }) + }) + + describe('with WSL1', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('wsl1') + jest + .spyOn(chromeFinderModule, 'wsl') + .mockReturnValue([ + '/mnt/c/Program Files/Google Chrome/chrome.exe', + '/mnt/c/Program Files/Chromium/chromium.exe', + ]) + }) + + it('calls wsl() in chrome-finder module and returns the first installation path', async () => { + const chrome = await chromeFinder({}) + + expect(chrome).toStrictEqual({ + path: '/mnt/c/Program Files/Google Chrome/chrome.exe', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(chromeFinderModule.wsl).toHaveBeenCalled() + }) + + it('throws error if wsl() was returned empty array', async () => { + jest.spyOn(chromeFinderModule, 'wsl').mockReturnValue([]) + + await expect(chromeFinder({})).rejects.toThrow(CLIError) + expect(chromeFinderModule.wsl).toHaveBeenCalled() + }) + }) + + describe('with FreeBSD', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('freebsd') + }) + + it('finds possible binaries from PATH by using which command, and return resolved path', async () => { + jest.spyOn(utils, 'which').mockImplementation(async (command) => { + if (command === 'chrome') return executableMock('empty') + return undefined + }) + + const chrome = await chromeFinder({}) + + expect(chrome).toStrictEqual({ + path: executableMock('empty'), + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(utils.which).toHaveBeenCalledWith('chrome') + }) + + it('throws error if the path was not resolved', async () => { + jest.spyOn(utils, 'which').mockResolvedValue(undefined) + + await expect(chromeFinder({})).rejects.toThrow(CLIError) + expect(utils.which).toHaveBeenCalled() + }) + + it('throws error if the which command has rejected by error', async () => { + jest.spyOn(utils, 'which').mockRejectedValue(new Error('Test error')) + + await expect(chromeFinder({})).rejects.toThrow(CLIError) + expect(utils.which).toHaveBeenCalled() + }) + }) +}) diff --git a/test/browser/finders/edge.ts b/test/browser/finders/edge.ts new file mode 100644 index 00000000..1c19ee1c --- /dev/null +++ b/test/browser/finders/edge.ts @@ -0,0 +1,282 @@ +import path from 'node:path' +import { ChromeBrowser } from '../../../src/browser/browsers/chrome' +import { ChromeCdpBrowser } from '../../../src/browser/browsers/chrome-cdp' +import { edgeFinder } from '../../../src/browser/finders/edge' +import * as utils from '../../../src/browser/finders/utils' +import { CLIError } from '../../../src/error' +import * as wsl from '../../../src/utils/wsl' + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +const winEdgeCanary = ['Microsoft', 'Edge SxS', 'Application', 'msedge.exe'] +const winEdgeDev = ['Microsoft', 'Edge Dev', 'Application', 'msedge.exe'] +const winEdgeBeta = ['Microsoft', 'Edge Beta', 'Application', 'msedge.exe'] +const winEdgeStable = ['Microsoft', 'Edge', 'Application', 'msedge.exe'] + +describe('Edge finder', () => { + describe('with preferred path', () => { + it('returns the preferred path as edge', async () => { + const edge = await edgeFinder({ + preferredPath: '/test/preferred/edge', + }) + + expect(edge).toStrictEqual({ + path: '/test/preferred/edge', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + }) + + describe('with Linux', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('linux') + jest + .spyOn(utils, 'isExecutable') + .mockImplementation(async (p) => p === '/opt/microsoft/msedge/msedge') + }) + + it('finds possible executable path and returns the matched path', async () => { + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + const edge = await edgeFinder({}) + + expect(edge).toStrictEqual({ + path: '/opt/microsoft/msedge/msedge', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(findExecutableSpy).toHaveBeenCalledWith([ + '/opt/microsoft/msedge-canary/msedge', + '/opt/microsoft/msedge-dev/msedge', + '/opt/microsoft/msedge-beta/msedge', + '/opt/microsoft/msedge/msedge', + ]) + }) + + it('prefers the latest version if multiple binaries are matched', async () => { + jest + .spyOn(utils, 'isExecutable') + .mockImplementation( + async (p) => + p === '/opt/microsoft/msedge-dev/msedge' || + p === '/opt/microsoft/msedge-beta/msedge' + ) + + const edge = await edgeFinder({}) + + expect(edge).toStrictEqual({ + path: '/opt/microsoft/msedge-dev/msedge', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + + it('throws error if no executable path is found', async () => { + jest.spyOn(utils, 'isExecutable').mockResolvedValue(false) + await expect(edgeFinder({})).rejects.toThrow(CLIError) + }) + }) + + describe('with macOS', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('darwin') + jest + .spyOn(utils, 'isExecutable') + .mockImplementation( + async (p) => + p === + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' + ) + }) + + it('finds possible executable path and returns the matched path', async () => { + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + const edge = await edgeFinder({}) + + expect(edge).toStrictEqual({ + path: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(findExecutableSpy).toHaveBeenCalledWith([ + '/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary', + '/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev', + '/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ]) + }) + + it('prefers the latest version if multiple binaries are matched', async () => { + jest + .spyOn(utils, 'isExecutable') + .mockImplementation( + async (p) => + p === + '/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev' || + p === + '/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta' + ) + + const edge = await edgeFinder({}) + + expect(edge).toStrictEqual({ + path: '/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + + it('throws error if no executable path is found', async () => { + jest.spyOn(utils, 'isExecutable').mockResolvedValue(false) + await expect(edgeFinder({})).rejects.toThrow(CLIError) + }) + }) + + describe('with Windows', () => { + const winProgramFiles = ['C:', 'Mock', 'Program Files'] + const winProgramFilesX86 = ['C:', 'Mock', 'Program Files (x86)'] + const winLocalAppData = ['C:', 'Mock', 'AppData', 'Local'] + + const edgePath = path.join(...winProgramFiles, ...winEdgeStable) + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + + jest.spyOn(utils, 'getPlatform').mockResolvedValue('win32') + jest + .spyOn(utils, 'isExecutable') + .mockImplementation(async (p) => p === edgePath) + + process.env = { + ...originalEnv, + PROGRAMFILES: path.join(...winProgramFiles), + 'PROGRAMFILES(X86)': path.join(...winProgramFilesX86), + LOCALAPPDATA: path.join(...winLocalAppData), + } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('finds possible executable path and returns the matched path', async () => { + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + const edge = await edgeFinder({}) + + expect(edge).toStrictEqual({ + path: edgePath, + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join(...winLocalAppData, ...winEdgeCanary), + path.join(...winProgramFiles, ...winEdgeCanary), + path.join(...winProgramFilesX86, ...winEdgeCanary), + path.join(...winLocalAppData, ...winEdgeDev), + path.join(...winProgramFiles, ...winEdgeDev), + path.join(...winProgramFilesX86, ...winEdgeDev), + path.join(...winLocalAppData, ...winEdgeBeta), + path.join(...winProgramFiles, ...winEdgeBeta), + path.join(...winProgramFilesX86, ...winEdgeBeta), + path.join(...winLocalAppData, ...winEdgeStable), + path.join(...winProgramFiles, ...winEdgeStable), + path.join(...winProgramFilesX86, ...winEdgeStable), + ]) + }) + + it('skips inaccessible directories to find', async () => { + process.env['PROGRAMFILES(X86)'] = '' // No WOW64 + + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + await edgeFinder({}) + + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join(...winLocalAppData, ...winEdgeCanary), + path.join(...winProgramFiles, ...winEdgeCanary), + path.join(...winLocalAppData, ...winEdgeDev), + path.join(...winProgramFiles, ...winEdgeDev), + path.join(...winLocalAppData, ...winEdgeBeta), + path.join(...winProgramFiles, ...winEdgeBeta), + path.join(...winLocalAppData, ...winEdgeStable), + path.join(...winProgramFiles, ...winEdgeStable), + ]) + }) + + it('throws error if no executable path is found', async () => { + jest.spyOn(utils, 'isExecutable').mockResolvedValue(false) + await expect(edgeFinder({})).rejects.toThrow(CLIError) + }) + }) + + describe('with WSL1', () => { + const wsl1EdgePath = path.join('/mnt/c/Program Files', ...winEdgeStable) + + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('wsl1') + jest + .spyOn(utils, 'isExecutable') + .mockImplementation(async (p) => p === wsl1EdgePath) + jest + .spyOn(wsl, 'resolveWindowsEnvSync') + .mockReturnValue('C:\\Mock\\AppData\\Local') + jest + .spyOn(wsl, 'resolveWSLPathToGuestSync') + .mockReturnValue('/mnt/c/mock/AppData/Local') + }) + + it('finds possible executable path and returns the matched path', async () => { + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + const edge = await edgeFinder({}) + + expect(edge).toStrictEqual({ + path: wsl1EdgePath, + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join('/mnt/c/mock/AppData/Local', ...winEdgeCanary), + path.join('/mnt/c/Program Files', ...winEdgeCanary), + path.join('/mnt/c/Program Files (x86)', ...winEdgeCanary), + path.join('/mnt/c/mock/AppData/Local', ...winEdgeDev), + path.join('/mnt/c/Program Files', ...winEdgeDev), + path.join('/mnt/c/Program Files (x86)', ...winEdgeDev), + path.join('/mnt/c/mock/AppData/Local', ...winEdgeBeta), + path.join('/mnt/c/Program Files', ...winEdgeBeta), + path.join('/mnt/c/Program Files (x86)', ...winEdgeBeta), + path.join('/mnt/c/mock/AppData/Local', ...winEdgeStable), + path.join('/mnt/c/Program Files', ...winEdgeStable), + path.join('/mnt/c/Program Files (x86)', ...winEdgeStable), + ]) + }) + + it('skips inaccessible directories to find', async () => { + jest.spyOn(wsl, 'resolveWindowsEnvSync').mockReturnValue(undefined) + + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + await edgeFinder({}) + + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join('/mnt/c/Program Files', ...winEdgeCanary), + path.join('/mnt/c/Program Files (x86)', ...winEdgeCanary), + path.join('/mnt/c/Program Files', ...winEdgeDev), + path.join('/mnt/c/Program Files (x86)', ...winEdgeDev), + path.join('/mnt/c/Program Files', ...winEdgeBeta), + path.join('/mnt/c/Program Files (x86)', ...winEdgeBeta), + path.join('/mnt/c/Program Files', ...winEdgeStable), + path.join('/mnt/c/Program Files (x86)', ...winEdgeStable), + ]) + }) + + it('throws error if no executable path is found', async () => { + jest.spyOn(utils, 'isExecutable').mockResolvedValue(false) + await expect(edgeFinder({})).rejects.toThrow(CLIError) + }) + }) + + describe('with other platforms', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('openbsd') + }) + + it('throws error', async () => { + await expect(edgeFinder({})).rejects.toThrow(CLIError) + }) + }) +}) diff --git a/test/browser/finders/firefox.ts b/test/browser/finders/firefox.ts new file mode 100644 index 00000000..f75975db --- /dev/null +++ b/test/browser/finders/firefox.ts @@ -0,0 +1,330 @@ +import path from 'node:path' +import which from 'which' +import { FirefoxBrowser } from '../../../src/browser/browsers/firefox' +import { firefoxFinder } from '../../../src/browser/finders/firefox' +import * as utils from '../../../src/browser/finders/utils' +import { CLIError } from '../../../src/error' + +jest.mock('which') + +const mockedWhich = jest.mocked(which<{ nothrow: true }>) + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() + mockedWhich.mockReset() + mockedWhich.mockRestore() +}) + +const winFxNightly = ['Nightly', 'firefox.exe'] +const winFxNightlyAlt = ['Firefox Nightly', 'firefox.exe'] +const winFxDev = ['Firefox Developer Edition', 'firefox.exe'] +const winFx = ['Mozilla Firefox', 'firefox.exe'] + +const executableMock = (name: string) => + path.join(__dirname, `../../utils/_executable_mocks`, name) + +describe('Firefox finder', () => { + describe('with preferred path', () => { + it('returns the preferred path as edge', async () => { + const firefox = await firefoxFinder({ + preferredPath: '/test/preferred/firefox', + }) + + expect(firefox).toStrictEqual({ + path: '/test/preferred/firefox', + acceptedBrowsers: [FirefoxBrowser], + }) + }) + }) + + describe('with Linux', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('linux') + }) + + it('finds possible binaries from PATH by using which command, and return resolved path', async () => { + mockedWhich.mockImplementation(async (command) => { + if (command === 'firefox') return executableMock('empty') + return null + }) + + const firefox = await firefoxFinder({}) + + expect(firefox).toStrictEqual({ + path: executableMock('empty'), + acceptedBrowsers: [FirefoxBrowser], + }) + expect(which).toHaveBeenCalledWith('firefox', { nothrow: true }) + }) + + it('prefers the latest version if multiple binaries are found', async () => { + mockedWhich.mockImplementation(async (command) => { + if (command === 'firefox-nightly') return executableMock('empty') + if (command === 'firefox') return executableMock('shebang-chromium') + + return null + }) + + const firefox = await firefoxFinder({}) + + expect(firefox).toStrictEqual({ + path: executableMock('empty'), + acceptedBrowsers: [FirefoxBrowser], + }) + }) + + it('throws error if the path was not resolved', async () => { + mockedWhich.mockResolvedValue(null) + + await expect(firefoxFinder({})).rejects.toThrow(CLIError) + expect(which).toHaveBeenCalled() + }) + + it('throws error if the which command has rejected by error', async () => { + mockedWhich.mockRejectedValue(new Error('Unexpected error')) + + await expect(firefoxFinder({})).rejects.toThrow(CLIError) + expect(which).toHaveBeenCalled() + }) + }) + + describe('with macOS', () => { + beforeEach(() => { + jest.spyOn(utils, 'getPlatform').mockResolvedValue('darwin') + jest + .spyOn(utils, 'isExecutable') + .mockImplementation( + async (p) => p === '/Applications/Firefox.app/Contents/MacOS/firefox' + ) + }) + + it('finds possible executable path and returns the matched path', async () => { + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + const edge = await firefoxFinder({}) + + expect(edge).toStrictEqual({ + path: '/Applications/Firefox.app/Contents/MacOS/firefox', + acceptedBrowsers: [FirefoxBrowser], + }) + expect(findExecutableSpy).toHaveBeenCalledWith([ + '/Applications/Firefox Nightly.app/Contents/MacOS/firefox', + '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox', + '/Applications/Firefox.app/Contents/MacOS/firefox', + ]) + }) + + it('prefers the latest version if multiple binaries are matched', async () => { + jest + .spyOn(utils, 'isExecutable') + .mockImplementation( + async (p) => + p === + '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox' || + p === '/Applications/Firefox.app/Contents/MacOS/firefox' + ) + + const edge = await firefoxFinder({}) + + expect(edge).toStrictEqual({ + path: '/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox', + acceptedBrowsers: [FirefoxBrowser], + }) + }) + + it('throws error if no executable path is found', async () => { + jest.spyOn(utils, 'isExecutable').mockResolvedValue(false) + await expect(firefoxFinder({})).rejects.toThrow(CLIError) + }) + }) + + describe('with Windows', () => { + const winProgramFiles = ['c:', 'Mock', 'Program Files'] + const winProgramFilesX86 = ['c:', 'Mock', 'Program Files (x86)'] + + const firefoxPath = path.join( + ...winProgramFiles, + 'Mozilla Firefox', + 'firefox.exe' + ) + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + + jest.spyOn(utils, 'getPlatform').mockResolvedValue('win32') + jest + .spyOn(utils, 'isExecutable') + .mockImplementation(async (p) => p === firefoxPath) + + process.env = { + ...originalEnv, + PATH: undefined, + PROGRAMFILES: path.join(...winProgramFiles), + 'PROGRAMFILES(X86)': path.join(...winProgramFilesX86), + } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('finds possible executable path and returns the matched path', async () => { + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + const edge = await firefoxFinder({}) + + expect(edge).toStrictEqual({ + path: firefoxPath, + acceptedBrowsers: [FirefoxBrowser], + }) + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join(...winProgramFiles, ...winFxNightly), + path.join(...winProgramFilesX86, ...winFxNightly), + path.join(...winProgramFiles, ...winFxNightlyAlt), + path.join(...winProgramFilesX86, ...winFxNightlyAlt), + path.join(...winProgramFiles, ...winFxDev), + path.join(...winProgramFilesX86, ...winFxDev), + path.join(...winProgramFiles, ...winFx), + path.join(...winProgramFilesX86, ...winFx), + ]) + }) + + it('skips inaccessible directories to find', async () => { + process.env['PROGRAMFILES(X86)'] = '' // No WOW64 + + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + await firefoxFinder({}) + + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join(...winProgramFiles, ...winFxNightly), + path.join(...winProgramFiles, ...winFxNightlyAlt), + path.join(...winProgramFiles, ...winFxDev), + path.join(...winProgramFiles, ...winFx), + ]) + }) + + it('finds from detected drives when the PATH environment has paths starting with any drive letters', async () => { + process.env.PATH = 'z:\\Mock;D:\\Mock;d:\\Mock\\Mock;' + + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + await firefoxFinder({}) + + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join('c:', ...winProgramFiles.slice(1), ...winFxNightly), + path.join('c:', ...winProgramFilesX86.slice(1), ...winFxNightly), + path.join('d:', ...winProgramFiles.slice(1), ...winFxNightly), + path.join('d:', ...winProgramFilesX86.slice(1), ...winFxNightly), + path.join('z:', ...winProgramFiles.slice(1), ...winFxNightly), + path.join('z:', ...winProgramFilesX86.slice(1), ...winFxNightly), + path.join('c:', ...winProgramFiles.slice(1), ...winFxNightlyAlt), + path.join('c:', ...winProgramFilesX86.slice(1), ...winFxNightlyAlt), + path.join('d:', ...winProgramFiles.slice(1), ...winFxNightlyAlt), + path.join('d:', ...winProgramFilesX86.slice(1), ...winFxNightlyAlt), + path.join('z:', ...winProgramFiles.slice(1), ...winFxNightlyAlt), + path.join('z:', ...winProgramFilesX86.slice(1), ...winFxNightlyAlt), + path.join('c:', ...winProgramFiles.slice(1), ...winFxDev), + path.join('c:', ...winProgramFilesX86.slice(1), ...winFxDev), + path.join('d:', ...winProgramFiles.slice(1), ...winFxDev), + path.join('d:', ...winProgramFilesX86.slice(1), ...winFxDev), + path.join('z:', ...winProgramFiles.slice(1), ...winFxDev), + path.join('z:', ...winProgramFilesX86.slice(1), ...winFxDev), + path.join('c:', ...winProgramFiles.slice(1), ...winFx), + path.join('c:', ...winProgramFilesX86.slice(1), ...winFx), + path.join('d:', ...winProgramFiles.slice(1), ...winFx), + path.join('d:', ...winProgramFilesX86.slice(1), ...winFx), + path.join('z:', ...winProgramFiles.slice(1), ...winFx), + path.join('z:', ...winProgramFilesX86.slice(1), ...winFx), + ]) + }) + + it('throws error if no executable path is found', async () => { + process.env.PROGRAMFILES = '' + process.env['PROGRAMFILES(X86)'] = '' + + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + await expect(firefoxFinder({})).rejects.toThrow(CLIError) + + expect(findExecutableSpy).toHaveBeenCalledWith([]) + }) + }) + + describe('with WSL1', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.resetModules() + + jest.spyOn(utils, 'getPlatform').mockResolvedValue('wsl1') + jest + .spyOn(utils, 'isExecutable') + .mockImplementation( + async (p) => p === '/mnt/c/Program Files/Mozilla Firefox/firefox.exe' + ) + + process.env = { ...originalEnv, PATH: undefined } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('finds possible executable path and returns the matched path', async () => { + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + const edge = await firefoxFinder({}) + + expect(edge).toStrictEqual({ + path: '/mnt/c/Program Files/Mozilla Firefox/firefox.exe', + acceptedBrowsers: [FirefoxBrowser], + }) + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join('/mnt/c/Program Files', ...winFxNightly), + path.join('/mnt/c/Program Files (x86)', ...winFxNightly), + path.join('/mnt/c/Program Files', ...winFxNightlyAlt), + path.join('/mnt/c/Program Files (x86)', ...winFxNightlyAlt), + path.join('/mnt/c/Program Files', ...winFxDev), + path.join('/mnt/c/Program Files (x86)', ...winFxDev), + path.join('/mnt/c/Program Files', ...winFx), + path.join('/mnt/c/Program Files (x86)', ...winFx), + ]) + }) + + it('finds from detected drives when the PATH environment has paths starting with any drive letters', async () => { + process.env.PATH = '/mnt/z/Mock:/mnt/d/Mock:/mnt/d/Mock/Mock' + + const findExecutableSpy = jest.spyOn(utils, 'findExecutable') + await firefoxFinder({}) + + expect(findExecutableSpy).toHaveBeenCalledWith([ + path.join('/mnt/c/Program Files', ...winFxNightly), + path.join('/mnt/c/Program Files (x86)', ...winFxNightly), + path.join('/mnt/d/Program Files', ...winFxNightly), + path.join('/mnt/d/Program Files (x86)', ...winFxNightly), + path.join('/mnt/z/Program Files', ...winFxNightly), + path.join('/mnt/z/Program Files (x86)', ...winFxNightly), + path.join('/mnt/c/Program Files', ...winFxNightlyAlt), + path.join('/mnt/c/Program Files (x86)', ...winFxNightlyAlt), + path.join('/mnt/d/Program Files', ...winFxNightlyAlt), + path.join('/mnt/d/Program Files (x86)', ...winFxNightlyAlt), + path.join('/mnt/z/Program Files', ...winFxNightlyAlt), + path.join('/mnt/z/Program Files (x86)', ...winFxNightlyAlt), + path.join('/mnt/c/Program Files', ...winFxDev), + path.join('/mnt/c/Program Files (x86)', ...winFxDev), + path.join('/mnt/d/Program Files', ...winFxDev), + path.join('/mnt/d/Program Files (x86)', ...winFxDev), + path.join('/mnt/z/Program Files', ...winFxDev), + path.join('/mnt/z/Program Files (x86)', ...winFxDev), + path.join('/mnt/c/Program Files', ...winFx), + path.join('/mnt/c/Program Files (x86)', ...winFx), + path.join('/mnt/d/Program Files', ...winFx), + path.join('/mnt/d/Program Files (x86)', ...winFx), + path.join('/mnt/z/Program Files', ...winFx), + path.join('/mnt/z/Program Files (x86)', ...winFx), + ]) + }) + + it('throws error if no executable path is found', async () => { + jest.spyOn(utils, 'isExecutable').mockResolvedValue(false) + await expect(firefoxFinder({})).rejects.toThrow(CLIError) + }) + }) +}) diff --git a/test/browser/finders/utils.ts b/test/browser/finders/utils.ts new file mode 100644 index 00000000..d6f6a4f4 --- /dev/null +++ b/test/browser/finders/utils.ts @@ -0,0 +1,84 @@ +import path from 'node:path' +import { getPlatform, isSnapBrowser } from '../../../src/browser/finders/utils' +import * as wsl from '../../../src/utils/wsl' + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +const executableMock = (name: string) => + path.join(__dirname, `../../utils/_executable_mocks`, name) + +describe('#getPlatform', () => { + it('returns current platform in non WSL environment', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(0) + expect(await getPlatform()).toBe(process.platform) + }) + + it('returns "wsl1" in WSL 1 environment', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(1) + expect(await getPlatform()).toBe('wsl1') + }) + + it('returns current platform in WSL 2 environment', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(2) + expect(await getPlatform()).toBe(process.platform) + }) +}) + +describe('#isSnapBrowser', () => { + const { platform } = process + + beforeEach(() => { + jest.resetModules() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + }) + + describe('with Linux', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + }) + + it('returns true if the specified path was starting with /snap/', async () => { + expect(await isSnapBrowser('/snap/bin/__dummy__')).toBe(true) + expect(await isSnapBrowser('/usr/local/bin/__dummy__')).toBe(false) + }) + + it('returns false if the specified path is not shebang', async () => { + expect(await isSnapBrowser(executableMock('empty'))).toBe(false) + }) + + it('returns false if the specified path is shebang but not referenced snap', async () => { + expect(await isSnapBrowser(executableMock('shebang-chromium'))).toBe( + false + ) + }) + + it('returns true if the specified path is shebang and referenced snap', async () => { + expect( + await isSnapBrowser(executableMock('shebang-snapd-chromium')) + ).toBe(true) + }) + }) + + describe('with other platforms', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + }) + + it('returns false even if the specified path was starting with /snap/', async () => { + expect(await isSnapBrowser('/snap/bin/__dummy__')).toBe(false) + expect(await isSnapBrowser('/usr/local/bin/__dummy__')).toBe(false) + }) + + it('returns false even if the specified path is shebang and referenced snap', async () => { + expect( + await isSnapBrowser(executableMock('shebang-snapd-chromium')) + ).toBe(false) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index a67db740..6a0a10f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1907,6 +1907,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== +"@types/which@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/which/-/which-3.0.4.tgz#2c3a89be70c56a84a6957a7264639f39ae4340a1" + integrity sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w== + "@types/ws@^8.5.12": version "8.5.12" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.12.tgz#619475fe98f35ccca2a2f6c137702d85ec247b7e" @@ -4743,6 +4748,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +isexe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" + integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== + istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" @@ -8566,6 +8576,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +which@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/which/-/which-4.0.0.tgz#cd60b5e74503a3fbcfbf6cd6b4138a8bae644c1a" + integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== + dependencies: + isexe "^3.1.1" + wicked-good-xpath@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz#81b0e95e8650e49c94b22298fff8686b5553cf6c" From 61d68cfeb3118b5f45007df4f5a09a444f87e53e Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Mon, 23 Sep 2024 02:33:00 +0900 Subject: [PATCH 08/13] Fix ESLint warnings --- src/watcher.ts | 5 ++--- test/converter.ts | 4 ++-- test/engine.ts | 2 +- test/server.ts | 2 +- test/templates/bespoke.ts | 2 +- test/watcher.ts | 10 ++++------ 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/watcher.ts b/src/watcher.ts index 21ab7ecb..ddb48b66 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import crypto from 'node:crypto' import path from 'node:path' -import chokidar, { type FSWatcher } from 'chokidar' +import { watch as chokidarWatch, type FSWatcher } from 'chokidar' import { getPortPromise } from 'portfinder' import WS, { type ServerOptions } from 'ws' import { Converter, ConvertedCallback } from './converter' @@ -22,8 +22,7 @@ export class Watcher { this.finder = opts.finder this.mode = opts.mode - this.chokidar = chokidar - .watch(watchPath, { ignoreInitial: true }) + this.chokidar = chokidarWatch(watchPath, { ignoreInitial: true }) .on('change', (f) => this.convert(f)) .on('add', (f) => this.convert(f)) .on('unlink', (f) => this.delete(f)) diff --git a/test/converter.ts b/test/converter.ts index 026c567c..d82e0974 100644 --- a/test/converter.ts +++ b/test/converter.ts @@ -9,7 +9,7 @@ import { load } from 'cheerio' import { imageSize } from 'image-size' import { PDFDocument, PDFDict, PDFName, PDFHexString, PDFNumber } from 'pdf-lib' import { TimeoutError } from 'puppeteer-core' -import yauzl from 'yauzl' +import { fromBuffer as yauzlFromBuffer } from 'yauzl' import { Converter, ConvertType, ConverterOption } from '../src/converter' import { CLIError } from '../src/error' import { File, FileType } from '../src/file' @@ -930,7 +930,7 @@ describe('Converter', () => { const getPptxDocProps = async (buffer: Buffer) => { // Require to ignore type definition by casting into any :( // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/20497 - const zip = await (promisify(yauzl.fromBuffer) as any)(buffer, { + const zip = await (promisify(yauzlFromBuffer) as any)(buffer, { lazyEntries: true, }) diff --git a/test/engine.ts b/test/engine.ts index 0ec48cdc..a761684e 100644 --- a/test/engine.ts +++ b/test/engine.ts @@ -1,4 +1,4 @@ -import Marp from '@marp-team/marp-core' +import { Marp } from '@marp-team/marp-core' import { ResolvedEngine } from '../src/engine' afterEach(() => jest.restoreAllMocks()) diff --git a/test/server.ts b/test/server.ts index d091441d..a7bf90ac 100644 --- a/test/server.ts +++ b/test/server.ts @@ -1,6 +1,6 @@ import { ClientRequest } from 'node:http' import path from 'node:path' -import Marp from '@marp-team/marp-core' +import { Marp } from '@marp-team/marp-core' import { load } from 'cheerio' import express, { type Express } from 'express' import request from 'supertest' diff --git a/test/templates/bespoke.ts b/test/templates/bespoke.ts index d19f7467..5368e449 100644 --- a/test/templates/bespoke.ts +++ b/test/templates/bespoke.ts @@ -1,6 +1,6 @@ /** @jest-environment jsdom */ import '../_browser/matchMedia' -import Marp from '@marp-team/marp-core' +import { Marp } from '@marp-team/marp-core' import { Element as MarpitElement } from '@marp-team/marpit' import { Key } from 'ts-key-enum' import bespoke from '../../src/templates/bespoke/bespoke' diff --git a/test/watcher.ts b/test/watcher.ts index b07fba1a..b2a65dd2 100644 --- a/test/watcher.ts +++ b/test/watcher.ts @@ -1,6 +1,5 @@ import http from 'node:http' -import chokidar from 'chokidar' -// import * as portfinder from 'portfinder' +import { watch as chokidarWatch } from 'chokidar' import { File, FileType } from '../src/file' import { ThemeSet } from '../src/theme' import { Watcher, WatchNotifier, notifier } from '../src/watcher' @@ -15,6 +14,7 @@ jest.mock('chokidar', () => ({ }), })), })) + jest.mock('ws', () => ({ Server: jest.fn(() => ({ clients: [], @@ -22,6 +22,7 @@ jest.mock('ws', () => ({ on: mockWsOn, })), })) + jest.mock('../src/watcher') jest.mock('../src/theme') @@ -61,10 +62,7 @@ describe('Watcher', () => { const watcher = createWatcher() expect(watcher).toBeInstanceOf(Watcher) - expect(chokidar.watch).toHaveBeenCalledWith( - ['test.md'], - expect.anything() - ) + expect(chokidarWatch).toHaveBeenCalledWith(['test.md'], expect.anything()) expect(notifier.start).toHaveBeenCalled() // Chokidar events From 93e6701f4cf296322d601cdcdeba860b3bb174a6 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Mon, 23 Sep 2024 03:19:42 +0900 Subject: [PATCH 09/13] Force posix path join to resolve WSL1 path --- src/browser/finders/edge.ts | 5 ++++- src/browser/finders/firefox.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/browser/finders/edge.ts b/src/browser/finders/edge.ts index 8c511288..20996a3c 100644 --- a/src/browser/finders/edge.ts +++ b/src/browser/finders/edge.ts @@ -57,10 +57,12 @@ const edgeFinderWin32 = async ({ programFiles = process.env.PROGRAMFILES, programFilesX86 = process.env['PROGRAMFILES(X86)'], localAppData = process.env.LOCALAPPDATA, + join = path.join, }: { programFiles?: string programFilesX86?: string localAppData?: string + join?: typeof path.join } = {}): Promise => { const paths: string[] = [] @@ -73,7 +75,7 @@ const edgeFinderWin32 = async ({ for (const suffix of suffixes) { for (const prefix of [localAppData, programFiles, programFilesX86]) { - if (prefix) paths.push(path.join(prefix, ...suffix)) + if (prefix) paths.push(join(prefix, ...suffix)) } } @@ -87,5 +89,6 @@ const edgeFinderWSL1 = async () => { programFiles: '/mnt/c/Program Files', programFilesX86: '/mnt/c/Program Files (x86)', localAppData: localAppData && resolveWSLPathToGuestSync(localAppData), + join: path.posix.join, }) } diff --git a/src/browser/finders/firefox.ts b/src/browser/finders/firefox.ts index 813fbbbe..fdbffca1 100644 --- a/src/browser/finders/firefox.ts +++ b/src/browser/finders/firefox.ts @@ -110,7 +110,7 @@ const firefoxFinderWSL1 = async () => { winFirefoxDevEdition, winFirefoxDefault, ].flatMap((suffix) => - prefixes.map((prefix) => path.join(prefix, ...suffix)) + prefixes.map((prefix) => path.posix.join(prefix, ...suffix)) ) ) } From 5cd5a6f165338202108c2d378faef8ea43542bf6 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Mon, 23 Sep 2024 03:39:41 +0900 Subject: [PATCH 10/13] Fix WSL1 tests to force POSIX path separator --- test/browser/finders/edge.ts | 45 ++++++++++++----------- test/browser/finders/firefox.ts | 64 ++++++++++++++++----------------- 2 files changed, 56 insertions(+), 53 deletions(-) diff --git a/test/browser/finders/edge.ts b/test/browser/finders/edge.ts index 1c19ee1c..c4f6ad35 100644 --- a/test/browser/finders/edge.ts +++ b/test/browser/finders/edge.ts @@ -207,7 +207,10 @@ describe('Edge finder', () => { }) describe('with WSL1', () => { - const wsl1EdgePath = path.join('/mnt/c/Program Files', ...winEdgeStable) + const wsl1EdgePath = path.posix.join( + '/mnt/c/Program Files', + ...winEdgeStable + ) beforeEach(() => { jest.spyOn(utils, 'getPlatform').mockResolvedValue('wsl1') @@ -231,18 +234,18 @@ describe('Edge finder', () => { acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], }) expect(findExecutableSpy).toHaveBeenCalledWith([ - path.join('/mnt/c/mock/AppData/Local', ...winEdgeCanary), - path.join('/mnt/c/Program Files', ...winEdgeCanary), - path.join('/mnt/c/Program Files (x86)', ...winEdgeCanary), - path.join('/mnt/c/mock/AppData/Local', ...winEdgeDev), - path.join('/mnt/c/Program Files', ...winEdgeDev), - path.join('/mnt/c/Program Files (x86)', ...winEdgeDev), - path.join('/mnt/c/mock/AppData/Local', ...winEdgeBeta), - path.join('/mnt/c/Program Files', ...winEdgeBeta), - path.join('/mnt/c/Program Files (x86)', ...winEdgeBeta), - path.join('/mnt/c/mock/AppData/Local', ...winEdgeStable), - path.join('/mnt/c/Program Files', ...winEdgeStable), - path.join('/mnt/c/Program Files (x86)', ...winEdgeStable), + path.posix.join('/mnt/c/mock/AppData/Local', ...winEdgeCanary), + path.posix.join('/mnt/c/Program Files', ...winEdgeCanary), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeCanary), + path.posix.join('/mnt/c/mock/AppData/Local', ...winEdgeDev), + path.posix.join('/mnt/c/Program Files', ...winEdgeDev), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeDev), + path.posix.join('/mnt/c/mock/AppData/Local', ...winEdgeBeta), + path.posix.join('/mnt/c/Program Files', ...winEdgeBeta), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeBeta), + path.posix.join('/mnt/c/mock/AppData/Local', ...winEdgeStable), + path.posix.join('/mnt/c/Program Files', ...winEdgeStable), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeStable), ]) }) @@ -253,14 +256,14 @@ describe('Edge finder', () => { await edgeFinder({}) expect(findExecutableSpy).toHaveBeenCalledWith([ - path.join('/mnt/c/Program Files', ...winEdgeCanary), - path.join('/mnt/c/Program Files (x86)', ...winEdgeCanary), - path.join('/mnt/c/Program Files', ...winEdgeDev), - path.join('/mnt/c/Program Files (x86)', ...winEdgeDev), - path.join('/mnt/c/Program Files', ...winEdgeBeta), - path.join('/mnt/c/Program Files (x86)', ...winEdgeBeta), - path.join('/mnt/c/Program Files', ...winEdgeStable), - path.join('/mnt/c/Program Files (x86)', ...winEdgeStable), + path.posix.join('/mnt/c/Program Files', ...winEdgeCanary), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeCanary), + path.posix.join('/mnt/c/Program Files', ...winEdgeDev), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeDev), + path.posix.join('/mnt/c/Program Files', ...winEdgeBeta), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeBeta), + path.posix.join('/mnt/c/Program Files', ...winEdgeStable), + path.posix.join('/mnt/c/Program Files (x86)', ...winEdgeStable), ]) }) diff --git a/test/browser/finders/firefox.ts b/test/browser/finders/firefox.ts index f75975db..fc19adc4 100644 --- a/test/browser/finders/firefox.ts +++ b/test/browser/finders/firefox.ts @@ -277,14 +277,14 @@ describe('Firefox finder', () => { acceptedBrowsers: [FirefoxBrowser], }) expect(findExecutableSpy).toHaveBeenCalledWith([ - path.join('/mnt/c/Program Files', ...winFxNightly), - path.join('/mnt/c/Program Files (x86)', ...winFxNightly), - path.join('/mnt/c/Program Files', ...winFxNightlyAlt), - path.join('/mnt/c/Program Files (x86)', ...winFxNightlyAlt), - path.join('/mnt/c/Program Files', ...winFxDev), - path.join('/mnt/c/Program Files (x86)', ...winFxDev), - path.join('/mnt/c/Program Files', ...winFx), - path.join('/mnt/c/Program Files (x86)', ...winFx), + path.posix.join('/mnt/c/Program Files', ...winFxNightly), + path.posix.join('/mnt/c/Program Files (x86)', ...winFxNightly), + path.posix.join('/mnt/c/Program Files', ...winFxNightlyAlt), + path.posix.join('/mnt/c/Program Files (x86)', ...winFxNightlyAlt), + path.posix.join('/mnt/c/Program Files', ...winFxDev), + path.posix.join('/mnt/c/Program Files (x86)', ...winFxDev), + path.posix.join('/mnt/c/Program Files', ...winFx), + path.posix.join('/mnt/c/Program Files (x86)', ...winFx), ]) }) @@ -295,30 +295,30 @@ describe('Firefox finder', () => { await firefoxFinder({}) expect(findExecutableSpy).toHaveBeenCalledWith([ - path.join('/mnt/c/Program Files', ...winFxNightly), - path.join('/mnt/c/Program Files (x86)', ...winFxNightly), - path.join('/mnt/d/Program Files', ...winFxNightly), - path.join('/mnt/d/Program Files (x86)', ...winFxNightly), - path.join('/mnt/z/Program Files', ...winFxNightly), - path.join('/mnt/z/Program Files (x86)', ...winFxNightly), - path.join('/mnt/c/Program Files', ...winFxNightlyAlt), - path.join('/mnt/c/Program Files (x86)', ...winFxNightlyAlt), - path.join('/mnt/d/Program Files', ...winFxNightlyAlt), - path.join('/mnt/d/Program Files (x86)', ...winFxNightlyAlt), - path.join('/mnt/z/Program Files', ...winFxNightlyAlt), - path.join('/mnt/z/Program Files (x86)', ...winFxNightlyAlt), - path.join('/mnt/c/Program Files', ...winFxDev), - path.join('/mnt/c/Program Files (x86)', ...winFxDev), - path.join('/mnt/d/Program Files', ...winFxDev), - path.join('/mnt/d/Program Files (x86)', ...winFxDev), - path.join('/mnt/z/Program Files', ...winFxDev), - path.join('/mnt/z/Program Files (x86)', ...winFxDev), - path.join('/mnt/c/Program Files', ...winFx), - path.join('/mnt/c/Program Files (x86)', ...winFx), - path.join('/mnt/d/Program Files', ...winFx), - path.join('/mnt/d/Program Files (x86)', ...winFx), - path.join('/mnt/z/Program Files', ...winFx), - path.join('/mnt/z/Program Files (x86)', ...winFx), + path.posix.join('/mnt/c/Program Files', ...winFxNightly), + path.posix.join('/mnt/c/Program Files (x86)', ...winFxNightly), + path.posix.join('/mnt/d/Program Files', ...winFxNightly), + path.posix.join('/mnt/d/Program Files (x86)', ...winFxNightly), + path.posix.join('/mnt/z/Program Files', ...winFxNightly), + path.posix.join('/mnt/z/Program Files (x86)', ...winFxNightly), + path.posix.join('/mnt/c/Program Files', ...winFxNightlyAlt), + path.posix.join('/mnt/c/Program Files (x86)', ...winFxNightlyAlt), + path.posix.join('/mnt/d/Program Files', ...winFxNightlyAlt), + path.posix.join('/mnt/d/Program Files (x86)', ...winFxNightlyAlt), + path.posix.join('/mnt/z/Program Files', ...winFxNightlyAlt), + path.posix.join('/mnt/z/Program Files (x86)', ...winFxNightlyAlt), + path.posix.join('/mnt/c/Program Files', ...winFxDev), + path.posix.join('/mnt/c/Program Files (x86)', ...winFxDev), + path.posix.join('/mnt/d/Program Files', ...winFxDev), + path.posix.join('/mnt/d/Program Files (x86)', ...winFxDev), + path.posix.join('/mnt/z/Program Files', ...winFxDev), + path.posix.join('/mnt/z/Program Files (x86)', ...winFxDev), + path.posix.join('/mnt/c/Program Files', ...winFx), + path.posix.join('/mnt/c/Program Files (x86)', ...winFx), + path.posix.join('/mnt/d/Program Files', ...winFx), + path.posix.join('/mnt/d/Program Files (x86)', ...winFx), + path.posix.join('/mnt/z/Program Files', ...winFx), + path.posix.join('/mnt/z/Program Files (x86)', ...winFx), ]) }) From fe17a32d0681c07ced3c18b679706b02b41deb37 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Mon, 23 Sep 2024 03:48:30 +0900 Subject: [PATCH 11/13] Fix including unexpected paths while finding Edge on WSL1 --- src/browser/finders/edge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/finders/edge.ts b/src/browser/finders/edge.ts index 20996a3c..4c582b3a 100644 --- a/src/browser/finders/edge.ts +++ b/src/browser/finders/edge.ts @@ -88,7 +88,7 @@ const edgeFinderWSL1 = async () => { return await edgeFinderWin32({ programFiles: '/mnt/c/Program Files', programFilesX86: '/mnt/c/Program Files (x86)', - localAppData: localAppData && resolveWSLPathToGuestSync(localAppData), + localAppData: localAppData ? resolveWSLPathToGuestSync(localAppData) : '', join: path.posix.join, }) } From 357e32f20cc9add6ee42960537c44790a639f0e4 Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Mon, 23 Sep 2024 13:36:46 +0900 Subject: [PATCH 12/13] Add lacked tests --- test/browser/manager.ts | 56 +++++++++++++++++++++++++++++++++++++++++ test/utils/wsl.ts | 11 ++++++++ 2 files changed, 67 insertions(+) create mode 100644 test/browser/manager.ts diff --git a/test/browser/manager.ts b/test/browser/manager.ts new file mode 100644 index 00000000..5f76127a --- /dev/null +++ b/test/browser/manager.ts @@ -0,0 +1,56 @@ +import { browserManager, BrowserManager } from '../../src/browser/manager' +import { ChromeBrowser } from '../../src/browser/browsers/chrome' + +describe('browserManager static instance', () => { + it('is an instance of BrowserManager', () => { + expect(browserManager).toBeInstanceOf(BrowserManager) + }) +}) + +describe('BrowserManager class', () => { + describe('#register', () => { + it('registers a browser', () => { + const previewBrowser = new ChromeBrowser({ purpose: 'preview' }) + const convertBrowser = new ChromeBrowser({ purpose: 'convert' }) + const manager = new BrowserManager() + + expect(manager.findBy({ browser: previewBrowser })).toBeUndefined() + expect(manager.findBy({ browser: convertBrowser })).toBeUndefined() + + manager.register(convertBrowser) + expect(manager.findBy({ browser: previewBrowser })).toBeUndefined() + expect(manager.findBy({ browser: convertBrowser })).toBe(convertBrowser) + + manager.register(previewBrowser) + expect(manager.findBy({ browser: previewBrowser })).toBe(previewBrowser) + expect(manager.findBy({ browser: convertBrowser })).toBe(convertBrowser) + }) + }) + + describe('#findBy', () => { + it('finds a browser by query', () => { + const browser = new ChromeBrowser({ purpose: 'convert' }) + const manager = new BrowserManager() + + manager.register(browser) + + // Find by instance + expect(manager.findBy({ browser })).toBe(browser) + expect( + manager.findBy({ browser: new ChromeBrowser({ purpose: 'preview' }) }) + ).toBeUndefined() + + // Find by kind + expect(manager.findBy({ kind: 'chrome' })).toBe(browser) + expect(manager.findBy({ kind: 'firefox' })).toBeUndefined() + + // Find by protocol + expect(manager.findBy({ protocol: 'webdriver-bidi' })).toBe(browser) + expect(manager.findBy({ protocol: 'cdp' })).toBeUndefined() + + // Find by purpose + expect(manager.findBy({ purpose: 'convert' })).toBe(browser) + expect(manager.findBy({ purpose: 'preview' })).toBeUndefined() + }) + }) +}) diff --git a/test/utils/wsl.ts b/test/utils/wsl.ts index ba8c6c26..127ac58b 100644 --- a/test/utils/wsl.ts +++ b/test/utils/wsl.ts @@ -172,6 +172,17 @@ describe('#isWSL', () => { expect(await wsl().isWSL()).toBe(2) expect(readFile).toHaveBeenCalledTimes(1) }) + + it('returns 1 if throing an error while checking WSL 2', async () => { + jest.doMock('is-wsl', () => true) + + const readFile = jest + .spyOn(fs.promises, 'readFile') + .mockRejectedValue(new Error('Failed to read file')) + + expect(await wsl().isWSL()).toBe(1) + expect(readFile).toHaveBeenCalledTimes(1) + }) }) describe('#isChromeInWSLHost', () => { From 5d3f750899cf8c9fdba7aac746b3281e4baa5c9a Mon Sep 17 00:00:00 2001 From: Yuki Hattori Date: Mon, 23 Sep 2024 13:46:03 +0900 Subject: [PATCH 13/13] Assume WSL is primary version 2 when throwing error while checking WSL2 --- src/utils/wsl.ts | 6 ++++-- test/browser/manager.ts | 2 +- test/utils/wsl.ts | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/wsl.ts b/src/utils/wsl.ts index 88cb720c..c9cd26e2 100644 --- a/src/utils/wsl.ts +++ b/src/utils/wsl.ts @@ -50,8 +50,10 @@ export const isWSL = async (): Promise => { const gccMatched = verStr.match(/gcc[^,]+?(\d+)\.\d+\.\d+/) if (gccMatched && Number.parseInt(gccMatched[1], 10) >= 8) return true - } catch { - // no ops + } catch (e) { + debug('Error while detecting WSL version: %o', e) + debug('Assuming current WSL version is the primary version 2') + return true } })() diff --git a/test/browser/manager.ts b/test/browser/manager.ts index 5f76127a..88207ba0 100644 --- a/test/browser/manager.ts +++ b/test/browser/manager.ts @@ -1,5 +1,5 @@ -import { browserManager, BrowserManager } from '../../src/browser/manager' import { ChromeBrowser } from '../../src/browser/browsers/chrome' +import { browserManager, BrowserManager } from '../../src/browser/manager' describe('browserManager static instance', () => { it('is an instance of BrowserManager', () => { diff --git a/test/utils/wsl.ts b/test/utils/wsl.ts index 127ac58b..a2bfc60a 100644 --- a/test/utils/wsl.ts +++ b/test/utils/wsl.ts @@ -173,14 +173,14 @@ describe('#isWSL', () => { expect(readFile).toHaveBeenCalledTimes(1) }) - it('returns 1 if throing an error while checking WSL 2', async () => { + it('returns 2 if throing an error while checking WSL 2 because 2 is the primary version of current WSL', async () => { jest.doMock('is-wsl', () => true) const readFile = jest .spyOn(fs.promises, 'readFile') .mockRejectedValue(new Error('Failed to read file')) - expect(await wsl().isWSL()).toBe(1) + expect(await wsl().isWSL()).toBe(2) expect(readFile).toHaveBeenCalledTimes(1) }) })