diff --git a/CHANGELOG.md b/CHANGELOG.md index a6fb4c6b..eaec32a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Added + + - CI testing against Node.js v22 ([#591](https://github.com/marp-team/marp-cli/pull/591)) ### Changed diff --git a/jest.config.js b/jest.config.js index 646ff641..be3d298c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -35,6 +35,7 @@ module.exports = { coverageProvider: 'v8', coverageThreshold: { global: { lines: 95 } }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + prettierPath: null, setupFiles: ['./jest.setup.js'], transform: { ...jsWithBabel.transform, diff --git a/package.json b/package.json index 2e068d78..e02296e6 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,7 @@ }, "pkg": { "scripts": "lib/**/*.js", - "assets": [ - "node_modules/vm2/**/*" - ] + "assets": ["tmp/icu/icudt*.dat", "node_modules/sharp/**/*", "node_modules/@img/**/*"] }, "browserslist": [ "> 1% and last 3 versions", @@ -61,7 +59,7 @@ "lint:css": "stylelint \"src/**/*.{css,scss}\"", "prepack": "npm-run-all --parallel check:* lint:* test:coverage --parallel build types", "preversion": "run-p check:* lint:* test:coverage", - "standalone": "node -e 'fs.rmSync(`bin`,{recursive:true,force:true})' && pkg --out-path ./bin .", + "standalone": "node -e 'fs.rmSync(`bin`,{recursive:true,force:true})' && pkg --options \"icu-data-dir=$(node ./scripts/icu.mjs)\" -C gzip --out-path ./bin .", "standalone:pack": "node ./scripts/pack.js", "test": "jest", "test:coverage": "jest --coverage", @@ -82,11 +80,12 @@ "@tsconfig/node20": "^20.1.4", "@tsconfig/recommended": "^1.0.7", "@types/cheerio": "^0.22.35", + "@types/debug": "^4.1.12", "@types/dom-view-transitions": "^1.0.5", "@types/express": "^4.17.21", "@types/jest": "^29.5.13", "@types/markdown-it": "^14.1.2", - "@types/node": "~16.18.108", + "@types/node": "~18.19.50", "@types/pug": "^2.0.10", "@types/supertest": "^6.0.2", "@types/which": "^3.0.4", @@ -162,6 +161,7 @@ "cosmiconfig": "^9.0.0", "puppeteer-core": "23.3.0", "serve-index": "^1.9.1", + "sharp": "^0.33.5", "tmp": "^0.2.3", "ws": "^8.18.0", "yargs": "^17.7.2" diff --git a/scripts/icu.mjs b/scripts/icu.mjs new file mode 100644 index 00000000..19e63846 --- /dev/null +++ b/scripts/icu.mjs @@ -0,0 +1,53 @@ +/* For baking the ICU data into the standalone binary, we need to download the compatible ICU data from the ICU repository. */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import yauzl from 'yauzl' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const icuDir = path.join(__dirname, '../tmp/icu') +await fs.promises.mkdir(icuDir, { recursive: true }) + +const zipFromBuffer = promisify(yauzl.fromBuffer) + +// Get the ICU version and endianness +const [icuMajor, icuMinor] = process.versions.icu.split('.') +const icuEndianness = process.config.variables.icu_endianness.toLowerCase() + +// Download the ICU data +const response = await fetch( + `https://github.com/unicode-org/icu/releases/download/release-${icuMajor}-${icuMinor}/icu4c-${icuMajor}_${icuMinor}-data-bin-${icuEndianness}.zip` +) + +if (!response.ok) { + throw new Error(`Failed to download ICU data: ${response.statusText}`) +} + +// Extract the ICU data +const zip = await zipFromBuffer(Buffer.from(await response.arrayBuffer()), { + lazyEntries: true, +}) + +const icuDat = await new Promise((res, rej) => { + zip.on('error', (err) => rej(err)) + zip.on('entry', async (entry) => { + if (/icudt\d+.\.dat/.test(entry.fileName)) { + zip.openReadStream(entry, (err, readStream) => { + if (err) return rej(err) + + const output = path.join(icuDir, entry.fileName) + + readStream.pipe(fs.createWriteStream(output)) + res(output) + }) + } else { + zip.readEntry() + } + }) + zip.on('end', () => rej(new Error('Failed to find ICU data in the archive'))) + zip.readEntry() +}) + +// Print the relative path to the ICU data from the project root +console.log(path.relative(path.join(__dirname, '../'), icuDat)) diff --git a/src/browser/browser.ts b/src/browser/browser.ts index e49de74a..fb2a1d24 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -1,21 +1,50 @@ +import { EventEmitter } from 'node:events' +import { launch } from 'puppeteer-core' +import type { + Browser as PuppeteerBrowser, + ProtocolType, + PuppeteerLaunchOptions, + Page, +} from 'puppeteer-core' +import type TypedEventEmitter from 'typed-emitter' +import { isWSL } from '../utils/wsl' + export type BrowserKind = 'chrome' | 'firefox' -export type BrowserProtocol = 'webdriver-bidi' | 'cdp' -export type BrowserPurpose = 'convert' | 'preview' +export type BrowserProtocol = ProtocolType export interface BrowserOptions { - purpose: BrowserPurpose + path: string + timeout?: number +} + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- TypedEventEmitter is only compatible with type +type BrowserEvents = { + close: (browser: PuppeteerBrowser) => void + disconnect: (browser: PuppeteerBrowser) => void + launch: (browser: PuppeteerBrowser) => void } -export abstract class Browser { +const wslHostMatcher = /^\/mnt\/[a-z]\// + +export abstract class Browser + extends (EventEmitter as new () => TypedEventEmitter) + implements AsyncDisposable +{ static readonly kind: BrowserKind static readonly protocol: BrowserProtocol - // --- - - purpose: BrowserPurpose + path: string + protocolTimeout: number + puppeteer: PuppeteerBrowser | undefined + timeout: number constructor(opts: BrowserOptions) { - this.purpose = opts.purpose + super() + + this.path = opts.path + this.timeout = opts.timeout ?? 30000 + this.protocolTimeout = + this.timeout === 0 ? 0 : Math.max(180_000, this.timeout) } get kind() { @@ -25,4 +54,81 @@ export abstract class Browser { get protocol() { return (this.constructor as typeof Browser).protocol } + + async launch(opts: PuppeteerLaunchOptions = {}): Promise { + if (!this.puppeteer) { + const puppeteer = await this.launchPuppeteer(opts) + + puppeteer.once('disconnected', () => { + this.emit('disconnect', puppeteer) + this.puppeteer = undefined + }) + + this.puppeteer = puppeteer + this.emit('launch', puppeteer) + + return puppeteer + } + return this.puppeteer + } + + async withPage(fn: (page: Page) => T) { + const puppeteer = await this.launch() + const page = await puppeteer.newPage() + + page.setDefaultTimeout(this.timeout) + page.setDefaultNavigationTimeout(this.timeout) + + try { + return await fn(page) + } finally { + await page.close() + } + } + + async close() { + if (this.puppeteer) { + const { puppeteer } = this + + if (puppeteer.connected) { + await puppeteer.close() + this.emit('close', puppeteer) + } + + this.puppeteer = undefined + } + } + + async [Symbol.asyncDispose]() { + await this.close() + } + + async browserInWSLHost(): Promise { + return ( + !!(await isWSL()) && + wslHostMatcher.test(this.puppeteer?.process()?.spawnfile ?? this.path) + ) + } + + /** @internal Overload in subclass to customize launch behavior */ + protected async launchPuppeteer( + opts: PuppeteerLaunchOptions + ): Promise { + return await launch(this.generateLaunchOptions(opts)) + } + + /** @internal */ + protected generateLaunchOptions( + mergeOptions: PuppeteerLaunchOptions = {} + ): PuppeteerLaunchOptions { + return { + browser: this.kind, + executablePath: this.path, + headless: true, + protocol: this.protocol, + protocolTimeout: this.protocolTimeout, + timeout: this.timeout, + ...mergeOptions, + } + } } diff --git a/src/browser/browsers/chrome-cdp.ts b/src/browser/browsers/chrome-cdp.ts index 80946ebf..2a7d4e5a 100644 --- a/src/browser/browsers/chrome-cdp.ts +++ b/src/browser/browsers/chrome-cdp.ts @@ -1,6 +1,26 @@ -import { Browser } from '../browser' +import type { PuppeteerLaunchOptions } from 'puppeteer-core' +import macDockIcon from '../../assets/mac-dock-icon.png' +import { BrowserProtocol } from '../browser' +import { ChromeBrowser } from './chrome' -export class ChromeCdpBrowser extends Browser { - static readonly kind = 'chrome' as const - static readonly protocol = 'cdp' as const +export class ChromeCdpBrowser extends ChromeBrowser { + static readonly protocol: BrowserProtocol = 'cdp' + + protected async launchPuppeteer(opts: PuppeteerLaunchOptions) { + const puppeteer = await super.launchPuppeteer(opts) + + // macOS specific: Set Marp icon asynchrnously + if (process.platform === 'darwin') { + puppeteer + .target() + .createCDPSession() + .then((session) => { + session + .send('Browser.setDockTile', { image: macDockIcon.slice(22) }) + .catch(() => void 0) + }) + } + + return puppeteer + } } diff --git a/src/browser/browsers/chrome.ts b/src/browser/browsers/chrome.ts index 2f17f438..a59bae09 100644 --- a/src/browser/browsers/chrome.ts +++ b/src/browser/browsers/chrome.ts @@ -1,6 +1,145 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { nanoid } from 'nanoid' +import { launch } from 'puppeteer-core' +import type { + Browser as PuppeteerBrowser, + PuppeteerLaunchOptions, +} from 'puppeteer-core' +import { CLIErrorCode, error, isError } from '../../error' +import { isInsideContainer } from '../../utils/container' +import { + isWSL, + resolveWindowsEnv, + resolveWSLPathToGuestSync, +} from '../../utils/wsl' import { Browser } from '../browser' +import type { BrowserKind, BrowserProtocol, BrowserOptions } from '../browser' +import { isSnapBrowser } from '../finders/utils' + +let wslTmp: string | undefined export class ChromeBrowser extends Browser { - static readonly kind = 'chrome' as const - static readonly protocol = 'webdriver-bidi' as const + static readonly kind: BrowserKind = 'chrome' + static readonly protocol: BrowserProtocol = 'webDriverBiDi' + + private _dataDirName: string + + constructor(opts: BrowserOptions) { + super(opts) + + this._dataDirName = `marp-cli-${nanoid(10)}` + } + + protected async launchPuppeteer( + opts: PuppeteerLaunchOptions + ): Promise { + const ignoreDefaultArgsSet = new Set( + typeof opts.ignoreDefaultArgs === 'object' ? opts.ignoreDefaultArgs : [] + ) + + // Escape hatch for force-extensions policy for Chrome enterprise + // https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-windows + // https://github.com/marp-team/marp-cli/issues/231 + if (process.env.CHROME_ENABLE_EXTENSIONS) { + ignoreDefaultArgsSet.add('--disable-extensions') + } + + const baseOpts = this.generateLaunchOptions({ + headless: this.puppeteerHeadless(), + pipe: await this.puppeteerPipe(), + userDataDir: await this.createPuppeteerDataDir(), + ...opts, + args: await this.puppeteerArgs(opts.args ?? []), + ignoreDefaultArgs: opts.ignoreDefaultArgs === true || [ + ...ignoreDefaultArgsSet, + ], + }) + + const tryLaunch = async ( + extraOpts: PuppeteerLaunchOptions = {} + ): Promise => { + const finalizedOpts = { ...baseOpts, ...extraOpts } + + try { + return await launch(finalizedOpts) + } catch (e: unknown) { + if (isError(e)) { + // Retry to launch with WebSocket connection if failed to connect to Chrome with pipe + // https://github.com/puppeteer/puppeteer/issues/6258 + if (finalizedOpts.pipe) { + return await tryLaunch({ ...extraOpts, pipe: false }) + } + + // User-friendly warning when tried to spawn the snap browser within the snapd container + if ( + /need to run as root or suid/im.test(e.message) && + (await isSnapBrowser(this.path)) + ) { + error( + 'Marp CLI has detected trying to spawn Chromium browser installed by snap, from the confined environment like another snap app. At least either of Chrome/Chromium or the shell environment must be non snap app.', + CLIErrorCode.CANNOT_SPAWN_SNAP_CHROMIUM + ) + } + } + throw e + } + } + + return await tryLaunch() + } + + private async puppeteerArgs(extraArgs: string[] = []) { + const args = new Set(['--test-type', ...extraArgs]) + + if (!(await this.puppeteerArgsEnableSandbox())) args.add('--no-sandbox') + + return [...args] + } + + private async puppeteerArgsEnableSandbox() { + if (process.env.CHROME_NO_SANDBOX) return false + if (isInsideContainer()) return false + if (await isWSL()) return false + + return true + } + + private async puppeteerPipe() { + if (await isWSL()) return false + if (await isSnapBrowser(this.path)) return false + + return true + } + + private puppeteerHeadless() { + const modeEnv = process.env.PUPPETEER_HEADLESS_MODE?.toLowerCase() ?? '' + return ['old', 'legacy', 'shell'].includes(modeEnv) ? 'shell' : true + } + + private async createPuppeteerDataDir() { + let requiredResolveWSLPath = false + + const dataDir = await (async () => { + // In WSL environment, Marp CLI may use Chrome on Windows. If Chrome has + // located in host OS (Windows), we have to specify Windows path. + if (await this.browserInWSLHost()) { + if (wslTmp === undefined) wslTmp = await resolveWindowsEnv('TMP') + if (wslTmp !== undefined) { + requiredResolveWSLPath = true + return path.win32.resolve(wslTmp, this._dataDirName) + } + } + return path.resolve(os.tmpdir(), this._dataDirName) + })() + + // Ensure the data directory is created + await fs.promises.mkdir( + requiredResolveWSLPath ? resolveWSLPathToGuestSync(dataDir) : dataDir, + { recursive: true } + ) + + return dataDir + } } diff --git a/src/browser/browsers/firefox.ts b/src/browser/browsers/firefox.ts index cfc33517..eb9ae844 100644 --- a/src/browser/browsers/firefox.ts +++ b/src/browser/browsers/firefox.ts @@ -1,6 +1,7 @@ import { Browser } from '../browser' +import type { BrowserKind, BrowserProtocol } from '../browser' export class FirefoxBrowser extends Browser { - static readonly kind = 'firefox' as const - static readonly protocol = 'webdriver-bidi' as const + static readonly kind: BrowserKind = 'firefox' + static readonly protocol: BrowserProtocol = 'webDriverBiDi' } diff --git a/src/browser/finder.ts b/src/browser/finder.ts index f42647f7..5baa8e07 100644 --- a/src/browser/finder.ts +++ b/src/browser/finder.ts @@ -21,12 +21,20 @@ export type BrowserFinder = ( const finderMap = { chrome, edge, firefox } as const -export const autoFinders = ['chrome', 'edge', 'firefox'] as const +export type FinderName = keyof typeof finderMap + +export const defaultFinders = ['chrome', 'edge', 'firefox'] as const export const findBrowser = async ( - finders: readonly (keyof typeof finderMap)[] = autoFinders, + finders: readonly FinderName[] = defaultFinders, opts: BrowserFinderOptions = {} ) => { + let found = false + + const debug = (...args: Parameters) => { + if (!found) return debugBrowserFinder(...args) + } + const finderCount = finders.length const normalizedOpts = { preferredPath: await (async () => { @@ -39,13 +47,10 @@ export const findBrowser = async ( } if (finderCount === 0) { - debugBrowserFinder('No browser finder specified.') + debug('No browser finder specified.') if (normalizedOpts.preferredPath) { - debugBrowserFinder( - 'Use preferred path as Chrome: %s', - normalizedOpts.preferredPath - ) + debug('Use preferred path as Chrome: %s', normalizedOpts.preferredPath) return await chrome(normalizedOpts) } @@ -56,10 +61,7 @@ export const findBrowser = async ( ) } - debugBrowserFinder( - `Start finding browser from ${finders.join(', ')} (%o)`, - normalizedOpts - ) + debug(`Start finding browser from ${finders.join(', ')} (%o)`, normalizedOpts) return new Promise((res, rej) => { const results = Array(finderCount) @@ -70,12 +72,12 @@ export const findBrowser = async ( finder(normalizedOpts) .then((ret) => { - debugBrowserFinder(`Found ${finderName}: %o`, ret) + debug(`Found ${finderName}: %o`, ret) results[index] = ret resolved[index] = true }) .catch((e) => { - debugBrowserFinder(`Finder ${finderName} was failed: %o`, e) + debug(`Finder ${finderName} was failed: %o`, e) resolved[index] = false }) .finally(() => { @@ -98,7 +100,9 @@ export const findBrowser = async ( }) }) }).then((result) => { - debugBrowserFinder('Use browser: %o', result) + debug('Use browser: %o', result) + found = true + return result }) } diff --git a/src/browser/finders/chrome.ts b/src/browser/finders/chrome.ts index 30c2d48f..db3083f2 100644 --- a/src/browser/finders/chrome.ts +++ b/src/browser/finders/chrome.ts @@ -8,7 +8,12 @@ import { error, CLIErrorCode } from '../../error' import { ChromeBrowser } from '../browsers/chrome' import { ChromeCdpBrowser } from '../browsers/chrome-cdp' import type { BrowserFinder, BrowserFinderResult } from '../finder' -import { findExecutableBinary, getPlatform } from './utils' +import { + findExecutableBinary, + getPlatform, + isExecutable, + normalizeDarwinAppPath, +} from './utils' const chrome = (path: string): BrowserFinderResult => ({ path, @@ -18,6 +23,11 @@ const chrome = (path: string): BrowserFinderResult => ({ export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => { if (preferredPath) return chrome(preferredPath) + if (process.env.CHROME_PATH) { + const path = await normalizeDarwinAppPath(process.env.CHROME_PATH) + if (path && (await isExecutable(path))) return chrome(path) + } + const platform = await getPlatform() const installation = await (async () => { switch (platform) { diff --git a/src/browser/finders/firefox.ts b/src/browser/finders/firefox.ts index fdbffca1..9bdc25af 100644 --- a/src/browser/finders/firefox.ts +++ b/src/browser/finders/firefox.ts @@ -2,7 +2,13 @@ import path from 'node:path' import { error, CLIErrorCode } from '../../error' import { FirefoxBrowser } from '../browsers/firefox' import type { BrowserFinder, BrowserFinderResult } from '../finder' -import { getPlatform, findExecutable, findExecutableBinary } from './utils' +import { + getPlatform, + findExecutable, + findExecutableBinary, + isExecutable, + normalizeDarwinAppPath, +} from './utils' const firefox = (path: string): BrowserFinderResult => ({ path, @@ -17,6 +23,11 @@ const winFirefoxDefault = ['Mozilla Firefox', 'firefox.exe'] // Firefox stable, export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => { if (preferredPath) return firefox(preferredPath) + if (process.env.FIREFOX_PATH) { + const nPath = await normalizeDarwinAppPath(process.env.FIREFOX_PATH) + if (nPath && (await isExecutable(nPath))) return firefox(nPath) + } + const platform = await getPlatform() const installation = await (async () => { switch (platform) { diff --git a/src/browser/manager.ts b/src/browser/manager.ts index e5f17aa5..da806691 100644 --- a/src/browser/manager.ts +++ b/src/browser/manager.ts @@ -1,35 +1,126 @@ -import type { - Browser, - BrowserKind, - BrowserProtocol, - BrowserPurpose, -} from './browser' - -export interface BrowserManagerQuery { - browser?: Browser - kind?: BrowserKind +import { error } from '../error' +import { debugBrowser } from '../utils/debug' +import type { Browser, BrowserProtocol } from './browser' +import { ChromeCdpBrowser } from './browsers/chrome-cdp' +import { defaultFinders, findBrowser } from './finder' +import type { BrowserFinderResult, FinderName } from './finder' + +export interface BrowserManagerConfig { + /** Browser finders */ + finders?: FinderName | FinderName[] + + /** Preferred path */ + path?: string + + /** Preferred protocol */ protocol?: BrowserProtocol - purpose?: BrowserPurpose + + /** Timeout for browser operations */ + timeout?: number } -export class BrowserManager { - private browsers = new Set() +export class BrowserManager implements AsyncDisposable { + // Finder + private _finders: readonly FinderName[] = defaultFinders + private _finderPreferredPath?: string + private _finderResult?: BrowserFinderResult + + // Browser + private _conversionBrowser?: Browser + private _preferredProtocol: BrowserProtocol = 'webDriverBiDi' + private _previewBrowser?: ChromeCdpBrowser + private _timeout?: number + + constructor(config: BrowserManagerConfig = {}) { + this.configure(config) + } - register(browser: Browser): void { - this.browsers.add(browser) + get timeout() { + return this._timeout } - 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 + configure(config: BrowserManagerConfig) { + if (config.finders) { + this._finders = ([] as FinderName[]).concat(config.finders) + this._finderResult = undefined // Reset finder result cache + } + if (config.path !== undefined) { + this._finderPreferredPath = config.path + this._finderResult = undefined // Reset finder result cache + } + if (config.protocol) { + if (this._conversionBrowser) + debugBrowser( + 'WARNING: Changing protocol after created browser for conversion is not supported' + ) - return browser + this._preferredProtocol = config.protocol } + if (config.timeout !== undefined) this._timeout = config.timeout + + debugBrowser('Browser manager configured: %o', config) } -} -export const browserManager = new BrowserManager() -export default browserManager + async findBrowser() { + return (this._finderResult ??= await findBrowser(this._finders, { + preferredPath: this._finderPreferredPath, + })) + } + + // Browser for converter + async browserForConversion(): Promise { + if (!this._conversionBrowser) { + const { acceptedBrowsers, path } = await this.findBrowser() + + const browser = + acceptedBrowsers.find( + ({ protocol }) => protocol === this._preferredProtocol + ) || + (() => { + if (acceptedBrowsers.length > 0) { + debugBrowser( + 'The available browsers do not support the preferred protocol "%s". Using the first available browser.', + this._preferredProtocol + ) + } + return acceptedBrowsers[0] + })() + + if (!browser) error('No browser found for conversion') + debugBrowser('Use browser class for conversion: %o', browser) + + // @ts-expect-error ts2511: TS cannot create an instance of an abstract class + this._conversionBrowser = new browser({ path, timeout: this.timeout }) + } + return this._conversionBrowser! + } + + // Browser for preview window + async browserForPreview(): Promise { + if (!this._previewBrowser) { + const { acceptedBrowsers, path } = await this.findBrowser() + + if (!acceptedBrowsers.some((browser) => browser === ChromeCdpBrowser)) { + error('No browser found for preview') + } + debugBrowser('Use browser class for preview: %o', ChromeCdpBrowser) + + this._previewBrowser = new ChromeCdpBrowser({ + path, + timeout: this.timeout, + }) + } + return this._previewBrowser + } + + async dispose() { + await Promise.all([ + this._conversionBrowser?.close(), + this._previewBrowser?.close(), + ]) + } + + async [Symbol.asyncDispose]() { + await this.dispose() + } +} diff --git a/src/config.ts b/src/config.ts index dc90b42f..713ffa69 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,8 +3,9 @@ import path from 'node:path' import chalk from 'chalk' import { cosmiconfig, cosmiconfigSync } from 'cosmiconfig' import { osLocale } from 'os-locale' +import type { BrowserManagerConfig } from './browser/manager' import { info, warn, error as cliError } from './cli' -import { ConverterOption, ConvertType } from './converter' +import { ConvertType, type ConverterOption } from './converter' import { ResolvableEngine, ResolvedEngine } from './engine' import { keywordsAsArray } from './engine/meta-plugin' import { error, isError } from './error' @@ -115,7 +116,23 @@ export class MarpCLIConfig { private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function - async converterOption(): Promise { + browserManagerOption() { + const timeout = (() => { + // TODO: Resolve timeout from args and configuration file + if (process.env.PUPPETEER_TIMEOUT) { + const envTimeout = Number.parseInt(process.env.PUPPETEER_TIMEOUT, 10) + if (!Number.isNaN(envTimeout)) return envTimeout + } + return undefined + })() + + return { + protocol: 'cdp', + timeout, + } as const satisfies BrowserManagerConfig + } + + async converterOption() { const inputDir = await this.inputDir() const server = this.args.server ?? this.conf.server ?? false const output = (() => { @@ -254,14 +271,6 @@ export class MarpCLIConfig { return scale })() - const puppeteerTimeout = (() => { - if (process.env['PUPPETEER_TIMEOUT']) { - const envTimeout = Number.parseInt(process.env['PUPPETEER_TIMEOUT'], 10) - if (!Number.isNaN(envTimeout)) return envTimeout - } - return undefined - })() - return { imageScale, inputDir, @@ -269,7 +278,6 @@ export class MarpCLIConfig { pdfNotes, pdfOutlines, preview, - puppeteerTimeout, server, template, templateOption, @@ -294,7 +302,7 @@ export class MarpCLIConfig { options: this.conf.options || {}, pages: !!(this.args.images || this.conf.images), watch: (this.args.watch ?? this.conf.watch) || preview || server || false, - } + } as const satisfies Partial } get files() { @@ -411,4 +419,4 @@ export class MarpCLIConfig { } } -export default MarpCLIConfig.fromArguments +export const { fromArguments } = MarpCLIConfig diff --git a/src/converter.ts b/src/converter.ts index e8197ed9..826ad7bc 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -2,7 +2,14 @@ import { URL } from 'node:url' import type { Marp, MarpOptions } from '@marp-team/marp-core' import { Marpit, Options as MarpitOptions } from '@marp-team/marpit' import chalk from 'chalk' -import type { Browser, Page, HTTPRequest, WaitForOptions } from 'puppeteer-core' +import type { + Page, + HTTPRequest, + WaitForOptions, + Viewport, +} from 'puppeteer-core' +import type { Browser } from './browser/browser' +import type { BrowserManager } from './browser/manager' import { silence, warn } from './cli' import { Engine, ResolvedEngine } from './engine' import { generateOverrideGlobalDirectivesPlugin } from './engine/directive-plugin' @@ -28,13 +35,9 @@ import templates, { } from './templates/' import { ThemeSet } from './theme' import { isOfficialDockerImage } from './utils/container' +import { debug } from './utils/debug' import { pdfLib, setOutline } from './utils/pdf' -import { - generatePuppeteerDataDirPath, - generatePuppeteerLaunchArgs, - launchPuppeteer, -} from './utils/puppeteer' -import { isChromeInWSLHost, resolveWSLPathToHost } from './utils/wsl' +import { resolveWSLPathToHost } from './utils/wsl' import { notifier } from './watcher' const CREATED_BY_MARP = 'Created by Marp' @@ -61,6 +64,7 @@ export const mimeTypes = { export interface ConverterOption { allowLocalFiles: boolean baseUrl?: string + browserManager: BrowserManager engine: Engine globalDirectives: { theme?: string } & Partial html?: MarpOptions['html'] @@ -78,7 +82,6 @@ export interface ConverterOption { headings: boolean } preview?: boolean - puppeteerTimeout?: number jpegQuality?: number server?: boolean template: string @@ -108,15 +111,30 @@ export interface ConvertResult { export type ConvertedCallback = (result: ConvertResult) => void +// Sharp image processing library +let _sharp: typeof import('sharp') | undefined + +const sharp = () => { + if (!_sharp) _sharp = require('sharp') as typeof import('sharp') // eslint-disable-line @typescript-eslint/no-require-imports + return _sharp +} + +// Markdown const stripBOM = (s: string) => (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s) export class Converter { readonly options: ConverterOption + private _firefoxPDFConversionWarning = false + constructor(opts: ConverterOption) { this.options = opts } + get browser(): Promise { + return this.options.browserManager.browserForConversion() + } + get template(): Template { const template = templates[this.options.template] if (!template) error(`Template "${this.options.template}" is not found.`) @@ -124,10 +142,6 @@ export class Converter { return template } - get puppeteerTimeout(): number { - return this.options.puppeteerTimeout ?? 30000 - } - async convert( markdown: string, file?: File, @@ -144,9 +158,9 @@ export class Converter { if (this.options.baseUrl) return this.options.baseUrl if (isFile(f) && type !== ConvertType.html) { - return (await isChromeInWSLHost( - (await generatePuppeteerLaunchArgs()).executablePath - )) + const browser = await this.browser + + return (await browser.browserInWSLHost()) ? `file:${await resolveWSLPathToHost(f.absolutePath)}` : f.absoluteFileScheme } @@ -245,7 +259,7 @@ export class Converter { if (opts.onConverted) opts.onConverted({ file, newFile, template }) } - // #convertFile must return a single file to serve in server + // convertFile must return a single file to serve in server return { file, template, newFile: files[0] } } else { // Try conversion with specific template to scan using resources @@ -294,6 +308,17 @@ export class Converter { const ret = file.convert(this.options.output, { extension: 'pdf' }) + // Generate PDF + const browser = await this.browser + + if (browser.kind === 'firefox' && !this._firefoxPDFConversionWarning) { + this._firefoxPDFConversionWarning = true + + warn( + 'Using Firefox to convert Markdown to PDF: The output may include some incompatible renderings compared to the PDF generated by Chrome.' + ) + } + let outlineData: OutlineData | undefined ret.buffer = Buffer.from( @@ -310,8 +335,9 @@ export class Converter { return await page.pdf({ printBackground: true, - preferCSSPageSize: true, - timeout: this.puppeteerTimeout, + preferCSSPageSize: true, // This option is not working in WebDriver BiDi + width: tpl.rendered.size.width, + height: tpl.rendered.size.height, }) }) ) @@ -385,13 +411,39 @@ export class Converter { const files: File[] = [] - await this.usePuppeteer(html, async (page, { render }) => { - await page.setViewport({ - ...tpl.rendered.size, - deviceScaleFactor: opts.scale ?? 1, - }) + await this.usePuppeteer(html, async (page, { browser, render }) => { + const scale = opts.scale ?? 1 + const viewPort = ( + browser.kind === 'firefox' + ? ({ + width: Math.floor(tpl.rendered.size.width * scale), + height: Math.floor(tpl.rendered.size.height * scale), + // Firefox seems not to respect deviceScaleFactor when taking screenshot + // https://github.com/puppeteer/puppeteer/issues/13032 + // https://github.com/w3c/webdriver-bidi/issues/686 + deviceScaleFactor: 1, + } as const) + : ({ + width: tpl.rendered.size.width, + height: tpl.rendered.size.height, + deviceScaleFactor: scale, + } as const) + ) satisfies Viewport + + await page.setViewport(viewPort) await render() - await page.emulateMediaType('print') + await page.addStyleTag({ + content: ':root,body { scrollbar-width:none !important; }', + }) + + try { + await page.emulateMediaType('print') + } catch (e) { + debug('%o', e) + debug( + 'Could not emulate media type "print". Continue capturing screenshot with media type "screen".' + ) + } if (opts.type === ConvertType.png) { // Enable transparency @@ -401,24 +453,44 @@ export class Converter { } const screenshot = async (pageNumber = 1) => { - const clip = { - x: 0, - y: (pageNumber - 1) * tpl.rendered.size.height, - ...tpl.rendered.size, - } as const + const y = (pageNumber - 1) * viewPort.height + const clip = { x: 0, y, width: viewPort.width, height: viewPort.height } + + if (browser.protocol === 'cdp') { + if (opts.type === ConvertType.jpeg) + return await page.screenshot({ + clip, + quality: opts.quality, + type: 'jpeg', + }) - if (opts.type === ConvertType.jpeg) return await page.screenshot({ clip, - quality: opts.quality, - type: 'jpeg', + omitBackground: true, + type: 'png', }) + } else if (browser.protocol === 'webDriverBiDi') { + if (browser.kind === 'firefox') { + clip.y = 0 - return await page.screenshot({ - clip, - omitBackground: true, - type: 'png', - }) + await page.evaluate( + `document.body.scrollTo({ left: 0, top: ${y}, behavior: 'instant' })` + ) + } + + // WebDriver BiDi only supports PNG + const buf = await page.screenshot({ clip, type: 'png' }) + + if (opts.type === ConvertType.jpeg) { + // Convert png to jpeg via sharp + return await sharp()(buf) + .jpeg({ mozjpeg: true, quality: opts.quality }) + .toBuffer() + } + + return buf + } + error('Unsupported browser protocol for taking screenshot.') } if (opts.pages) { @@ -541,44 +613,43 @@ export class Converter { baseFile: File, processer: ( page: Page, - helpers: { render: () => Promise } + helpers: { + browser: Browser + render: () => Promise + } ) => Promise ) { - const tmpFile: File.TmpFileInterface | undefined = await (() => { - if (!this.options.allowLocalFiles) return undefined + let uri: string | undefined + let tmpFile: File.TmpFileInterface | undefined + if (this.options.allowLocalFiles) { warn( `Insecure local file accessing is enabled for conversion from ${baseFile.relativePath()}.` ) - // Snapd Chromium cannot access from sandbox container to user-land `/tmp` - // directory so always create tmp file to home directory if in Linux. - // (There is an exception for an official docker image) - return baseFile.saveTmpFile({ + tmpFile = await baseFile.saveTmpFile({ + // Snapd Chromium cannot access from sandbox container to user-land `/tmp` + // directory so always create tmp file to home directory if in Linux. + // (Except an official docker image) home: process.platform === 'linux' && !isOfficialDockerImage(), extension: '.html', }) - })() + } - try { - const browser = await Converter.runBrowser({ - timeout: this.puppeteerTimeout, - }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + using _tmpFile = tmpFile ?? { [Symbol.dispose]: () => void 0 } - const uri = await (async () => { - if (tmpFile) { - if (await isChromeInWSLHost(browser.process()?.spawnfile)) { - // Windows Chrome should read file from WSL environment - return `file:${await resolveWSLPathToHost(tmpFile.path)}` - } - return `file://${tmpFile.path}` - } - return undefined - })() + const browser = await this.browser - const page = await browser.newPage() - page.setDefaultTimeout(this.puppeteerTimeout) + if (tmpFile) { + if (await browser.browserInWSLHost()) { + uri = `file:${await resolveWSLPathToHost(tmpFile.path)}` + } else { + uri = `file://${tmpFile.path}` + } + } + return await browser.withPage(async (page) => { const { missingFileSet, failedFileSet } = this.trackFailedLocalFileAccess(page) @@ -590,13 +661,13 @@ export class Converter { if (uri) { await page.goto(uri, waitForOptions) } else { - await page.goto('data:text/html,') + await page.goto('data:text/html,', waitForOptions) await page.setContent(baseFile.buffer!.toString(), waitForOptions) } } try { - return await processer(page, { render }) + return await processer(page, { browser, render }) } finally { if (missingFileSet.size > 0) { warn( @@ -618,11 +689,8 @@ export class Converter { )} option if you are understood of security risk)` ) } - await page.close() } - } finally { - if (tmpFile) await tmpFile.cleanup() - } + }) } private trackFailedLocalFileAccess(page: Page): { @@ -633,8 +701,12 @@ export class Converter { const failedFileSet = new Set() page.on('requestfailed', (req: HTTPRequest) => { + debug('Failed request: %s', req.url()) + debug('%o', req.failure()) + try { const url = new URL(req.url()) + if (url.protocol === 'file:') { if (req.failure()?.errorText === 'net::ERR_FILE_NOT_FOUND') { missingFileSet.add(url.href) @@ -649,28 +721,4 @@ export class Converter { return { missingFileSet, failedFileSet } } - - static async closeBrowser() { - if (Converter.browser) await Converter.browser.close() - } - - private static browser?: Browser - - private static async runBrowser({ timeout }: { timeout?: number }) { - if (!Converter.browser) { - const baseArgs = await generatePuppeteerLaunchArgs() - - Converter.browser = await launchPuppeteer({ - ...baseArgs, - timeout, - userDataDir: await generatePuppeteerDataDirPath('marp-cli-conversion', { - wslHost: await isChromeInWSLHost(baseArgs.executablePath), - }), - }) - Converter.browser.once('disconnected', () => { - Converter.browser = undefined - }) - } - return Converter.browser - } } diff --git a/src/engine.ts b/src/engine.ts index 08647d82..925597f0 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -7,6 +7,7 @@ import importFrom from 'import-from' import { resolve as importMetaResolve } from 'import-meta-resolve' import { pkgUp } from 'pkg-up' import { error, isError } from './error' +import { debugEngine } from './utils/debug' type FunctionalEngine = ( constructorOptions: ConstructorParameters[0] & { readonly marp: Marp } @@ -137,21 +138,25 @@ export class ResolvedEngine { moduleId: string, from?: string ): Promise { + let normalizedModuleId = moduleId + const basePath = path.join(from || process.cwd(), '_.js') const dirPath = path.dirname(basePath) - const moduleFilePath = path.resolve(dirPath, moduleId) + const moduleFilePath = path.resolve(dirPath, normalizedModuleId) try { const stat = await fs.promises.stat(moduleFilePath) - if (stat.isFile()) moduleId = url.pathToFileURL(moduleFilePath).toString() + if (stat.isFile()) { + normalizedModuleId = url.pathToFileURL(moduleFilePath).toString() + } } catch { // No ops } try { const resolved = importMetaResolve( - moduleId, + normalizedModuleId, url.pathToFileURL(basePath).toString() ) @@ -169,7 +174,14 @@ export class ResolvedEngine { return await import(resolved) /* c8 ignore stop */ - } catch { + } catch (e) { + debugEngine( + 'Failed to import %s. (Normalized module id: %s)', + moduleId + (from ? ` from ${from}` : ''), + normalizedModuleId + ) + debugEngine('%O', e) + return null } } @@ -187,6 +199,12 @@ export class ResolvedEngine { /* c8 ignore start */ } catch (e) { + debugEngine( + 'Failed to require %s.', + moduleId + (from ? ` from ${from}` : '') + ) + debugEngine('%O', e) + if (isError(e) && e.code === 'ERR_REQUIRE_ESM') { // Show reason why `require()` failed in the current context if ('pkg' in process) { diff --git a/src/error.ts b/src/error.ts index 236260da..c93dda07 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,3 +1,5 @@ +import { debug } from './utils/debug' + export class CLIError extends Error { readonly errorCode: number readonly message: string @@ -28,7 +30,10 @@ export function error( msg: string, errorCode: number = CLIErrorCode.GENERAL_ERROR ): never { - throw new CLIError(msg, errorCode) + const cliError = new CLIError(msg, errorCode) + debug('%O', cliError) + + throw cliError } export const isError = (e: unknown): e is NodeJS.ErrnoException => diff --git a/src/file.ts b/src/file.ts index b76bbf70..2f8a02d2 100644 --- a/src/file.ts +++ b/src/file.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util' import getStdin from 'get-stdin' import { globby, Options as GlobbyOptions } from 'globby' import { tmpName } from 'tmp' +import { debug } from './utils/debug' const tmpNamePromise = promisify(tmpName) @@ -97,25 +98,30 @@ export class File { opts: { extension?: string; home?: boolean } = {} ): Promise { let tmp: string = await tmpNamePromise({ postfix: opts.extension }) - if (opts.home) tmp = path.join(os.homedir(), path.basename(tmp)) + debug('Saving temporary file: %s', tmp) await this.saveToFile(tmp) + const cleanup = async () => { + try { + await this.cleanup(tmp) + } catch (e) { + debug('Failed to clean up temporary file: %o', e) + } + } + return { - cleanup: async () => { - try { - await this.cleanup(tmp) - } catch { - // No ops - } - }, path: tmp, + cleanup, + [Symbol.dispose]: () => void cleanup(), + [Symbol.asyncDispose]: cleanup, } } - private cleanup(tmpPath: string) { - return fs.promises.unlink(tmpPath) + private async cleanup(tmpPath: string) { + await fs.promises.unlink(tmpPath) + debug('Cleaned up temporary file: %s', tmpPath) } private convertName( @@ -147,6 +153,8 @@ export class File { } private async saveToFile(savePath: string = this.path) { + debug('Saving file: %s', savePath) + const directory = path.dirname(path.resolve(savePath)) if (path.dirname(directory) !== directory) { @@ -154,6 +162,7 @@ export class File { } await fs.promises.writeFile(savePath, this.buffer!) + debug('Saved: %s', savePath) } private static stdinBuffer?: Buffer @@ -236,7 +245,7 @@ export class File { } export namespace File { - export interface TmpFileInterface { + export interface TmpFileInterface extends AsyncDisposable, Disposable { path: string cleanup: () => Promise } diff --git a/src/marp-cli.ts b/src/marp-cli.ts index 40312a43..3493a798 100644 --- a/src/marp-cli.ts +++ b/src/marp-cli.ts @@ -1,6 +1,7 @@ import chalk from 'chalk' +import { BrowserManager } from './browser/manager' import * as cli from './cli' -import fromArguments from './config' +import { fromArguments } from './config' import { Converter, ConvertedCallback, ConvertType } from './converter' import { CLIError, error, isError } from './error' import { File, FileType } from './file' @@ -8,7 +9,6 @@ import { Preview, fileToURI } from './preview' 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' @@ -46,6 +46,7 @@ export const marpCli = async ( argv: string[], { baseUrl, stdin: defaultStdin, throwErrorAlways }: MarpCLIInternalOptions ): Promise => { + let browserManager: BrowserManager | undefined let server: Server | undefined let watcherInstance: Watcher | undefined @@ -294,8 +295,14 @@ export const marpCli = async ( const config = await fromArguments(args) if (args.version) return await version(config) + // Initialize browser manager + browserManager = new BrowserManager(config.browserManagerOption()) + // Initialize converter - const converter = new Converter(await config.converterOption()) + const converter = new Converter({ + ...(await config.converterOption()), + browserManager, + }) const cvtOpts = converter.options // Find target markdown files @@ -393,10 +400,10 @@ export const marpCli = async ( ) // Preview window - const preview = new Preview() + const preview = new Preview({ browserManager: browserManager! }) preview.on('exit', () => res(0)) preview.on('opening', (location: string) => { - const loc = location.substr(0, 50) + const loc = location.substring(0, 50) const msg = `[Preview] Opening ${loc}...` cli.info(chalk.cyan(msg)) }) @@ -447,7 +454,7 @@ export const marpCli = async ( } finally { await Promise.all([ notifier.stop(), - Converter.closeBrowser(), + browserManager?.dispose(), server?.stop(), watcherInstance?.chokidar.close(), ]) @@ -459,20 +466,10 @@ export const waitForObservation = () => resolversForObservation.push(res) }) -export const apiInterface = (argv: string[], opts: MarpCLIAPIOptions = {}) => { - resetExecutablePath() - - return marpCli(argv, { - ...opts, - stdin: false, - throwErrorAlways: true, - }) -} +export const apiInterface = (argv: string[], opts: MarpCLIAPIOptions = {}) => + marpCli(argv, { ...opts, stdin: false, throwErrorAlways: true }) export const cliInterface = (argv: string[] = []) => - marpCli(argv, { - stdin: true, - throwErrorAlways: false, - }) + marpCli(argv, { stdin: true, throwErrorAlways: false }) export default cliInterface diff --git a/src/preview.ts b/src/preview.ts index 609eaf0d..aac35d0a 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -3,20 +3,18 @@ import { EventEmitter } from 'node:events' import { nanoid } from 'nanoid' import type { Page, Browser, Target } from 'puppeteer-core' import TypedEmitter from 'typed-emitter' +import { BrowserManager } from './browser/manager' import { ConvertType, mimeTypes } from './converter' import { error } from './error' import { File, FileType } from './file' -import { - generatePuppeteerDataDirPath, - generatePuppeteerLaunchArgs, - enableHeadless, - launchPuppeteer, -} from './utils/puppeteer' -import { isChromeInWSLHost } from './utils/wsl' +import { debugPreview } from './utils/debug' const emptyPageURI = `data:text/html;base64,PHRpdGxlPk1hcnAgQ0xJPC90aXRsZT4` // Marp CLI export namespace Preview { + type PartialByKeys = Pick> & + Partial> + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- TypedEmitter requires type definition instead of interface export type Events = { close: (window: any) => void @@ -27,10 +25,13 @@ export namespace Preview { } export interface Options { + browserManager: BrowserManager height: number width: number } + export type ConstructorOptions = PartialByKeys + export interface Window extends EventEmitter { page: Page close: () => Promise @@ -43,12 +44,16 @@ export class Preview extends (EventEmitter as new () => TypedEmitter = {}) { + constructor(opts: Preview.ConstructorOptions) { super() + this.options = { + browserManager: opts.browserManager, height: opts.height || 360, width: opts.width || 640, } + + debugPreview('Initialized preview instance: %o', this.options) } get puppeteer(): Browser | undefined { @@ -68,27 +73,40 @@ export class Preview extends (EventEmitter as new () => TypedEmitter window.emit('close')) + page.on('close', () => window.emit('close')) return Object.assign(window, { page, close: async () => { try { + debugPreview('Request to close a page: %o', page) return await page.close() /* c8 ignore start */ } catch (e: any) { + debugPreview('%O', e) + // Ignore raising error if a target page has already close if (!e.message.includes('Target closed.')) throw e } @@ -98,7 +116,11 @@ export class Preview extends (EventEmitter as new () => TypedEmitter TypedEmitter { - session.send('Page.resetNavigationHistory').catch(() => { - // No ops - }) + await page.createCDPSession().then((session) => { + session.send('Page.resetNavigationHistory').catch(() => { + // No ops }) + }) }, }) } private async createWindow() { + debugPreview('Trying to create new window') + try { return this.createWindowObject( await new Promise((res, rej) => { const pptr = this.puppeteer - if (!pptr) return rej(false) + + if (!pptr) { + debugPreview('Ignored: Puppeteer instance is not available') + return rej(false) + } const id = nanoid() const idMatcher = (target: Target) => { + debugPreview('Activated the window finder for %s.', id) + const url = new URL(target.url()) if (url.searchParams.get('__marp_cli_id') === id) { + debugPreview('Found a target window with id: %s', id) pptr.off('targetcreated', idMatcher) - ;(async () => res((await target.page())!))() + + void (async () => { + res((await target.page()) ?? (await target.asPage())) + })() } } @@ -148,10 +183,28 @@ export class Preview extends (EventEmitter as new () => TypedEmitter { const [page] = await pptr.pages() + debugPreview('Opening a new window... (id: %s)', id) + await page.evaluate( `window.open('about:blank?__marp_cli_id=${id}', '', 'width=${this.options.width},height=${this.options.height}')` ) })() + }).then(async (page) => { + const sizeCorrection = await page.evaluate( + ([w, h]) => { + const nw = w - window.innerWidth + w + const nh = h - window.innerHeight + h + + window.resizeTo(nw, nh) + return [nw, nh] + }, + [this.options.width, this.options.height] + ) + + debugPreview('Apply window size correction: %o', sizeCorrection) + debugPreview('Created new window: %s', page.url()) + + return page }) ) } catch (e: unknown) { @@ -161,29 +214,30 @@ export class Preview extends (EventEmitter as new () => TypedEmitter { - const baseArgs = await generatePuppeteerLaunchArgs() + const browser = await this.browserManager.browserForPreview() - this.puppeteerInternal = await launchPuppeteer({ - ...baseArgs, + this.puppeteerInternal = await browser.launch({ args: [ - ...baseArgs.args, `--app=${emptyPageURI}`, `--window-size=${this.options.width},${this.options.height}`, ], defaultViewport: null, - headless: process.env.NODE_ENV === 'test' ? enableHeadless() : false, + headless: process.env.NODE_ENV === 'test', ignoreDefaultArgs: ['--enable-automation'], - userDataDir: await generatePuppeteerDataDirPath('marp-cli-preview', { - wslHost: await isChromeInWSLHost(baseArgs.executablePath), - }), }) const handlePageOnClose = async () => { - const pages = (await this.puppeteer?.pages()) || [] - if (pages.length === 0) await this.exit() + debugPreview('Page closed') + + const pagesCount = (await this.puppeteer?.pages())?.length ?? 0 + debugPreview('Remaining pages count: %d', pagesCount) + + if (pagesCount === 0) await this.exit() } this.puppeteerInternal.on('targetcreated', (target) => { + debugPreview('Target created: %o', target.url()) + // NOTE: PDF viewer on headfull Chrome may return `null`. target.page().then((page) => page?.on('close', handlePageOnClose)) }) diff --git a/src/utils/chrome-finder.ts b/src/utils/chrome-finder.ts deleted file mode 100644 index ebf04260..00000000 --- a/src/utils/chrome-finder.ts +++ /dev/null @@ -1,96 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import { - darwinFast, - linux, - win32, - wsl, -} from 'chrome-launcher/dist/chrome-finder' -import { parse as parsePlist } from 'fast-plist' -import { isWSL } from './wsl' - -const macAppDirectoryMatcher = /.app\/?$/ - -// A lightweight version of Launcher.getFirstInstallation() -// https://github.com/GoogleChrome/chrome-launcher/blob/30755cde8627b7aad6caff1594c9752f80a39a4d/src/chrome-launcher.ts#L189-L192 -export const findChromeInstallation = async () => { - // 'wsl' platform will resolve Chrome from Windows. In WSL 2, Puppeteer cannot - // communicate with Chrome spawned in the host OS so should follow the - // original platform ('linux') if CLI was executed in WSL 2. - const platform = (await isWSL()) === 1 ? 'wsl' : process.platform - - const installations = await (async () => { - switch (platform) { - case 'darwin': - return await withNormalizedChromePathForDarwin(() => [darwinFast()]) - case 'linux': - return linux() - case 'win32': - return win32() - // CI cannot test against WSL environment - /* c8 ignore start */ - case 'wsl': - return wsl() - } - return [] - /* c8 ignore stop */ - })() - - return installations[0] -} - -/** - * Run callback with modified CHROME_PATH env variable if it has been pointed to - * the ".app" directory instead of the executable binary for Darwin. - */ -export const withNormalizedChromePathForDarwin = async ( - callback: () => T -): Promise => { - const originalChromePath = Object.prototype.hasOwnProperty.call( - process.env, - 'CHROME_PATH' - ) - ? process.env.CHROME_PATH - : undefined - - if ( - originalChromePath !== undefined && - macAppDirectoryMatcher.test(originalChromePath) - ) { - try { - const appDirStat = await fs.promises.stat(originalChromePath) - - if (appDirStat.isDirectory()) { - const manifestPath = path.join( - originalChromePath, - 'Contents', - 'Info.plist' - ) - const manifestBody = await fs.promises.readFile(manifestPath) - const manifest = parsePlist(manifestBody.toString()) - - if ( - manifest.CFBundlePackageType == 'APPL' && - manifest.CFBundleExecutable - ) { - process.env.CHROME_PATH = path.join( - originalChromePath, - 'Contents', - 'MacOS', - manifest.CFBundleExecutable - ) - } - } - } catch { - // ignore - } - } - - try { - return await callback() - } finally { - if (originalChromePath !== undefined) { - process.env.CHROME_PATH = originalChromePath - } - } -} diff --git a/src/utils/container.ts b/src/utils/container.ts index 9aa65ac8..c8b59461 100644 --- a/src/utils/container.ts +++ b/src/utils/container.ts @@ -1,6 +1,6 @@ import _isInsideContainer from 'is-inside-container' export const isInsideContainer = () => - isOfficialDockerImage() || _isInsideContainer() + isOfficialDockerImage() || (!process.env.CI && _isInsideContainer()) export const isOfficialDockerImage = () => !!process.env.MARP_USER diff --git a/src/utils/debug.ts b/src/utils/debug.ts index fd60a18f..301d2713 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -3,3 +3,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') +export const debugEngine = dbg('marp-cli:engine') +export const debugPreview = dbg('marp-cli:preview') diff --git a/src/utils/edge-finder.ts b/src/utils/edge-finder.ts deleted file mode 100644 index bc60c7eb..00000000 --- a/src/utils/edge-finder.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { accessSync, constants } from 'node:fs' -import path from 'node:path' -import { isWSL, resolveWindowsEnvSync, resolveWSLPathToGuestSync } from './wsl' - -export const findAccessiblePath = (paths: string[]): string | undefined => - paths.find((p) => { - try { - accessSync(p, constants.X_OK) - return true - } catch { - // no ops - } - return false - }) - -const linux = async (): Promise => { - // WSL 1 should find Edge executable from host OS - if ((await isWSL()) === 1) { - const localAppData = resolveWindowsEnvSync('LOCALAPPDATA') - - return win32({ - programFiles: '/mnt/c/Program Files', - programFilesX86: '/mnt/c/Program Files (x86)', - localAppData: localAppData ? resolveWSLPathToGuestSync(localAppData) : '', - }) - } - - return findAccessiblePath([ - '/opt/microsoft/msedge-canary/msedge', - '/opt/microsoft/msedge-dev/msedge', - '/opt/microsoft/msedge-beta/msedge', - '/opt/microsoft/msedge/msedge', - ]) -} - -const darwin = (): string | undefined => - findAccessiblePath([ - '/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 win32 = ({ - programFiles = process.env.PROGRAMFILES, - programFilesX86 = process.env['PROGRAMFILES(X86)'], - localAppData = process.env.LOCALAPPDATA, -}: { - programFiles?: string - programFilesX86?: string - localAppData?: string -} = {}): string | undefined => { - const prefixes = [localAppData, programFiles, programFilesX86].filter( - (p): p is string => !!p - ) - - return findAccessiblePath( - [ - path.join('Microsoft', 'Edge SxS', 'Application', 'msedge.exe'), - path.join('Microsoft', 'Edge Dev', 'Application', 'msedge.exe'), - path.join('Microsoft', 'Edge Beta', 'Application', 'msedge.exe'), - path.join('Microsoft', 'Edge', 'Application', 'msedge.exe'), - ].reduce( - (acc, suffix) => [ - ...acc, - ...prefixes.map((prefix) => path.join(prefix, suffix)), - ], - [] - ) - ) -} - -export const findEdgeInstallation = async (): Promise => { - if (process.platform === 'linux') return await linux() - if (process.platform === 'darwin') return darwin() - if (process.platform === 'win32') return win32() - - return undefined -} diff --git a/src/utils/puppeteer.ts b/src/utils/puppeteer.ts deleted file mode 100644 index 36f7dd05..00000000 --- a/src/utils/puppeteer.ts +++ /dev/null @@ -1,196 +0,0 @@ -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { nanoid } from 'nanoid' -import { launch } from 'puppeteer-core' -import macDockIcon from '../assets/mac-dock-icon.png' -import { warn } from '../cli' -import { CLIErrorCode, error, isError } from '../error' -import { findChromeInstallation } from './chrome-finder' -import { isInsideContainer } from './container' -import { findEdgeInstallation } from './edge-finder' -import { isWSL, resolveWindowsEnv } from './wsl' - -let executablePath: string | undefined | false = false -let wslTmp: string | undefined - -export const enableHeadless = (): 'shell' | true => - ['old', 'legacy', 'shell'].includes( - process.env.PUPPETEER_HEADLESS_MODE?.toLowerCase() ?? '' - ) - ? 'shell' - : true - -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 -} - -const isSnapBrowser = (executablePath: string | undefined) => { - if (process.platform === 'linux' && executablePath) { - // Snapd binary - if (executablePath.startsWith('/snap/')) return true - - // Check the content of shebang script (for alias script installed by apt) - if (isShebang(executablePath)) { - const scriptContent = fs.readFileSync(executablePath, 'utf8') - if (scriptContent.includes('/snap/')) return true - } - } - return false -} - -const puppeteerDataDirSuffix = nanoid(10) - -export const generatePuppeteerDataDirPath = async ( - name: string, - { wslHost }: { wslHost?: boolean } = {} -): Promise => { - const nameWithSuffix = `${name}-${puppeteerDataDirSuffix}` - - const dataDir = await (async () => { - if ((await isWSL()) && wslHost) { - // In WSL environment, Marp CLI may use Chrome on Windows. If Chrome has - // located in host OS (Windows), we have to specify Windows path. - if (wslTmp === undefined) wslTmp = await resolveWindowsEnv('TMP') - if (wslTmp !== undefined) - return path.win32.resolve(wslTmp, nameWithSuffix) - } - return path.resolve(os.tmpdir(), nameWithSuffix) - })() - - // Ensure the data directory is created - try { - await fs.promises.mkdir(dataDir, { recursive: true }) - } catch (e: unknown) { - if (isError(e) && e.code !== 'EEXIST') throw e - } - - return dataDir -} - -export const generatePuppeteerLaunchArgs = async () => { - const args = new Set(['--export-tagged-pdf', '--test-type']) - - // Docker environment and WSL environment always need to disable sandbox - if (process.env.CHROME_NO_SANDBOX || isInsideContainer() || (await isWSL())) - args.add('--no-sandbox') - - args.add('--enable-blink-features=ViewTransition') - - // LayoutNG Printing - if (process.env.CHROME_LAYOUTNG_PRINTING) - args.add( - '--enable-blink-features=LayoutNGPrinting,LayoutNGTableFragmentation' - ) - - // Resolve Chrome path to execute - if (executablePath === false) { - let findChromeError: Error | undefined - - try { - executablePath = await findChromeInstallation() - } catch (e: unknown) { - if (isError(e)) findChromeError = e - } - - if (!executablePath) { - // Find Edge as fallback (Edge has pre-installed to almost Windows) - executablePath = await findEdgeInstallation() - - if (!executablePath) { - if (findChromeError) warn(findChromeError.message) - - // https://github.com/marp-team/marp-cli/issues/475 - // https://github.com/GoogleChrome/chrome-launcher/issues/278 - const chromiumResolvable = process.platform === 'linux' - - error( - `You have to install Google Chrome${ - chromiumResolvable ? ', Chromium,' : '' - } or Microsoft Edge to convert slide deck with current options.`, - CLIErrorCode.NOT_FOUND_CHROMIUM - ) - } - } - } - - return { - executablePath, - args: [...args], - pipe: !(isWSL() || isSnapBrowser(executablePath)), - headless: enableHeadless(), - - // Workaround to avoid force-extensions policy for Chrome enterprise (SET CHROME_ENABLE_EXTENSIONS=1) - // https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-windows - // - // @see https://github.com/marp-team/marp-cli/issues/231 - ignoreDefaultArgs: process.env.CHROME_ENABLE_EXTENSIONS - ? ['--disable-extensions'] - : undefined, - } -} - -export const launchPuppeteer = async ( - ...[options]: Parameters -) => { - try { - const browser = await launch(options) - - // Set Marp icon asynchrnously (only for macOS) - /* c8 ignore start */ - browser - ?.target() - .createCDPSession() - .then((session) => { - session - .send('Browser.setDockTile', { image: macDockIcon.slice(22) }) - .catch(() => { - // No ops - }) - }) - /* c8 ignore stop */ - - return browser - } catch (e: unknown) { - if (isError(e)) { - // Retry to launch Chromium with WebSocket connection instead of pipe if failed to connect to Chromium - // https://github.com/puppeteer/puppeteer/issues/6258 - if (options?.pipe && e.message.includes('Target.setDiscoverTargets')) { - return await launch({ ...options, pipe: false }) - } - - // Warning when tried to spawn the snap chromium within the snapd container - // (e.g. Terminal in VS Code installed by snap + chromium installed by apt) - // It would be resolved by https://github.com/snapcore/snapd/pull/10029 but there is no progress :( - if ( - options?.executablePath && - isSnapBrowser(options.executablePath) && - /^need to run as root or suid$/im.test(e.message) - ) { - error( - 'Marp CLI has detected trying to spawn Chromium browser installed by snap, from the confined environment like another snap app. At least either of Chrome/Chromium or the shell environment must be non snap app.', - CLIErrorCode.CANNOT_SPAWN_SNAP_CHROMIUM - ) - } - } - throw e - } -} - -export const resetExecutablePath = () => { - executablePath = false -} diff --git a/src/utils/wsl.ts b/src/utils/wsl.ts index c9cd26e2..aebcaab9 100644 --- a/src/utils/wsl.ts +++ b/src/utils/wsl.ts @@ -69,6 +69,3 @@ export const isWSL = async (): Promise => { return await isWsl } - -export const isChromeInWSLHost = async (chromePath: string | undefined) => - !!((await isWSL()) && chromePath?.match(/^\/mnt\/[a-z]\//)) diff --git a/test/browser/browser.ts b/test/browser/browser.ts new file mode 100644 index 00000000..61c773a9 --- /dev/null +++ b/test/browser/browser.ts @@ -0,0 +1,102 @@ +import * as puppeteer from 'puppeteer-core' +import { Browser } from '../../src/browser/browser' +import { FirefoxBrowser } from '../../src/browser/browsers/firefox' +import * as wsl from '../../src/utils/wsl' + +jest.mock('puppeteer-core') + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +describe('Browser class', () => { + describe('#launch', () => { + const mockedBrowser = { once: jest.fn() } + + beforeEach(() => { + jest.spyOn(puppeteer as any, 'launch').mockResolvedValue(mockedBrowser) + }) + + it('calls #launch in puppeteer-core with caching', async () => { + const browser = new FirefoxBrowser({ path: '/path/to/firefox' }) + + expect(await browser.launch()).toBe(mockedBrowser) + expect(await browser.launch()).toBe(mockedBrowser) + expect(puppeteer.launch).toHaveBeenCalledTimes(1) + }) + }) + + describe('[Symbol.asyncDisposable()]', () => { + it('calls #close when disposed', async () => { + const closeSpy = jest.spyOn(Browser.prototype, 'close') + { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await using _browser = new FirefoxBrowser({ path: '/path/to/firefox' }) + } + expect(closeSpy).toHaveBeenCalled() + }) + }) + + describe('#browserInWSLHost', () => { + it('always returns false if the current environment is not WSL', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(0) + + expect( + await new FirefoxBrowser({ + path: '/mnt/c/Program Files/Firefox/firefox.exe', + }).browserInWSLHost() + ).toBe(false) + }) + + it('returns true if the current environment is WSL and the browser path is located in the host OS', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(1) + + expect( + await new FirefoxBrowser({ + path: '/mnt/c/Program Files/Firefox/firefox.exe', + }).browserInWSLHost() + ).toBe(true) + }) + + it('returns false if the current environment is WSL and the browser path is not located in the host OS', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(1) + + expect( + await new FirefoxBrowser({ + path: '/usr/bin/firefox', + }).browserInWSLHost() + ).toBe(false) + }) + + it('returns true if the current environment is WSL, the browser path is located in the guest OS, but the spawned browser has located in the host OS', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(1) + + const browser = new FirefoxBrowser({ + path: '/usr/bin/firefox-host', // Assuming this is an alias to /mnt/c/Program Files/Firefox/firefox.exe + }) + + const browserMock = { + once: jest.fn(), + process: jest.fn(() => ({ + spawnfile: '/mnt/c/Program Files/Firefox/firefox.exe', + })), + } + + jest + .spyOn(browser as any, 'launchPuppeteer') + .mockResolvedValue(browserMock) + + // If not yet the browser is spawned, it is still false + expect(await browser.browserInWSLHost()).toBe(false) + + // After the browser is spawned, it should be true + expect(await browser.launch()).toBe(browserMock) + expect(await browser.browserInWSLHost()).toBe(true) + + // If close the browser, it should be false again + await browser.close() + expect(await browser.browserInWSLHost()).toBe(false) + }) + }) +}) diff --git a/test/browser/browsers/chrome-cdp.ts b/test/browser/browsers/chrome-cdp.ts new file mode 100644 index 00000000..559b316e --- /dev/null +++ b/test/browser/browsers/chrome-cdp.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs' +import * as puppeteer from 'puppeteer-core' +import type { CDPSession } from 'puppeteer-core' +import { ChromeBrowser } from '../../../src/browser/browsers/chrome' +import { ChromeCdpBrowser } from '../../../src/browser/browsers/chrome-cdp' + +jest.mock('puppeteer-core') + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +describe('ChromeCdpBrowser', () => { + it('extends ChromeBrowser', () => { + const browser = new ChromeCdpBrowser({ path: '/path/to/chrome' }) + expect(browser).toBeInstanceOf(ChromeBrowser) + }) + + describe('#launch', () => { + const { platform } = process + + let send: jest.Mock + + beforeEach(() => { + jest.resetModules() + + send = jest.fn().mockReturnValue(Promise.resolve()) + + jest.spyOn(puppeteer as any, 'launch').mockResolvedValue({ + once: jest.fn(), + target: jest.fn(() => ({ + createCDPSession: jest.fn(async () => ({ send })), + })), + }) + + jest.spyOn(fs.promises, 'mkdir').mockImplementation() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + }) + + it('calls #launch in puppeteer-core', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + await new ChromeCdpBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + browser: 'chrome', + protocol: 'cdp', + executablePath: '/path/to/chrome', + } as const satisfies puppeteer.PuppeteerLaunchOptions) + ) + }) + + it('sets the dock icon through Browser.setDockTile when the platform is darwin (macOS)', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + await new ChromeCdpBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/path/to/chrome' }) + ) + expect(send).toHaveBeenCalledWith( + 'Browser.setDockTile', + expect.objectContaining({ image: expect.any(String) }) + ) + + // Check a base64 image has header of PNG + const { image } = send.mock.calls[0][1] + const imageBuffer = Buffer.from(image, 'base64') + + // Buffer may change an invalid character as UTF-8 encoding to U+FFFD, so it may not start with 0x89 + expect(imageBuffer.subarray(0, 16).toString()).toStrictEqual( + expect.stringContaining('PNG') + ) + }) + }) +}) diff --git a/test/browser/browsers/chrome.ts b/test/browser/browsers/chrome.ts new file mode 100644 index 00000000..b6fdfd76 --- /dev/null +++ b/test/browser/browsers/chrome.ts @@ -0,0 +1,419 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import * as puppeteer from 'puppeteer-core' +import { ChromeBrowser } from '../../../src/browser/browsers/chrome' +import * as container from '../../../src/utils/container' +import * as wsl from '../../../src/utils/wsl' + +jest.mock('puppeteer-core') + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +describe('ChromeBrowser', () => { + describe('#launch', () => { + beforeEach(() => { + jest + .spyOn(puppeteer as any, 'launch') + .mockResolvedValue({ once: jest.fn() }) + + jest.spyOn(fs.promises, 'mkdir').mockImplementation() + }) + + it('calls #launch in puppeteer-core', async () => { + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + browser: 'chrome', + protocol: 'webDriverBiDi', + executablePath: '/path/to/chrome', + } as const satisfies puppeteer.PuppeteerLaunchOptions) + ) + }) + + it('merges passed options to puppeteer-core', async () => { + await new ChromeBrowser({ path: '/path/to/chrome' }).launch({ + env: { FOO: 'bar' }, + pipe: false, + slowMo: 50, + }) + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + env: { FOO: 'bar' }, + pipe: false, + slowMo: 50, + }) + ) + }) + + describe('Arguments', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + jest.resetModules() + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('launches with default arguments', async () => { + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['--test-type'], + ignoreDefaultArgs: [], + }) + ) + }) + + it('launches with merged arguments if args option is passed', async () => { + await new ChromeBrowser({ path: '/path/to/chrome' }).launch({ + args: ['--foo', '--bar', '--test-type'], + ignoreDefaultArgs: ['--ignore'], + }) + + // Duplicate arguments should be removed + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['--test-type', '--foo', '--bar'], + ignoreDefaultArgs: ['--ignore'], + }) + ) + }) + + describe('Disabling sandbox', () => { + it('adds --disable-sandbox argument if CHROME_NO_SANDBOX environment variable is defined', async () => { + process.env.CHROME_NO_SANDBOX = '1' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining(['--no-sandbox']), + }) + ) + }) + + it('adds --disable-sandbox argument if running within a container image', async () => { + jest.spyOn(container, 'isInsideContainer').mockReturnValue(true) + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining(['--no-sandbox']), + }) + ) + }) + + it('adds --disable-sandbox argument if running within WSL', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(1) + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining(['--no-sandbox']), + }) + ) + }) + }) + + describe('Disable extensions', () => { + it('ignores --disable-extensions argument if CHROME_ENABLE_EXTENSIONS environment variable is defined', async () => { + process.env.CHROME_ENABLE_EXTENSIONS = '1' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreDefaultArgs: expect.arrayContaining([ + '--disable-extensions', + ]), + }) + ) + }) + + it('does not ignore --disable-extensions argument if CHROME_ENABLE_EXTENSIONS environment variable is empty', async () => { + process.env.CHROME_ENABLE_EXTENSIONS = '' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ ignoreDefaultArgs: [] }) + ) + }) + + it('merges ignoreDefaultArgs if passed extra ignore args', async () => { + process.env.CHROME_ENABLE_EXTENSIONS = 'true' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch({ + ignoreDefaultArgs: ['--foo', '--bar'], + }) + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreDefaultArgs: expect.arrayContaining([ + '--disable-extensions', + '--foo', + '--bar', + ]), + }) + ) + }) + + it('ignores all default arguments if explicitly passed ignoreDefaultArgs as true', async () => { + process.env.CHROME_ENABLE_EXTENSIONS = 'TRUE' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch({ + ignoreDefaultArgs: true, + }) + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ ignoreDefaultArgs: true }) + ) + }) + + it('keeps ignoring --disable-extensions argument even if explicitly passed ignoreDefaultArgs as false', async () => { + process.env.CHROME_ENABLE_EXTENSIONS = 'enabled' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch({ + ignoreDefaultArgs: false, + }) + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + ignoreDefaultArgs: expect.arrayContaining([ + '--disable-extensions', + ]), + }) + ) + }) + }) + }) + + describe('Headless mode', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + jest.resetModules() + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('launches with default headless mode', async () => { + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ headless: true }) + ) + }) + + it('launches with default headless mode if PUPPETEER_HEADLESS_MODE environment variable is empty', async () => { + process.env.PUPPETEER_HEADLESS_MODE = '' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ headless: true }) + ) + }) + + it('launches with default headless mode if PUPPETEER_HEADLESS_MODE environment variable is unknown value', async () => { + process.env.PUPPETEER_HEADLESS_MODE = 'unknown' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ headless: true }) + ) + }) + + it('launches with using headless shell if PUPPETEER_HEADLESS_MODE environment variable is "old"', async () => { + process.env.PUPPETEER_HEADLESS_MODE = 'old' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ headless: 'shell' }) + ) + }) + + it('launches with using headless shell if PUPPETEER_HEADLESS_MODE environment variable is "OLD" (case sensitive)', async () => { + process.env.PUPPETEER_HEADLESS_MODE = 'OLD' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ headless: 'shell' }) + ) + }) + + it('launches with using headless shell if PUPPETEER_HEADLESS_MODE environment variable is "legacy"', async () => { + process.env.PUPPETEER_HEADLESS_MODE = 'legacy' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ headless: 'shell' }) + ) + }) + + it('launches with using headless shell if PUPPETEER_HEADLESS_MODE environment variable is "shell"', async () => { + process.env.PUPPETEER_HEADLESS_MODE = 'shell' + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ headless: 'shell' }) + ) + }) + }) + + describe('Pipe', () => { + const { platform } = process + + beforeEach(() => { + jest.resetModules() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + }) + + it('launches with enabled pipe', async () => { + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ pipe: true }) + ) + }) + + it('retries to launch with using WebSocket if failed to launch with pipe', async () => { + jest + .spyOn(puppeteer, 'launch') + .mockRejectedValueOnce(new Error('Failed')) + + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledTimes(2) + expect(puppeteer.launch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ pipe: true }) + ) + expect(puppeteer.launch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ pipe: false }) + ) + }) + + it('disables pipe if running on WSL', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(2) + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ pipe: false }) + ) + }) + + it('disables pipe if detected the snap browser', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + await new ChromeBrowser({ path: '/snap/bin/chrome' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ pipe: false }) + ) + }) + }) + + describe('Errors', () => { + const { platform } = process + + beforeEach(() => { + jest.resetModules() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + }) + + it('throws error if failed to launch', async () => { + jest + .spyOn(puppeteer, 'launch') + .mockRejectedValue(new Error('Failed to launch')) + + await expect( + new ChromeBrowser({ path: '/path/to/chrome' }).launch() + ).rejects.toThrow('Failed to launch') + }) + + it('throws user-friendly error if failed to launch the snap browser by AppArmor confinement', async () => { + jest + .spyOn(puppeteer, 'launch') + .mockRejectedValue(new Error('need to run as root or suid')) + + Object.defineProperty(process, 'platform', { value: 'linux' }) + + await expect( + new ChromeBrowser({ path: '/snap/bin/chrome' }).launch() + ).rejects.toMatchInlineSnapshot( + `[CLIError: Marp CLI has detected trying to spawn Chromium browser installed by snap, from the confined environment like another snap app. At least either of Chrome/Chromium or the shell environment must be non snap app.]` + ) + }) + }) + + describe('Data directory', () => { + it('makes uniq data directory', async () => { + const mockedMkdir = jest.mocked(fs.promises.mkdir) + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + + expect(mockedMkdir).toHaveBeenCalledWith( + expect.stringContaining(path.join(os.tmpdir(), 'marp-cli-')), + { recursive: true } + ) + + const userDataDir = mockedMkdir.mock.calls[0][0] + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ userDataDir }) + ) + + // Check uniqueness + mockedMkdir.mockClear() + await new ChromeBrowser({ path: '/path/to/chrome' }).launch() + expect(userDataDir).not.toEqual(mockedMkdir.mock.calls[0][0]) + }) + + it('makes data directory to Windows if trying to spawn Chrome in the host Windows from WSL', async () => { + jest.spyOn(wsl, 'isWSL').mockResolvedValue(1) + jest + .spyOn(wsl, 'resolveWindowsEnv') + .mockResolvedValue(String.raw`C:\Users\user\AppData\Local\Temp`) + jest + .spyOn(wsl, 'resolveWSLPathToGuestSync') + .mockImplementation((path) => + path + .replace(/\\/g, '/') + .replace( + /^([a-z]):\//i, + (_, drive) => `/mnt/${drive.toLowerCase()}/` + ) + ) + + await new ChromeBrowser({ + path: '/mnt/c/Program Files/Google/Chrome/chrome.exe', + }).launch() + + expect(fs.promises.mkdir).toHaveBeenCalledWith( + expect.stringContaining( + '/mnt/c/Users/user/AppData/Local/Temp/marp-cli-' + ), + { recursive: true } + ) + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + userDataDir: expect.stringContaining( + // Chrome is a Windows application, so the path should be Windows style + String.raw`C:\Users\user\AppData\Local\Temp\marp-cli-` + ), + }) + ) + }) + }) + }) +}) diff --git a/test/browser/browsers/firefox.ts b/test/browser/browsers/firefox.ts new file mode 100644 index 00000000..8a9d671e --- /dev/null +++ b/test/browser/browsers/firefox.ts @@ -0,0 +1,31 @@ +import * as puppeteer from 'puppeteer-core' +import { FirefoxBrowser } from '../../../src/browser/browsers/firefox' + +jest.mock('puppeteer-core') + +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() +}) + +describe('FirefoxBrowser', () => { + describe('#launch', () => { + beforeEach(() => { + jest + .spyOn(puppeteer as any, 'launch') + .mockResolvedValue({ once: jest.fn() }) + }) + + it('calls #launch in puppeteer-core', async () => { + await new FirefoxBrowser({ path: '/path/to/firefox' }).launch() + + expect(puppeteer.launch).toHaveBeenCalledWith( + expect.objectContaining({ + browser: 'firefox', + protocol: 'webDriverBiDi', + executablePath: '/path/to/firefox', + } as const satisfies puppeteer.PuppeteerLaunchOptions) + ) + }) + }) +}) diff --git a/test/browser/finder.ts b/test/browser/finder.ts index 591751b8..761ccafb 100644 --- a/test/browser/finder.ts +++ b/test/browser/finder.ts @@ -2,7 +2,7 @@ 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 { defaultFinders, findBrowser } from '../../src/browser/finder' import { CLIError } from '../../src/error' afterEach(() => { @@ -148,7 +148,7 @@ describe('Browser finder', () => { }) it('normalizes executable path if preferred path was pointed to valid app bundle', async () => { - const browser = await findBrowser(autoFinders, { + const browser = await findBrowser(defaultFinders, { preferredPath: macBundle('Valid.app'), }) @@ -159,7 +159,7 @@ describe('Browser finder', () => { }) it('does not normalize executable path if preferred path was pointed to invalid app bundle', async () => { - const browser = await findBrowser(autoFinders, { + const browser = await findBrowser(defaultFinders, { preferredPath: macBundle('Invalid.app'), }) diff --git a/test/browser/finders/chrome.ts b/test/browser/finders/chrome.ts index 5b436776..0949b04e 100644 --- a/test/browser/finders/chrome.ts +++ b/test/browser/finders/chrome.ts @@ -13,6 +13,8 @@ afterEach(() => { jest.restoreAllMocks() }) +const itExceptWin = process.platform === 'win32' ? it.skip : it + const executableMock = (name: string) => path.join(__dirname, `../../utils/_executable_mocks`, name) @@ -30,6 +32,55 @@ describe('Chrome finder', () => { }) }) + describe('with CHROME_PATH environment variable', () => { + const originalEnv = { ...process.env } + const regularResolution = new Error('Starting regular resolution') + + beforeEach(() => { + jest.resetModules() + jest.spyOn(utils, 'getPlatform').mockRejectedValue(regularResolution) + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('return the path for executable specified in CHROME_PATH', async () => { + process.env.CHROME_PATH = executableMock('empty') + + expect(await chromeFinder({})).toStrictEqual({ + path: process.env.CHROME_PATH, + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + + itExceptWin( + 'processes regular resolution if CHROME_PATH is not executable', + async () => { + process.env.CHROME_PATH = executableMock('non-executable') + + await expect(chromeFinder({})).rejects.toThrow(regularResolution) + } + ) + + it('processes regular resolution if CHROME_PATH is not found', async () => { + process.env.CHROME_PATH = executableMock('not-found') + + await expect(chromeFinder({})).rejects.toThrow(regularResolution) + }) + + it('prefers the preferred path over CHROME_PATH', async () => { + process.env.CHROME_PATH = executableMock('empty') + + expect( + await chromeFinder({ preferredPath: '/test/preferred/chrome' }) + ).toStrictEqual({ + path: '/test/preferred/chrome', + acceptedBrowsers: [ChromeBrowser, ChromeCdpBrowser], + }) + }) + }) + describe('with Linux', () => { beforeEach(() => { jest.spyOn(utils, 'getPlatform').mockResolvedValue('linux') diff --git a/test/browser/finders/edge.ts b/test/browser/finders/edge.ts index c4f6ad35..77ace60c 100644 --- a/test/browser/finders/edge.ts +++ b/test/browser/finders/edge.ts @@ -136,7 +136,7 @@ describe('Edge finder', () => { const winLocalAppData = ['C:', 'Mock', 'AppData', 'Local'] const edgePath = path.join(...winProgramFiles, ...winEdgeStable) - const originalEnv = process.env + const originalEnv = { ...process.env } beforeEach(() => { jest.resetModules() @@ -155,7 +155,7 @@ describe('Edge finder', () => { }) afterEach(() => { - process.env = originalEnv + process.env = { ...originalEnv } }) it('finds possible executable path and returns the matched path', async () => { diff --git a/test/browser/finders/firefox.ts b/test/browser/finders/firefox.ts index fc19adc4..516607b8 100644 --- a/test/browser/finders/firefox.ts +++ b/test/browser/finders/firefox.ts @@ -21,6 +21,8 @@ const winFxNightlyAlt = ['Firefox Nightly', 'firefox.exe'] const winFxDev = ['Firefox Developer Edition', 'firefox.exe'] const winFx = ['Mozilla Firefox', 'firefox.exe'] +const itExceptWin = process.platform === 'win32' ? it.skip : it + const executableMock = (name: string) => path.join(__dirname, `../../utils/_executable_mocks`, name) @@ -38,6 +40,55 @@ describe('Firefox finder', () => { }) }) + describe('with FIREFOX_PATH environment variable', () => { + const originalEnv = { ...process.env } + const regularResolution = new Error('Starting regular resolution') + + beforeEach(() => { + jest.resetModules() + jest.spyOn(utils, 'getPlatform').mockRejectedValue(regularResolution) + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('return the path for executable specified in FIREFOX_PATH', async () => { + process.env.FIREFOX_PATH = executableMock('empty') + + expect(await firefoxFinder({})).toStrictEqual({ + path: process.env.FIREFOX_PATH, + acceptedBrowsers: [FirefoxBrowser], + }) + }) + + itExceptWin( + 'processes regular resolution if FIREFOX_PATH is not executable', + async () => { + process.env.FIREFOX_PATH = executableMock('non-executable') + + await expect(firefoxFinder({})).rejects.toThrow(regularResolution) + } + ) + + it('processes regular resolution if FIREFOX_PATH is not found', async () => { + process.env.FIREFOX_PATH = executableMock('not-found') + + await expect(firefoxFinder({})).rejects.toThrow(regularResolution) + }) + + it('prefers the preferred path over FIREFOX_PATH', async () => { + process.env.FIREFOX_PATH = executableMock('empty') + + expect( + await firefoxFinder({ preferredPath: '/test/preferred/firefox' }) + ).toStrictEqual({ + path: '/test/preferred/firefox', + acceptedBrowsers: [FirefoxBrowser], + }) + }) + }) + describe('with Linux', () => { beforeEach(() => { jest.spyOn(utils, 'getPlatform').mockResolvedValue('linux') @@ -147,7 +198,7 @@ describe('Firefox finder', () => { 'Mozilla Firefox', 'firefox.exe' ) - const originalEnv = process.env + const originalEnv = { ...process.env } beforeEach(() => { jest.resetModules() @@ -166,7 +217,7 @@ describe('Firefox finder', () => { }) afterEach(() => { - process.env = originalEnv + process.env = { ...originalEnv } }) it('finds possible executable path and returns the matched path', async () => { @@ -249,7 +300,7 @@ describe('Firefox finder', () => { }) describe('with WSL1', () => { - const originalEnv = process.env + const originalEnv = { ...process.env } beforeEach(() => { jest.resetModules() @@ -265,7 +316,7 @@ describe('Firefox finder', () => { }) afterEach(() => { - process.env = originalEnv + process.env = { ...originalEnv } }) it('finds possible executable path and returns the matched path', async () => { diff --git a/test/browser/manager.ts b/test/browser/manager.ts index 88207ba0..7fe68ace 100644 --- a/test/browser/manager.ts +++ b/test/browser/manager.ts @@ -1,56 +1,123 @@ import { ChromeBrowser } from '../../src/browser/browsers/chrome' -import { browserManager, BrowserManager } from '../../src/browser/manager' +import { ChromeCdpBrowser } from '../../src/browser/browsers/chrome-cdp' +import { FirefoxBrowser } from '../../src/browser/browsers/firefox' +import * as browserFinder from '../../src/browser/finder' +import { BrowserManager } from '../../src/browser/manager' -describe('browserManager static instance', () => { - it('is an instance of BrowserManager', () => { - expect(browserManager).toBeInstanceOf(BrowserManager) - }) +afterEach(() => { + jest.resetAllMocks() + jest.restoreAllMocks() }) -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() +describe('Browser manager', () => { + describe('constructor', () => { + it('creates a new instance with specified options', () => { + const manager: any = new BrowserManager({ + finders: ['chrome', 'firefox'], + path: '/dummy/path', + protocol: 'webDriverBiDi', + timeout: 12345, + }) + + expect(manager).toBeInstanceOf(BrowserManager) + expect(manager._finders).toStrictEqual(['chrome', 'firefox']) + expect(manager._finderPreferredPath).toBe('/dummy/path') + expect(manager._preferredProtocol).toBe('webDriverBiDi') + expect(manager.timeout).toBe(12345) + }) + }) - expect(manager.findBy({ browser: previewBrowser })).toBeUndefined() - expect(manager.findBy({ browser: convertBrowser })).toBeUndefined() + describe('#browserForConversion', () => { + it('returns suited browser class for conversion', async () => { + jest.spyOn(browserFinder, 'findBrowser').mockResolvedValue({ + path: '/dummy/path/resolved', + acceptedBrowsers: [ChromeCdpBrowser, ChromeBrowser], + }) - manager.register(convertBrowser) - expect(manager.findBy({ browser: previewBrowser })).toBeUndefined() - expect(manager.findBy({ browser: convertBrowser })).toBe(convertBrowser) + const manager = new BrowserManager({ + finders: ['chrome', 'firefox'], + path: '/dummy/path', + protocol: 'webDriverBiDi', + }) - manager.register(previewBrowser) - expect(manager.findBy({ browser: previewBrowser })).toBe(previewBrowser) - expect(manager.findBy({ browser: convertBrowser })).toBe(convertBrowser) + expect(await manager.browserForConversion()).toBeInstanceOf(ChromeBrowser) + expect(browserFinder.findBrowser).toHaveBeenCalledWith( + ['chrome', 'firefox'], + { preferredPath: '/dummy/path' } + ) + + // Check whether the found result will be cached + await manager.browserForConversion() + expect(browserFinder.findBrowser).toHaveBeenCalledTimes(1) + }) + + it('throws an error if no suited browser is found', async () => { + jest + .spyOn(browserFinder, 'findBrowser') + .mockResolvedValue({ path: '/dev/null', acceptedBrowsers: [] }) + + await expect(new BrowserManager().browserForConversion()).rejects.toThrow( + 'No browser found for conversion' + ) + }) + + it('uses first browser if the preferred protocol did not match', async () => { + jest.spyOn(browserFinder, 'findBrowser').mockResolvedValue({ + path: '/dummy/path/resolved', + acceptedBrowsers: [FirefoxBrowser], + }) + + const manager = new BrowserManager({ + finders: ['firefox'], + protocol: 'cdp', + }) + + expect(await manager.browserForConversion()).toBeInstanceOf( + FirefoxBrowser + ) + expect(browserFinder.findBrowser).toHaveBeenCalledWith(['firefox'], { + preferredPath: undefined, + }) }) }) - describe('#findBy', () => { - it('finds a browser by query', () => { - const browser = new ChromeBrowser({ purpose: 'convert' }) - const manager = new BrowserManager() + describe('#browserForPreview', () => { + it('returns suited browser class for preview', async () => { + jest.spyOn(browserFinder, 'findBrowser').mockResolvedValue({ + path: '/dummy/path/resolved', + acceptedBrowsers: [ChromeCdpBrowser, ChromeBrowser], + }) - manager.register(browser) + const manager = new BrowserManager({ + finders: ['chrome', 'firefox'], + path: '/dummy/path', + protocol: 'webDriverBiDi', + timeout: 0, + }) - // Find by instance - expect(manager.findBy({ browser })).toBe(browser) - expect( - manager.findBy({ browser: new ChromeBrowser({ purpose: 'preview' }) }) - ).toBeUndefined() + // Preview window is depending on CDP protocol, so it should return ChromeCdpBrowser + const cdpBrowser = await manager.browserForPreview() + expect(cdpBrowser).toBeInstanceOf(ChromeCdpBrowser) + expect(cdpBrowser.timeout).toBe(0) + expect(browserFinder.findBrowser).toHaveBeenCalledWith( + ['chrome', 'firefox'], + { preferredPath: '/dummy/path' } + ) - // Find by kind - expect(manager.findBy({ kind: 'chrome' })).toBe(browser) - expect(manager.findBy({ kind: 'firefox' })).toBeUndefined() + // Check whether the found result will be cached + await manager.browserForPreview() + expect(browserFinder.findBrowser).toHaveBeenCalledTimes(1) + }) - // Find by protocol - expect(manager.findBy({ protocol: 'webdriver-bidi' })).toBe(browser) - expect(manager.findBy({ protocol: 'cdp' })).toBeUndefined() + it('throws an error if no suited browser is found', async () => { + jest.spyOn(browserFinder, 'findBrowser').mockResolvedValue({ + path: '/dummy/path/resolved', + acceptedBrowsers: [FirefoxBrowser], + }) - // Find by purpose - expect(manager.findBy({ purpose: 'convert' })).toBe(browser) - expect(manager.findBy({ purpose: 'preview' })).toBeUndefined() + await expect(new BrowserManager().browserForPreview()).rejects.toThrow( + 'No browser found for preview' + ) }) }) }) diff --git a/test/converter.ts b/test/converter.ts index d82e0974..cf2f9e04 100644 --- a/test/converter.ts +++ b/test/converter.ts @@ -10,6 +10,7 @@ import { imageSize } from 'image-size' import { PDFDocument, PDFDict, PDFName, PDFHexString, PDFNumber } from 'pdf-lib' import { TimeoutError } from 'puppeteer-core' import { fromBuffer as yauzlFromBuffer } from 'yauzl' +import { BrowserManager } from '../src/browser/manager' import { Converter, ConvertType, ConverterOption } from '../src/converter' import { CLIError } from '../src/error' import { File, FileType } from '../src/file' @@ -17,9 +18,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') // eslint-disable-line @typescript-eslint/no-require-imports -- Puppeteer's internal module - -const puppeteerTimeoutMs = 60000 +const timeout = 60000 let mkdirSpy: jest.SpiedFunction @@ -30,18 +29,27 @@ beforeEach(() => { }) afterEach(() => mkdirSpy.mockRestore()) -afterAll(() => Converter.closeBrowser()) describe('Converter', () => { const onePath = path.resolve(__dirname, '_files/1.md') const twoPath = path.resolve(__dirname, '_files/2.mdown') const threePath = path.resolve(__dirname, '_files/3.markdown') + let browserManager: BrowserManager let themeSet: ThemeSet - beforeEach(async () => (themeSet = await ThemeSet.initialize([]))) + + beforeEach(async () => { + browserManager = new BrowserManager({ timeout }) + themeSet = await ThemeSet.initialize([]) + }) + + afterEach(async () => { + await browserManager.dispose() + }) const instance = (opts: Partial = {}) => new Converter({ + browserManager, themeSet, allowLocalFiles: false, engine: Marp, @@ -57,8 +65,11 @@ describe('Converter', () => { }) describe('#constructor', () => { - it('assigns initial options to options member', () => { + it('assigns initial options to options member', async () => { + await using browserManager = new BrowserManager({ timeout: 12345 }) + const options = { + browserManager, themeSet, allowLocalFiles: true, engine: Marp, @@ -88,17 +99,6 @@ describe('Converter', () => { }) }) - describe('get #puppeteerTimeout', () => { - it('returns specified timeout', () => { - expect(instance({ puppeteerTimeout: 1000 }).puppeteerTimeout).toBe(1000) - expect(instance({ puppeteerTimeout: 0 }).puppeteerTimeout).toBe(0) - }) - - it('returns the default timeout 30000ms if not specified', () => { - expect(instance().puppeteerTimeout).toBe(30000) - }) - }) - describe('#convert', () => { const md = '# Hello!' const dummyFile = new File(process.cwd()) @@ -555,7 +555,7 @@ describe('Converter', () => { expect(ret.newFile?.path).toBe('test.pdf') expect(ret.newFile?.buffer).toBe(pdf) }, - puppeteerTimeoutMs + timeout ) describe('with meta global directives', () => { @@ -581,7 +581,7 @@ describe('Converter', () => { expect(pdf.getAuthor()).toBe('author') expect(pdf.getKeywords()).toBe('a; b; c') }, - puppeteerTimeoutMs + timeout ) }) @@ -624,7 +624,7 @@ describe('Converter', () => { warn.mockRestore() } }, - puppeteerTimeoutMs + timeout ) }) @@ -651,7 +651,7 @@ describe('Converter', () => { PDFHexString.fromText('presenter note') ) }, - puppeteerTimeoutMs + timeout ) it('sets a comment author to notes if set author global directive', async () => { @@ -788,7 +788,7 @@ describe('Converter', () => { ] `) }, - puppeteerTimeoutMs + timeout ) }) @@ -833,7 +833,7 @@ describe('Converter', () => { ] `) }, - puppeteerTimeoutMs + timeout ) }) @@ -898,19 +898,21 @@ describe('Converter', () => { ] `) }, - puppeteerTimeoutMs + timeout ) }) }) - describe('with custom puppeteer timeout', () => { + describe('with custom timeout set by browser manager', () => { it('follows setting timeout', async () => { ;(fs as any).__mockWriteFile() + await using browserManager = new BrowserManager({ timeout: 1 }) + await expect( pdfInstance({ + browserManager, output: 'test.pdf', - puppeteerTimeout: 1, }).convertFile(new File(onePath)) ).rejects.toThrow(TimeoutError) }) @@ -970,15 +972,18 @@ describe('Converter', () => { it( 'converts markdown file into PPTX', async () => { - const setViewport = jest.spyOn(CdpPage.prototype, 'setViewport') + const cvt = converter() + const imageSpy = jest.spyOn(cvt as any, 'convertFileToImage') - await converter().convertFile(new File(onePath)) + await cvt.convertFile(new File(onePath)) expect(write).toHaveBeenCalled() expect(write.mock.calls[0][0]).toBe('test.pptx') // It has a different default scale x2 - expect(setViewport).toHaveBeenCalledWith( - expect.objectContaining({ deviceScaleFactor: 2 }) + expect(imageSpy).toHaveBeenLastCalledWith( + expect.anything(), // Template + expect.anything(), // File + expect.objectContaining({ scale: 2 }) ) // ZIP PK header for Office Open XML @@ -990,23 +995,24 @@ describe('Converter', () => { const meta = await getPptxDocProps(pptx) expect(meta['dc:creator']).toBe('Created by Marp') }, - puppeteerTimeoutMs + timeout ) describe('with meta global directives', () => { it( 'assigns meta info thorugh PptxGenJs', async () => { - const setViewport = jest.spyOn(CdpPage.prototype, 'setViewport') - - await converter({ + const cvt = converter({ imageScale: 1, globalDirectives: { title: 'Test meta', description: 'Test description', author: 'author', }, - }).convertFile(new File(onePath)) + }) + const imageSpy = jest.spyOn(cvt as any, 'convertFileToImage') + + await cvt.convertFile(new File(onePath)) const pptx: Buffer = write.mock.calls[0][1] const meta = await getPptxDocProps(pptx) @@ -1016,11 +1022,13 @@ describe('Converter', () => { expect(meta['dc:creator']).toBe('author') // Custom scale - expect(setViewport).toHaveBeenCalledWith( - expect.objectContaining({ deviceScaleFactor: 1 }) + expect(imageSpy).toHaveBeenLastCalledWith( + expect.anything(), // Template + expect.anything(), // File + expect.objectContaining({ scale: 1 }) ) }, - puppeteerTimeoutMs + timeout ) }) }) @@ -1050,7 +1058,7 @@ describe('Converter', () => { expect(width).toBe(1280) expect(height).toBe(720) }, - puppeteerTimeoutMs + timeout ) describe('with 4:3 size global directive for Marp Core', () => { @@ -1069,7 +1077,7 @@ describe('Converter', () => { expect(width).toBe(960) expect(height).toBe(720) }, - puppeteerTimeoutMs + timeout ) }) @@ -1090,7 +1098,7 @@ describe('Converter', () => { expect(width).toBe(640) expect(height).toBe(360) }, - puppeteerTimeoutMs + timeout ) }) }) @@ -1121,7 +1129,7 @@ describe('Converter', () => { expect(width).toBe(1280) expect(height).toBe(720) }, - puppeteerTimeoutMs + timeout ) describe('with 4:3 size global directive for Marp Core', () => { @@ -1141,7 +1149,7 @@ describe('Converter', () => { expect(width).toBe(960) expect(height).toBe(720) }, - puppeteerTimeoutMs + timeout ) }) @@ -1162,7 +1170,7 @@ describe('Converter', () => { expect(width).toBe(640) expect(height).toBe(360) }, - puppeteerTimeoutMs + timeout ) }) }) @@ -1189,7 +1197,7 @@ describe('Converter', () => { expect(write.mock.calls[0][0]).toBe('c.001.png') expect(write.mock.calls[1][0]).toBe('c.002.png') }, - puppeteerTimeoutMs + timeout ) }) diff --git a/test/index.ts b/test/index.ts index cc67ccef..811eeab4 100644 --- a/test/index.ts +++ b/test/index.ts @@ -2,7 +2,6 @@ import path from 'node:path' import getStdin from 'get-stdin' import api, { CLIError } from '../src/index' import * as marpCli from '../src/marp-cli' -import * as puppeteerUtil from '../src/utils/puppeteer' const stdinBuffer = getStdin.buffer @@ -55,22 +54,4 @@ describe('Marp CLI API interface', () => { cliError.mockRestore() } }) - - it('resets cached Chrome path every time', async () => { - const resetExecutablePathSpy = jest.spyOn( - puppeteerUtil, - 'resetExecutablePath' - ) - const marpCliSpy = jest.spyOn(marpCli, 'marpCli').mockResolvedValue(0) - - try { - await api([]) - expect(resetExecutablePathSpy).toHaveBeenCalledTimes(1) - - await api([]) - expect(resetExecutablePathSpy).toHaveBeenCalledTimes(2) - } finally { - marpCliSpy.mockRestore() - } - }) }) diff --git a/test/marp-cli.ts b/test/marp-cli.ts index b705204b..7bf49e98 100644 --- a/test/marp-cli.ts +++ b/test/marp-cli.ts @@ -38,8 +38,8 @@ const runForObservation = async (argv: string[]) => { return ret } -jest.mock('cosmiconfig') jest.mock('node:fs', () => require('./__mocks__/node/fs')) // eslint-disable-line @typescript-eslint/no-require-imports, jest/no-mocks-import -- Windows file system cannot use `:` +jest.mock('cosmiconfig') jest.mock('../src/preview') jest.mock('../src/watcher', () => jest.createMockFromModule('../src/watcher')) @@ -1344,7 +1344,7 @@ describe('Marp CLI', () => { it('follows specified timeout in conversion that is using Puppeteer', async () => { expect( - (await conversion(onePath, '--pdf')).options.puppeteerTimeout + (await conversion(onePath, '--pdf')).options.browserManager.timeout ).toBe(12345) }) @@ -1352,7 +1352,7 @@ describe('Marp CLI', () => { process.env.PUPPETEER_TIMEOUT = 'invalid' expect( - (await conversion(onePath, '--pdf')).options.puppeteerTimeout + (await conversion(onePath, '--pdf')).options.browserManager.timeout ).toBeUndefined() }) }) diff --git a/test/preview.ts b/test/preview.ts index e38d5421..2063cf8b 100644 --- a/test/preview.ts +++ b/test/preview.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { BrowserManager } from '../src/browser/manager' import { ConvertType } from '../src/converter' import { CLIError } from '../src/error' import { File, FileType } from '../src/file' @@ -8,9 +9,14 @@ jest.mock('node:path', () => require('./__mocks__/node/path')) // eslint-disable jest.setTimeout(40000) describe('Preview', () => { + let browserManager: BrowserManager + + beforeAll(() => (browserManager = new BrowserManager())) + afterAll(async () => browserManager?.dispose()) + const previews = new Set() - const preview = (...args): Preview => { - const instance = new Preview(...args) + const preview = (opts: Partial = {}): Preview => { + const instance = new Preview({ browserManager, ...opts }) previews.add(instance) return instance @@ -128,6 +134,16 @@ describe('Preview', () => { )) }) }) + + describe('when opening data URI', () => { + it('opens data URI converted Blob URL to avoid URL length limit', async () => { + const instance = preview() + const win = await instance.open('data:text/html,') + + expect(await instance.puppeteer?.pages()).toHaveLength(1) + expect(win.page.url()).toStrictEqual(expect.stringMatching(/^blob:/)) + }) + }) }) }) diff --git a/test/server.ts b/test/server.ts index a7bf90ac..51a55086 100644 --- a/test/server.ts +++ b/test/server.ts @@ -4,6 +4,7 @@ import { Marp } from '@marp-team/marp-core' import { load } from 'cheerio' import express, { type Express } from 'express' import request from 'supertest' +import { BrowserManager } from '../src/browser/manager' import { Converter, ConvertType, @@ -17,11 +18,16 @@ import { ThemeSet } from '../src/theme' jest.mock('express') describe('Server', () => { + let browserManager: BrowserManager let themeSet: ThemeSet + + beforeAll(() => (browserManager = new BrowserManager())) beforeEach(async () => (themeSet = await ThemeSet.initialize([]))) + afterAll(async () => browserManager?.dispose()) const converter = (opts: Partial = {}) => new Converter({ + browserManager, themeSet, allowLocalFiles: false, engine: Marp, diff --git a/test/utils/edge-finder.ts b/test/utils/edge-finder.ts deleted file mode 100644 index 4c830c2f..00000000 --- a/test/utils/edge-finder.ts +++ /dev/null @@ -1,189 +0,0 @@ -import path from 'node:path' -import * as edgeFinder from '../../src/utils/edge-finder' -import * as wsl from '../../src/utils/wsl' - -jest.mock('../../src/utils/wsl') - -let originalPlatform: NodeJS.Platform | undefined - -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'] - -beforeEach(() => jest.spyOn(wsl, 'isWSL').mockImplementation()) -afterEach(() => { - jest.restoreAllMocks() - - if (originalPlatform) { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - } -}) - -describe('#findAccessiblePath', () => { - it('return the first accessible path', () => { - const unknownFile = path.resolve(__dirname, 'unknown') - const marpCliExecutable = path.resolve(__dirname, '../../marp-cli.js') - - expect( - edgeFinder.findAccessiblePath([unknownFile, marpCliExecutable]) - ).toBe(marpCliExecutable) - expect(edgeFinder.findAccessiblePath([unknownFile])).toBeUndefined() - }) -}) - -describe('#findEdgeInstallation', () => { - describe('Windows', () => { - beforeEach(() => { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'win32' }) - }) - - it('finds out the first accessible Edge from 3 locations', async () => { - const currentEnv = process.env - - const programFiles = path.join('C:', 'Mock', 'Program Files') - const programFilesX86 = path.join('C:', 'Mock', 'Program Files (x86)') - const localAppData = path.join('C:', 'Mock', 'Local') - - try { - process.env.PROGRAMFILES = programFiles - process.env['PROGRAMFILES(X86)'] = programFilesX86 - process.env.LOCALAPPDATA = localAppData - - const findAccessiblePath = jest - .spyOn(edgeFinder, 'findAccessiblePath') - .mockImplementation( - () => 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe' - ) - - expect(await edgeFinder.findEdgeInstallation()).toBe( - 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe' - ) - expect(findAccessiblePath.mock.calls[0][0]).toStrictEqual([ - path.join(localAppData, ...winEdgeCanary), - path.join(programFiles, ...winEdgeCanary), - path.join(programFilesX86, ...winEdgeCanary), - path.join(localAppData, ...winEdgeDev), - path.join(programFiles, ...winEdgeDev), - path.join(programFilesX86, ...winEdgeDev), - path.join(localAppData, ...winEdgeBeta), - path.join(programFiles, ...winEdgeBeta), - path.join(programFilesX86, ...winEdgeBeta), - path.join(localAppData, ...winEdgeStable), - path.join(programFiles, ...winEdgeStable), - path.join(programFilesX86, ...winEdgeStable), - ]) - } finally { - delete process.env.PROGRAMFILES - delete process.env['PROGRAMFILES(X86)'] - delete process.env.LOCALAPPDATA - - process.env = currentEnv - } - }) - }) - - describe('macOS', () => { - beforeEach(() => { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'darwin' }) - }) - - it('finds out the first accessible Edge from specific paths', async () => { - const findAccessiblePath = jest - .spyOn(edgeFinder, 'findAccessiblePath') - .mockImplementation( - () => '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' - ) - - expect(await edgeFinder.findEdgeInstallation()).toBe( - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' - ) - expect(findAccessiblePath.mock.calls[0][0]).toMatchInlineSnapshot(` - [ - "/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", - ] - `) - }) - }) - - describe('Linux', () => { - beforeEach(() => { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'linux' }) - }) - - it('finds out the first accessible Edge from specific paths', async () => { - const findAccessiblePath = jest - .spyOn(edgeFinder, 'findAccessiblePath') - .mockImplementation(() => '/opt/microsoft/msedge/msedge') - - expect(await edgeFinder.findEdgeInstallation()).toBe( - '/opt/microsoft/msedge/msedge' - ) - expect(findAccessiblePath.mock.calls[0][0]).toMatchInlineSnapshot(` - [ - "/opt/microsoft/msedge-canary/msedge", - "/opt/microsoft/msedge-dev/msedge", - "/opt/microsoft/msedge-beta/msedge", - "/opt/microsoft/msedge/msedge", - ] - `) - }) - - describe('on Windows WSL', () => { - beforeEach(() => - jest.spyOn(wsl, 'isWSL').mockImplementation(async () => 1) - ) - - it('finds out the first accessible Edge from mounted Windows location', async () => { - const findAccessiblePath = jest - .spyOn(edgeFinder, 'findAccessiblePath') - .mockImplementation() - - const resolveWindowsEnvSync = jest - .spyOn(wsl, 'resolveWindowsEnvSync') - .mockImplementation(() => 'C:\\Mock\\Local') - - const resolveWSLPathToGuestSync = jest - .spyOn(wsl, 'resolveWSLPathToGuestSync') - .mockImplementation(() => '/mnt/c/mock/Local') - - await edgeFinder.findEdgeInstallation() - expect(resolveWindowsEnvSync).toHaveBeenCalledWith('LOCALAPPDATA') - expect(resolveWSLPathToGuestSync).toHaveBeenCalledWith( - 'C:\\Mock\\Local' - ) - expect(findAccessiblePath).toHaveBeenCalledWith([ - path.join('/mnt/c/mock/Local', ...winEdgeCanary), - path.join('/mnt/c/Program Files', ...winEdgeCanary), - path.join('/mnt/c/Program Files (x86)', ...winEdgeCanary), - path.join('/mnt/c/mock/Local', ...winEdgeDev), - path.join('/mnt/c/Program Files', ...winEdgeDev), - path.join('/mnt/c/Program Files (x86)', ...winEdgeDev), - path.join('/mnt/c/mock/Local', ...winEdgeBeta), - path.join('/mnt/c/Program Files', ...winEdgeBeta), - path.join('/mnt/c/Program Files (x86)', ...winEdgeBeta), - path.join('/mnt/c/mock/Local', ...winEdgeStable), - path.join('/mnt/c/Program Files', ...winEdgeStable), - path.join('/mnt/c/Program Files (x86)', ...winEdgeStable), - ]) - }) - }) - }) - - describe('Unknown platform', () => { - beforeEach(() => { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'unknown' }) - }) - - it('returns undefined', async () => { - expect(await edgeFinder.findEdgeInstallation()).toBeUndefined() - }) - }) -}) diff --git a/test/utils/puppeteer.ts b/test/utils/puppeteer.ts deleted file mode 100644 index 72887178..00000000 --- a/test/utils/puppeteer.ts +++ /dev/null @@ -1,426 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -import os from 'node:os' -import path from 'node:path' - -jest.mock('../../src/utils/chrome-finder') -jest.mock('../../src/utils/edge-finder') -jest.mock('../../src/utils/wsl') - -const CLIError = (): typeof import('../../src/error').CLIError => - require('../../src/error').CLIError - -const puppeteer = (): typeof import('puppeteer-core') => - require('puppeteer-core') - -const puppeteerUtils = (): typeof import('../../src/utils/puppeteer') => - require('../../src/utils/puppeteer') - -const chromeFinder = (): typeof import('../../src/utils/chrome-finder') => - require('../../src/utils/chrome-finder') - -const container = (): typeof import('../../src/utils/container') => - require('../../src/utils/container') - -const edgeFinder = (): typeof import('../../src/utils/edge-finder') => - require('../../src/utils/edge-finder') - -const wsl = (): typeof import('../../src/utils/wsl') => - require('../../src/utils/wsl') - -beforeEach(() => jest.resetModules()) -afterEach(() => jest.restoreAllMocks()) - -describe('#generatePuppeteerDataDirPath', () => { - let mkdirSpy: jest.SpyInstance - - beforeEach(async () => { - const { promises } = await import('node:fs') - - mkdirSpy = jest.spyOn(promises, 'mkdir') - mkdirSpy.mockImplementation(() => Promise.resolve(undefined)) - }) - - it('returns the path of created data dir for OS specific temporary directory', async () => { - const dataDir = - await puppeteerUtils().generatePuppeteerDataDirPath('tmp-name') - const expectedDir = path.resolve(os.tmpdir(), 'tmp-name') - - expect(dataDir).toContain(expectedDir) - expect(mkdirSpy).toHaveBeenCalledWith( - expect.stringContaining(expectedDir), - { recursive: true } - ) - }) - - it('ignores EEXIST error thrown by mkdir', async () => { - // EEXIST error - mkdirSpy.mockRejectedValueOnce( - Object.assign(new Error('EEXIST'), { code: 'EEXIST' }) - ) - - await expect( - puppeteerUtils().generatePuppeteerDataDirPath('tmp-name') - ).resolves.toStrictEqual(expect.any(String)) - - // Regular error - const err = new Error('Regular error') - mkdirSpy.mockRejectedValueOnce(err) - - await expect( - puppeteerUtils().generatePuppeteerDataDirPath('tmp-name') - ).rejects.toBe(err) - }) - - describe('with wslPath option', () => { - it('returns regular path if the current environment is not WSL', async () => { - expect( - await puppeteerUtils().generatePuppeteerDataDirPath('tmp-name', { - wslHost: true, - }) - ).toBe(await puppeteerUtils().generatePuppeteerDataDirPath('tmp-name')) - }) - - it('resolves tmpdir for Windows and returns data dir path for resolved directory', async () => { - jest.spyOn(wsl(), 'isWSL').mockResolvedValue(1) - - const resolveWindowsEnv = jest - .spyOn(wsl(), 'resolveWindowsEnv') - .mockResolvedValue('C:\\Test\\Tmp') - - const dataDir = await puppeteerUtils().generatePuppeteerDataDirPath( - 'tmp-name', - { wslHost: true } - ) - - expect(resolveWindowsEnv).toHaveBeenCalledWith('TMP') - expect(dataDir).toContain('C:\\Test\\Tmp\\tmp-name') - }) - }) -}) - -describe('#generatePuppeteerLaunchArgs', () => { - it('finds out installed Chrome through chrome finder', async () => { - const getFirstInstallation = jest - .spyOn(chromeFinder(), 'findChromeInstallation') - .mockResolvedValue('/usr/bin/chromium') - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.executablePath).toBe('/usr/bin/chromium') - expect(getFirstInstallation).toHaveBeenCalledTimes(1) - - // Cache found result - await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(getFirstInstallation).toHaveBeenCalledTimes(1) - }) - - it('finds out installed Edge as the fallback if not found Chrome', async () => { - jest.spyOn(chromeFinder(), 'findChromeInstallation').mockImplementation() - jest - .spyOn(edgeFinder(), 'findEdgeInstallation') - .mockResolvedValue('/usr/bin/msedge') - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.executablePath).toBe('/usr/bin/msedge') - expect(edgeFinder().findEdgeInstallation).toHaveBeenCalledTimes(1) - }) - - it('throws CLIError with specific error code if not found executable path', async () => { - const warn = jest.spyOn(console, 'warn').mockImplementation() - - jest - .spyOn(chromeFinder(), 'findChromeInstallation') - .mockImplementation(() => { - throw new Error('Error in chrome finder') - }) - jest.spyOn(edgeFinder(), 'findEdgeInstallation').mockImplementation() - - await expect(puppeteerUtils().generatePuppeteerLaunchArgs).rejects.toThrow( - 'You have to install Google Chrome' - ) - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Error in chrome finder') - ) - }) - - it('uses custom executable path if CHROME_PATH environment value was defined as executable path', async () => { - jest.dontMock('../../src/utils/chrome-finder') - - try { - process.env.CHROME_PATH = path.resolve(__dirname, '../../marp-cli.js') - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.executablePath).toBe(process.env.CHROME_PATH) - } finally { - delete process.env.CHROME_PATH - } - }) - - it('disables sandbox if defined CHROME_NO_SANDBOX environment value', async () => { - try { - process.env.CHROME_NO_SANDBOX = '1' - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.args).toContain('--no-sandbox') - } finally { - delete process.env.CHROME_NO_SANDBOX - } - }) - - it('disables sandbox if running within a container image', async () => { - jest.spyOn(container(), 'isInsideContainer').mockImplementation(() => true) - jest - .spyOn(chromeFinder(), 'findChromeInstallation') - .mockResolvedValue('/usr/bin/chromium') - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.args).toContain('--no-sandbox') - }) - - it("ignores puppeteer's --disable-extensions option if defined CHROME_ENABLE_EXTENSIONS environment value", async () => { - try { - process.env.CHROME_ENABLE_EXTENSIONS = 'true' - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.ignoreDefaultArgs).toContain('--disable-extensions') - } finally { - delete process.env.CHROME_ENABLE_EXTENSIONS - } - }) - - it('enables LayoutNGPrinting and LayoutNGTableFragmentation if defined CHROME_LAYOUTNG_PRINTING environment value', async () => { - try { - process.env.CHROME_LAYOUTNG_PRINTING = '1' - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.args).toContain( - '--enable-blink-features=LayoutNGPrinting,LayoutNGTableFragmentation' - ) - } finally { - delete process.env.CHROME_LAYOUTNG_PRINTING - } - }) - - describe('with PUPPETEER_HEADLESS_MODE env', () => { - it('uses headless mode if PUPPETEER_HEADLESS_MODE was empty', async () => { - try { - process.env.PUPPETEER_HEADLESS_MODE = '' - - const { headless } = - await puppeteerUtils().generatePuppeteerLaunchArgs() - - expect(headless).toBe(true) - } finally { - delete process.env.PUPPETEER_HEADLESS_MODE - } - }) - - it('uses new headless mode if PUPPETEER_HEADLESS_MODE was "new"', async () => { - try { - process.env.PUPPETEER_HEADLESS_MODE = 'new' - - const { headless } = - await puppeteerUtils().generatePuppeteerLaunchArgs() - - expect(headless).toBe(true) - } finally { - delete process.env.PUPPETEER_HEADLESS_MODE - } - }) - - it('uses legacy headless mode if PUPPETEER_HEADLESS_MODE was "legacy"', async () => { - try { - process.env.PUPPETEER_HEADLESS_MODE = 'legacy' - - const { headless } = - await puppeteerUtils().generatePuppeteerLaunchArgs() - - expect(headless).toBe('shell') - } finally { - delete process.env.PUPPETEER_HEADLESS_MODE - } - }) - - it('uses legacy headless mode if PUPPETEER_HEADLESS_MODE was "old"', async () => { - try { - process.env.PUPPETEER_HEADLESS_MODE = 'old' - - const { headless } = - await puppeteerUtils().generatePuppeteerLaunchArgs() - - expect(headless).toBe('shell') - } finally { - delete process.env.PUPPETEER_HEADLESS_MODE - } - }) - - it('uses legacy headless mode if PUPPETEER_HEADLESS_MODE was "shell"', async () => { - try { - process.env.PUPPETEER_HEADLESS_MODE = 'shell' - - const { headless } = - await puppeteerUtils().generatePuppeteerLaunchArgs() - - expect(headless).toBe('shell') - } finally { - delete process.env.PUPPETEER_HEADLESS_MODE - } - }) - }) - - describe('with CHROME_PATH env in macOS', () => { - let originalPlatform: string | undefined - - beforeEach(() => { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'darwin' }) - - jest.dontMock('../../src/utils/chrome-finder') - - // Set Edge fallback as a sentinel - jest - .spyOn(edgeFinder(), 'findEdgeInstallation') - .mockResolvedValue('/usr/bin/msedge') - }) - - afterEach(() => { - if (originalPlatform !== undefined) { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - } - originalPlatform = undefined - - delete process.env.CHROME_PATH - }) - - it('does not apply custom path if CHROME_PATH was undefined', async () => { - process.env.CHROME_PATH = undefined - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.executablePath).toBeTruthy() - }) - - it('tries to resolve the executable binary if CHROME_PATH was pointed to valid app bundle', async () => { - const validAppPath = path.resolve(__dirname, './_mac_bundles/Valid.app') - const validAppExecutable = path.resolve( - __dirname, - './_mac_bundles/Valid.app/Contents/MacOS/Valid app' - ) - - process.env.CHROME_PATH = validAppPath - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.executablePath).toBe(validAppExecutable) - expect(process.env.CHROME_PATH).toBe(validAppPath) - }) - - it('fallbacks to an original custom path if CHROME_PATH was pointed to invalid app bundle', async () => { - const invalidAppPath = path.resolve( - __dirname, - './_mac_bundles/Invalid.app' - ) - - process.env.CHROME_PATH = invalidAppPath - - const args = await puppeteerUtils().generatePuppeteerLaunchArgs() - expect(args.executablePath).toBe(invalidAppPath) - expect(process.env.CHROME_PATH).toBe(invalidAppPath) - }) - }) -}) - -describe('#launchPuppeteer', () => { - let launchSpy: jest.SpyInstance - - beforeEach(() => { - launchSpy = jest.spyOn(puppeteer(), 'launch').mockImplementation() - }) - - it('delegates to puppeteer.launch', async () => { - const launchOpts = { headless: true } as const - await puppeteerUtils().launchPuppeteer(launchOpts) - - expect(launchSpy).toHaveBeenCalledTimes(1) - expect(launchSpy).toHaveBeenCalledWith(launchOpts) - }) - - describe('when rejected', () => { - it('rejects with an error occured in delegated puppeteer.launch', async () => { - const err = new Error('test') - launchSpy.mockRejectedValue(err) - - await expect(puppeteerUtils().launchPuppeteer()).rejects.toBe(err) - }) - - it('retries to launch with "pipe: false" if rejected with "Target.setDiscoverTargets" when pipe option is enabled', async () => { - const puppeteerKnownError = new Error( - 'Protocol error (Target.setDiscoverTargets): Target closed.' - ) - launchSpy.mockRejectedValueOnce(puppeteerKnownError) - - await puppeteerUtils().launchPuppeteer({ pipe: true }) - expect(launchSpy).toHaveBeenCalledTimes(2) - expect(launchSpy).toHaveBeenNthCalledWith(2, { pipe: false }) - }) - - describe('by AppArmor for snapd containment', () => { - // Simulate spawning error by AppArmor - const originalError = new Error('need to run as root or suid') - - let originalPlatform: NodeJS.Platform | undefined - - beforeEach(() => { - originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'linux' }) - - launchSpy.mockRejectedValue(originalError) - }) - - afterEach(() => { - if (originalPlatform) { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }) - } - }) - - it('rejects CLIError instead of original error if the executable path is snap binary', async () => { - await expect( - puppeteerUtils().launchPuppeteer({ - executablePath: '/snap/bin/chromium-browser', - }) - ).rejects.toMatchInlineSnapshot( - `[CLIError: Marp CLI has detected trying to spawn Chromium browser installed by snap, from the confined environment like another snap app. At least either of Chrome/Chromium or the shell environment must be non snap app.]` - ) - }) - - it('rejects an original error if the executable path is not snap executable', async () => { - await expect( - puppeteerUtils().launchPuppeteer({ - executablePath: path.resolve(__dirname, '_executable_mocks/empty'), - }) - ).rejects.toBe(originalError) - }) - - it('rejects CLIError if the executable path is shebang script that has included path to snap binary', async () => { - await expect( - puppeteerUtils().launchPuppeteer({ - executablePath: path.resolve( - __dirname, - '_executable_mocks/shebang-snapd-chromium' - ), - }) - ).rejects.toStrictEqual(expect.any(CLIError())) - }) - - it('rejects an original error if the executable path is shebang script that has included path to snap binary', async () => { - await expect( - puppeteerUtils().launchPuppeteer({ - executablePath: path.resolve( - __dirname, - '_executable_mocks/shebang-chromium' - ), - }) - ).rejects.toBe(originalError) - }) - }) - }) -}) diff --git a/test/utils/wsl.ts b/test/utils/wsl.ts index a2bfc60a..7fd6ea69 100644 --- a/test/utils/wsl.ts +++ b/test/utils/wsl.ts @@ -184,27 +184,3 @@ describe('#isWSL', () => { expect(readFile).toHaveBeenCalledTimes(1) }) }) - -describe('#isChromeInWSLHost', () => { - it('returns true if executed in WSL environment and the passed path is in WSL host', async () => { - const isWSL = jest.spyOn(wsl(), 'isWSL') - const { isChromeInWSLHost } = wsl() - - isWSL.mockResolvedValue(1) - expect(await isChromeInWSLHost('/mnt/c/Programs/Chrome/chrome.exe')).toBe( - true - ) - expect(await isChromeInWSLHost('/mnt/d/foo/bar/chrome')).toBe(true) - expect(await isChromeInWSLHost('/usr/bin/chrome')).toBe(false) - expect(await isChromeInWSLHost('/home/test/.chromium/chrome.exe')).toBe( - false - ) - expect(await isChromeInWSLHost(undefined)).toBe(false) - - isWSL.mockResolvedValue(0) - expect(await isChromeInWSLHost('/mnt/c/Programs/Chrome/chrome.exe')).toBe( - false - ) - expect(await isChromeInWSLHost('/mnt/d/foo/bar/chrome')).toBe(false) - }) -}) diff --git a/yarn.lock b/yarn.lock index 1abfcd0d..d69caf37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1027,6 +1027,13 @@ resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz#519c1549b0e147759e7825701ecffd25e5819f7b" integrity sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg== +"@emnapi/runtime@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.2.0.tgz#71d018546c3a91f3b51106530edbc056b9f2f2e3" + integrity sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ== + dependencies: + tslib "^2.4.0" + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1095,6 +1102,119 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.0.tgz#6d86b8cb322660f03d3f0aa94b99bdd8e172d570" integrity sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew== +"@img/sharp-darwin-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" + integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.0.4" + +"@img/sharp-darwin-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" + integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.0.4" + +"@img/sharp-libvips-darwin-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" + integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== + +"@img/sharp-libvips-darwin-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" + integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== + +"@img/sharp-libvips-linux-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" + integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== + +"@img/sharp-libvips-linux-arm@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" + integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== + +"@img/sharp-libvips-linux-s390x@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" + integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== + +"@img/sharp-libvips-linux-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" + integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== + +"@img/sharp-libvips-linuxmusl-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" + integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== + +"@img/sharp-libvips-linuxmusl-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" + integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== + +"@img/sharp-linux-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" + integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.0.4" + +"@img/sharp-linux-arm@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" + integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.0.5" + +"@img/sharp-linux-s390x@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" + integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.0.4" + +"@img/sharp-linux-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" + integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.0.4" + +"@img/sharp-linuxmusl-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" + integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + +"@img/sharp-linuxmusl-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" + integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + +"@img/sharp-wasm32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" + integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== + dependencies: + "@emnapi/runtime" "^1.2.0" + +"@img/sharp-win32-ia32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" + integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== + +"@img/sharp-win32-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" + integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -1715,6 +1835,13 @@ resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== +"@types/debug@^4.1.12": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + "@types/dom-view-transitions@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/dom-view-transitions/-/dom-view-transitions-1.0.5.tgz#06070954f1ebc2f94bbea5a64618574772eb4c1d" @@ -1831,25 +1958,25 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== +"@types/ms@*": + version "0.7.34" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" + integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== + "@types/node@*": - version "22.6.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.6.2.tgz#e09012a8fad9557a4bbbd690a9d5a373366a8595" - integrity sha512-roF5SJIJFOiDNf+vO6h/a0Y02/U4wJ7KwgDNrBdkBGUstvOkct7JQaSkF16jXn/UsAhvNeLEXqyuN6EUvg6+xw== + version "22.7.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.0.tgz#670aa1874bc836863e5c116f9f2c32416ff27e1f" + integrity sha512-MOdOibwBs6KW1vfqz2uKMlxq5xAfAZ98SZjO8e3XnAbFnTJtAspqhWk7hrdSAs9/Y14ZWMiy7/MxMUzAOadYEw== dependencies: undici-types "~6.19.2" -"@types/node@^18.7.3": +"@types/node@^18.7.3", "@types/node@~18.19.50": version "18.19.51" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.51.tgz#bebfb2f282de467f556a1a85aa74878b6ca0ab3a" integrity sha512-IIMkWEIVQDlBpi6pPeGqTqOx7KbzGC3EgIyH8NrxplXOwWw0uVl9vthJUMFrxD7kcEfcRp7jIkgpB28M6JnfWA== dependencies: undici-types "~5.26.4" -"@types/node@~16.18.108": - version "16.18.109" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.109.tgz#3d2c2ac102b3e8707f2512bb9609c00b2566f5f5" - integrity sha512-PxPCTJDDwBrigapKYIRHegNOMfKTeQUkZMJt+mkEwHf2rskRylueIqaHyAHfcpmFIFi7wq7f/X8Se/5hIVREvg== - "@types/normalize-package-data@^2.4.0": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" @@ -2301,9 +2428,9 @@ autoprefixer@^10.4.20: postcss-value-parser "^4.2.0" b4a@^1.6.4, b4a@^1.6.6: - version "1.6.6" - resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.6.tgz#a4cc349a3851987c3c4ac2d7785c18744f6da9ba" - integrity sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg== + version "1.6.7" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.7.tgz#a99587d4ebbfbd5a6e3b21bdb5d5fa385767abe4" + integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg== babel-jest@^29.7.0: version "29.7.0" @@ -2780,11 +2907,27 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@~1.1.4: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + colord@^2.9.1, colord@^2.9.3: version "2.9.3" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" @@ -3272,7 +3415,7 @@ destroy@1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-libc@^2.0.0: +detect-libc@^2.0.0, detect-libc@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== @@ -4590,6 +4733,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-builtin-module@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169" @@ -7511,6 +7659,35 @@ setprototypeof@1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +sharp@^0.33.5: + version "0.33.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" + integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== + dependencies: + color "^4.2.3" + detect-libc "^2.0.3" + semver "^7.6.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.33.5" + "@img/sharp-darwin-x64" "0.33.5" + "@img/sharp-libvips-darwin-arm64" "1.0.4" + "@img/sharp-libvips-darwin-x64" "1.0.4" + "@img/sharp-libvips-linux-arm" "1.0.5" + "@img/sharp-libvips-linux-arm64" "1.0.4" + "@img/sharp-libvips-linux-s390x" "1.0.4" + "@img/sharp-libvips-linux-x64" "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + "@img/sharp-linux-arm" "0.33.5" + "@img/sharp-linux-arm64" "0.33.5" + "@img/sharp-linux-s390x" "0.33.5" + "@img/sharp-linux-x64" "0.33.5" + "@img/sharp-linuxmusl-arm64" "0.33.5" + "@img/sharp-linuxmusl-x64" "0.33.5" + "@img/sharp-wasm32" "0.33.5" + "@img/sharp-win32-ia32" "0.33.5" + "@img/sharp-win32-x64" "0.33.5" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7562,6 +7739,13 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -8265,7 +8449,7 @@ tslib@^1.11.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.1.0, tslib@^2.6.3, tslib@^2.7.0: +tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.3, tslib@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==