/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import fs from 'fs'; import path from 'path'; import os from 'os'; import dotenv from 'dotenv'; import { isSystemDirectory } from '@utils/fileUtils'; import { proxySettingsFromString } from '@utils/network'; import { playwright } from '../../inprocess'; import { configFromIniFile } from './configIni'; import type * as playwrightTypes from '../../..'; import type { Config, ToolCapability } from './config.d'; async function fileExistsAsync(resolved: string) { try { return (await fs.promises.stat(resolved)).isFile(); } catch { return false; } } type ViewportSize = { width: number; height: number }; export type CLIOptions = { allowedHosts?: string[]; allowedOrigins?: string[]; allowUnrestrictedFileAccess?: boolean; blockedOrigins?: string[]; blockServiceWorkers?: boolean; browser?: string; caps?: string[]; cdpEndpoint?: string; cdpHeader?: Record; cdpTimeout?: number; codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none'; config?: string; consoleLevel?: 'error' | 'warning' | 'info' | 'debug'; device?: string; endpoint?: string; extension?: boolean; executablePath?: string; filePaths?: 'relative' | 'absolute'; grantPermissions?: string[]; headless?: boolean; host?: string; ignoreHttpsErrors?: boolean; ignoreRoots?: boolean; initScript?: string[]; initPage?: string[]; isolated?: boolean; idleTimeout?: number; imageResponses?: 'allow' | 'omit' | 'only'; mobile?: boolean; sandbox?: boolean; outputDir?: string; outputMaxSize?: number; port?: number; profileDirName?: string; proxyBypass?: string; proxyServer?: string; redactUrlParams?: string[]; remoteHeader?: Record; saveSession?: boolean; secrets?: Record; sharedBrowserContext?: boolean; snapshotBoxes?: boolean; snapshotMode?: 'full' | 'none'; storageState?: string; testIdAttribute?: string; timeoutAction?: number; timeoutNavigation?: number; timeoutSettle?: number; userAgent?: string; webmcp?: boolean; userDataDir?: string; viewportSize?: ViewportSize; }; export const defaultCodegenLanguage: 'typescript' | 'python' | 'java' | 'csharp' = process.env.PW_LANG_NAME === 'python' || process.env.PW_LANG_NAME === 'java' || process.env.PW_LANG_NAME === 'csharp' ? process.env.PW_LANG_NAME : 'typescript'; const defaultConfig: MergedConfig = { browser: { launchOptions: {}, contextOptions: {}, }, codegen: defaultCodegenLanguage, timeouts: { action: 5000, navigation: 60000, expect: 5000, settle: 500, }, }; type BrowserUserConfig = NonNullable; export type MergedConfig = Config & { browser: BrowserUserConfig & { launchOptions: NonNullable; contextOptions: NonNullable; } }; export type FullConfig = MergedConfig & { browser: MergedConfig['browser'] & { browserName: 'chromium' | 'firefox' | 'webkit'; }, skillMode?: boolean; configFile?: string; }; export async function resolveConfig(config: Config): Promise { const merged = mergeConfig(defaultConfig, config); const browser = await validateBrowserConfig(merged.browser); return { ...merged, browser }; } export async function resolveCLIConfigForMCP(cliOptions: CLIOptions, env?: NodeJS.ProcessEnv): Promise { const envOverrides = configFromEnv(env); const cliOverrides = configFromCLIOptions(cliOptions); const configFile = cliOverrides.configFile ?? envOverrides.configFile; const configInFile = await loadConfig(configFile); const configDir = configFile ? path.dirname(path.resolve(configFile)) : process.cwd(); let result = defaultConfig; result = mergeConfig(result, resolveConfigPaths(configInFile, configDir)); result = mergeConfig(result, resolveConfigPaths(envOverrides, process.cwd())); result = mergeConfig(result, resolveConfigPaths(cliOverrides, process.cwd())); const browser = await validateBrowserConfig(result.browser); if (browser.launchOptions.headless === undefined) browser.launchOptions.headless = os.platform() === 'linux' && !process.env.DISPLAY; validateOutputDir(result.outputDir); return { ...result, browser, configFile }; } function validateOutputDir(outputDir: string | undefined) { if (!outputDir) return; if (isSystemDirectory(outputDir)) throw new Error(`--output-dir cannot point to a system directory: ${path.resolve(outputDir)}.`); } export async function resolveCLIConfigForCLI(daemonProfilesDir: string, sessionName: string, options: any, env?: NodeJS.ProcessEnv): Promise { const config = options.config ? path.resolve(options.config) : undefined; try { const defaultConfigFile = path.resolve('.playwright', 'cli.config.json'); if (!config && fs.existsSync(defaultConfigFile)) options.config = defaultConfigFile; } catch { } const daemonOverrides = configFromCLIOptions({ endpoint: options.endpoint, cdpEndpoint: options.cdp, config: options.config, browser: options.browser, device: options.device, headless: options.headed ? false : undefined, mobile: options.mobile, extension: options.extension, userDataDir: options.profile, idleTimeout: options.idleTimeout, snapshotMode: 'full', }); const envOverrides = configFromEnv(env); const configFile = daemonOverrides.configFile ?? envOverrides.configFile; const configInFile = await loadConfig(configFile); const configDir = configFile ? path.dirname(path.resolve(configFile)) : process.cwd(); const globalConfigPath = path.join((env ?? process.env)['PWTEST_CLI_GLOBAL_CONFIG'] ?? os.homedir(), '.playwright', 'cli.config.json'); const globalConfigExists = fs.existsSync(globalConfigPath); const globalConfigInFile = await loadConfig(globalConfigExists ? globalConfigPath : undefined); const globalConfigDir = globalConfigExists ? path.dirname(globalConfigPath) : process.cwd(); let result = defaultConfig; result = mergeConfig(result, resolveConfigPaths(globalConfigInFile, globalConfigDir)); result = mergeConfig(result, resolveConfigPaths(configInFile, configDir)); result = mergeConfig(result, resolveConfigPaths(envOverrides, process.cwd())); result = mergeConfig(result, resolveConfigPaths(daemonOverrides, process.cwd())); if (result.browser.isolated === undefined) result.browser.isolated = !options.profile && !options.persistent && !result.browser.userDataDir && !result.browser.remoteEndpoint && !result.browser.cdpEndpoint && !result.extension; if (result.browser.launchOptions.headless === undefined) result.browser.launchOptions.headless = true; const browser = await validateBrowserConfig(result.browser); validateOutputDir(result.outputDir); if (!result.extension && !browser.isolated && !browser.userDataDir && !browser.remoteEndpoint && !browser.cdpEndpoint) { // No custom value provided, use the daemon data dir. const browserToken = browser.launchOptions?.channel ?? browser?.browserName; const userDataDir = path.resolve(daemonProfilesDir, `ud-${sessionName}-${browserToken}`); browser.userDataDir = userDataDir; } return { ...result, browser, configFile, skillMode: true }; } export function resolveExtensionOptions(cliOptions: CLIOptions): { channel: string, executablePath?: string, profileDirName?: string } { const browser = cliOptions.browser ?? envToString(process.env.PLAYWRIGHT_MCP_BROWSER); const { channel } = resolveBrowserParam(browser); const executablePath = cliOptions.executablePath ?? envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH); const profileDirName = cliOptions.profileDirName ?? envToString(process.env.PLAYWRIGHT_MCP_PROFILE_DIR_NAME); return { channel: channel ?? 'chrome', executablePath, profileDirName }; } async function validateBrowserConfig(browser: MergedConfig['browser']): Promise { let browserName = browser.browserName; if (!browserName) { browserName = 'chromium'; // Assign channel only if the browserName is not provided, otherwise assume full control to the user. if (browser.launchOptions.channel === undefined) browser.launchOptions.channel = 'chrome'; } if (browserName === 'chromium' && browser.launchOptions.chromiumSandbox === undefined) { if (process.platform === 'linux') { // Downloaded chromium builds (undefined channel, 'chromium', 'chrome-for-testing') lack the setuid sandbox helper on linux. const { channel } = browser.launchOptions; browser.launchOptions.chromiumSandbox = channel !== undefined && channel !== 'chromium' && channel !== 'chrome-for-testing'; } else { browser.launchOptions.chromiumSandbox = true; } } if (browser.isolated && browser.userDataDir) throw new Error('Browser userDataDir is not supported in isolated mode.'); if (browser.initScript) { for (const script of browser.initScript) { if (!await fileExistsAsync(script)) throw new Error(`Init script file does not exist: ${script}`); } } if (browser.initPage) { for (const page of browser.initPage) { if (!await fileExistsAsync(page)) throw new Error(`Init page file does not exist: ${page}`); } } if (browser.contextOptions.viewport === undefined) { if (browser.launchOptions.headless) browser.contextOptions.viewport = { width: 1280, height: 720 }; else browser.contextOptions.viewport = null; } if (browserName === 'chromium') { browser.launchOptions.args = browser.launchOptions.args ?? []; if (!browser.launchOptions.args.some(a => a.includes('--disable-blink-features'))) browser.launchOptions.args.push(`--disable-blink-features=AutomationControlled`); } return { ...browser, browserName }; } function resolveBrowserParam(browserOption: string | undefined): { browserName?: 'chromium' | 'firefox' | 'webkit', channel?: string } { switch (browserOption) { case 'chrome': case 'chrome-beta': case 'chrome-canary': case 'chrome-dev': case 'msedge': case 'msedge-beta': case 'msedge-canary': case 'msedge-dev': return { browserName: 'chromium', channel: browserOption }; case 'chromium': return { browserName: 'chromium', channel: 'chrome-for-testing' }; case 'firefox': return { browserName: 'firefox' }; case 'moz-firefox': case 'moz-firefox-beta': case 'moz-firefox-nightly': return { browserName: 'firefox', channel: browserOption }; case 'webkit': return { browserName: 'webkit' }; default: return {}; } } function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: string } { const { browserName, channel } = resolveBrowserParam(cliOptions.browser); // Launch options const launchOptions: playwrightTypes.LaunchOptions = { channel, executablePath: cliOptions.executablePath, headless: cliOptions.headless, }; // --sandbox was passed, enable the sandbox // --no-sandbox was passed, disable the sandbox if (cliOptions.sandbox !== undefined) launchOptions.chromiumSandbox = cliOptions.sandbox; let device = cliOptions.device; if (cliOptions.mobile) { if (device) throw new Error('Cannot use --mobile together with --device, pick one.'); if (browserName === 'firefox') throw new Error('--mobile is not supported with the Firefox browser.'); device = browserName === 'webkit' ? 'iPhone 17' : 'Pixel 10'; } if (device && cliOptions.cdpEndpoint) throw new Error('Device emulation is not supported with cdpEndpoint.'); // Context options const contextOptions: playwrightTypes.BrowserContextOptions = device ? playwright.devices[device] : {}; if (cliOptions.proxyServer) { const proxy: playwrightTypes.LaunchOptions['proxy'] = proxySettingsFromString(cliOptions.proxyServer); if (cliOptions.proxyBypass) proxy.bypass = cliOptions.proxyBypass; // Set on both to ensure CLI takes precedence over any proxy set in the config file // (launchOptions.proxy applies at browser launch, contextOptions.proxy at context creation). launchOptions.proxy = proxy; contextOptions.proxy = proxy; } if (cliOptions.storageState) contextOptions.storageState = cliOptions.storageState; if (cliOptions.userAgent) contextOptions.userAgent = cliOptions.userAgent; if (cliOptions.viewportSize) contextOptions.viewport = cliOptions.viewportSize; if (cliOptions.ignoreHttpsErrors) contextOptions.ignoreHTTPSErrors = true; if (cliOptions.blockServiceWorkers) contextOptions.serviceWorkers = 'block'; if (cliOptions.grantPermissions) contextOptions.permissions = cliOptions.grantPermissions; const config: Config = { browser: { browserName, isolated: cliOptions.isolated, userDataDir: cliOptions.userDataDir, launchOptions, contextOptions, cdpEndpoint: cliOptions.cdpEndpoint, cdpHeaders: cliOptions.cdpHeader, cdpTimeout: cliOptions.cdpTimeout, initPage: cliOptions.initPage, initScript: cliOptions.initScript, remoteEndpoint: cliOptions.endpoint, }, extension: cliOptions.extension, server: { port: cliOptions.port, host: cliOptions.host, allowedHosts: cliOptions.allowedHosts, }, capabilities: cliOptions.caps as ToolCapability[], console: { level: cliOptions.consoleLevel, }, network: { allowedOrigins: cliOptions.allowedOrigins, blockedOrigins: cliOptions.blockedOrigins, }, allowUnrestrictedFileAccess: cliOptions.allowUnrestrictedFileAccess, ignoreRoots: cliOptions.ignoreRoots, codegen: cliOptions.codegen, saveSession: cliOptions.saveSession, secrets: cliOptions.secrets, redactUrlParams: cliOptions.redactUrlParams, sharedBrowserContext: cliOptions.sharedBrowserContext, snapshot: cliOptions.snapshotMode || cliOptions.snapshotBoxes !== undefined ? { mode: cliOptions.snapshotMode, boxes: cliOptions.snapshotBoxes } : undefined, outputDir: cliOptions.outputDir, outputMaxSize: cliOptions.outputMaxSize, imageResponses: cliOptions.imageResponses, filePaths: cliOptions.filePaths, testIdAttribute: cliOptions.testIdAttribute, timeouts: { action: cliOptions.timeoutAction, idle: cliOptions.idleTimeout, navigation: cliOptions.timeoutNavigation, settle: cliOptions.timeoutSettle, }, }; // Note that cli args parser defaults to "true" when no option is passed, so only honor an explict "false". if (cliOptions.webmcp === false) config.webmcp = false; // `remoteHeaders` is for back-compat, assign it here so it survives config merging. if (cliOptions.remoteHeader) // eslint-disable-next-line no-restricted-syntax (config.browser as any).remoteHeaders = cliOptions.remoteHeader; return { ...config, configFile: cliOptions.config }; } export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: string } { const e = env ?? process.env; const options: CLIOptions = {}; options.allowedHosts = commaSeparatedList(e.PLAYWRIGHT_MCP_ALLOWED_HOSTS); options.allowedOrigins = semicolonSeparatedList(e.PLAYWRIGHT_MCP_ALLOWED_ORIGINS); options.allowUnrestrictedFileAccess = envToBoolean(e.PLAYWRIGHT_MCP_ALLOW_UNRESTRICTED_FILE_ACCESS); options.blockedOrigins = semicolonSeparatedList(e.PLAYWRIGHT_MCP_BLOCKED_ORIGINS); options.blockServiceWorkers = envToBoolean(e.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS); options.browser = envToString(e.PLAYWRIGHT_MCP_BROWSER); options.caps = commaSeparatedList(e.PLAYWRIGHT_MCP_CAPS); options.cdpEndpoint = envToString(e.PLAYWRIGHT_MCP_CDP_ENDPOINT); options.cdpHeader = headerParser(envToString(e.PLAYWRIGHT_MCP_CDP_HEADERS)); options.cdpTimeout = numberParser(e.PLAYWRIGHT_MCP_CDP_TIMEOUT); if (e.PLAYWRIGHT_MCP_CODEGEN) options.codegen = enumParser<'typescript' | 'python' | 'java' | 'csharp' | 'none'>('--codegen', ['none', 'typescript', 'python', 'java', 'csharp'], e.PLAYWRIGHT_MCP_CODEGEN); options.config = envToString(e.PLAYWRIGHT_MCP_CONFIG); if (e.PLAYWRIGHT_MCP_CONSOLE_LEVEL) options.consoleLevel = enumParser<'error' | 'warning' | 'info' | 'debug'>('--console-level', ['error', 'warning', 'info', 'debug'], e.PLAYWRIGHT_MCP_CONSOLE_LEVEL); options.device = envToString(e.PLAYWRIGHT_MCP_DEVICE); options.executablePath = envToString(e.PLAYWRIGHT_MCP_EXECUTABLE_PATH); options.extension = envToBoolean(e.PLAYWRIGHT_MCP_EXTENSION); if (e.PLAYWRIGHT_MCP_FILE_PATHS) options.filePaths = enumParser<'relative' | 'absolute'>('--file-paths', ['relative', 'absolute'], e.PLAYWRIGHT_MCP_FILE_PATHS); options.grantPermissions = commaSeparatedList(e.PLAYWRIGHT_MCP_GRANT_PERMISSIONS); options.headless = envToBoolean(e.PLAYWRIGHT_MCP_HEADLESS); options.host = envToString(e.PLAYWRIGHT_MCP_HOST); options.ignoreHttpsErrors = envToBoolean(e.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS); options.ignoreRoots = envToBoolean(e.PLAYWRIGHT_MCP_IGNORE_ROOTS); const initPage = envToString(e.PLAYWRIGHT_MCP_INIT_PAGE); if (initPage) options.initPage = [initPage]; const initScript = envToString(e.PLAYWRIGHT_MCP_INIT_SCRIPT); if (initScript) options.initScript = [initScript]; options.isolated = envToBoolean(e.PLAYWRIGHT_MCP_ISOLATED); if (e.PLAYWRIGHT_MCP_IMAGE_RESPONSES) options.imageResponses = enumParser<'allow' | 'omit' | 'only'>('--image-responses', ['allow', 'omit', 'only'], e.PLAYWRIGHT_MCP_IMAGE_RESPONSES); options.mobile = envToBoolean(e.PLAYWRIGHT_MCP_MOBILE); options.sandbox = envToBoolean(e.PLAYWRIGHT_MCP_SANDBOX); options.outputDir = envToString(e.PLAYWRIGHT_MCP_OUTPUT_DIR); options.outputMaxSize = numberParser(e.PLAYWRIGHT_MCP_OUTPUT_MAX_SIZE); options.port = numberParser(e.PLAYWRIGHT_MCP_PORT); options.proxyBypass = envToString(e.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(e.PLAYWRIGHT_MCP_PROXY_SERVER); options.redactUrlParams = commaSeparatedList(e.PLAYWRIGHT_MCP_REDACT_URL_PARAMS); options.remoteHeader = headerParser(envToString(e.PLAYWRIGHT_MCP_REMOTE_HEADERS)); options.secrets = dotenvFileLoader(e.PLAYWRIGHT_MCP_SECRETS_FILE); options.storageState = envToString(e.PLAYWRIGHT_MCP_STORAGE_STATE); options.testIdAttribute = envToString(e.PLAYWRIGHT_MCP_TEST_ID_ATTRIBUTE); options.timeoutAction = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_ACTION); options.idleTimeout = numberParser(e.PLAYWRIGHT_MCP_IDLE_TIMEOUT); options.timeoutNavigation = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION); options.timeoutSettle = numberParser(e.PLAYWRIGHT_MCP_TIMEOUT_SETTLE); options.userAgent = envToString(e.PLAYWRIGHT_MCP_USER_AGENT); options.webmcp = envToBoolean(e.PLAYWRIGHT_MCP_WEBMCP); options.userDataDir = envToString(e.PLAYWRIGHT_MCP_USER_DATA_DIR); options.viewportSize = resolutionParser('--viewport-size', e.PLAYWRIGHT_MCP_VIEWPORT_SIZE); return configFromCLIOptions(options); } export async function loadConfig(configFile: string | undefined): Promise { if (!configFile) return {}; if (configFile.endsWith('.ini')) return configFromIniFile(configFile); const raw = await fs.promises.readFile(configFile, 'utf8'); const data = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw; try { return JSON.parse(data); } catch (jsonError) { // A JSON config is always an object, so JSON-looking input must surface its // parse error rather than silently falling back to INI (and the default // config). A leading `[` stays with INI — it is a `[section]` header there. if (/^\s*\{/.test(data)) throw jsonError; return configFromIniFile(configFile); } } // initPage/initScript paths are resolved against a per-source base dir // (config-file dir for entries loaded from a --config file, cwd for entries // supplied via CLI flags or PLAYWRIGHT_MCP_INIT_* env vars) so they keep // working when the CLI is invoked from a different cwd. function resolveConfigPaths(config: Config, baseDir: string): Config { if (config.browser?.initPage) config.browser.initPage = config.browser.initPage.map(p => path.resolve(baseDir, p)); if (config.browser?.initScript) config.browser.initScript = config.browser.initScript.map(p => path.resolve(baseDir, p)); return config; } function pickDefined(obj: T | undefined): Partial { return Object.fromEntries( Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined) ) as Partial; } function mergeConfig(base: MergedConfig, overrides: Config): MergedConfig { const browser: Config['browser'] = { ...pickDefined(base.browser), ...pickDefined(overrides.browser), browserName: overrides.browser?.browserName ?? base.browser?.browserName, isolated: overrides.browser?.isolated ?? base.browser?.isolated, launchOptions: { ...pickDefined(base.browser?.launchOptions), ...pickDefined(overrides.browser?.launchOptions), }, contextOptions: { ...pickDefined(base.browser?.contextOptions), ...pickDefined(overrides.browser?.contextOptions), }, }; // Firefox supports the `moz-firefox*` channels via WebDriver BiDi, so keep // those; otherwise channels are a Chromium-only concept and should be dropped. if (browser.browserName !== 'chromium' && browser.launchOptions && !browser.launchOptions.channel?.startsWith('moz-')) delete browser.launchOptions.channel; return { ...pickDefined(base), ...pickDefined(overrides), browser, console: { ...pickDefined(base.console), ...pickDefined(overrides.console), }, network: { ...pickDefined(base.network), ...pickDefined(overrides.network), }, server: { ...pickDefined(base.server), ...pickDefined(overrides.server), }, snapshot: { ...pickDefined(base.snapshot), ...pickDefined(overrides.snapshot), }, timeouts: { ...pickDefined(base.timeouts), ...pickDefined(overrides.timeouts), }, } as FullConfig; } export function semicolonSeparatedList(value: string | undefined): string[] | undefined { if (!value) return undefined; return value.split(';').map(v => v.trim()); } export function commaSeparatedList(value: string | undefined): string[] | undefined { if (!value) return undefined; return value.split(',').map(v => v.trim()); } export function dotenvFileLoader(value: string | undefined): Record | undefined { if (!value) return undefined; return dotenv.parse(fs.readFileSync(value, 'utf8')); } export function numberParser(value: string | undefined): number | undefined { if (!value) return undefined; return +value; } export function resolutionParser(name: string, value: string | undefined): ViewportSize | undefined { if (!value) return undefined; if (value.includes('x')) { const [width, height] = value.split('x').map(v => +v); if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) throw new Error(`Invalid resolution format: use ${name}="800x600"`); return { width, height }; } // Legacy format if (value.includes(',')) { const [width, height] = value.split(',').map(v => +v); if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) throw new Error(`Invalid resolution format: use ${name}="800x600"`); return { width, height }; } throw new Error(`Invalid resolution format: use ${name}="800x600"`); } export function headerParser(arg: string | undefined, previous?: Record): Record | undefined { if (!arg) return previous; const result: Record = { ...(previous ?? {}) }; const colonIndex = arg.indexOf(':'); const name = colonIndex === -1 ? arg.trim() : arg.substring(0, colonIndex).trim(); const value = colonIndex === -1 ? '' : arg.substring(colonIndex + 1).trim(); result[name] = value; return result; } export function enumParser(name: string, options: T[], value: string): T { if (!options.includes(value as T)) throw new Error(`Invalid ${name}: ${value}. Valid values are: ${options.join(', ')}`); return value as T; } function envToBoolean(value: string | undefined): boolean | undefined { if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; return undefined; } function envToString(value: string | undefined): string | undefined { return value ? value.trim() : undefined; }