/**
 * 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 path from 'path';
import colors from 'colors/safe';
import debug from 'debug';
import { ManualPromise } from '@isomorphic/manualPromise';
import { monotonicTime } from '@isomorphic/time';
import { raceAgainstDeadline } from '@isomorphic/timeoutRunner';
import { createSocket } from '@utils/happyEyeballs';
import { isURLAvailable } from '@utils/network';
import { launchProcess } from '@utils/processLauncher';
const DEFAULT_ENVIRONMENT_VARIABLES = {
    'BROWSER': 'none', // Disable that create-react-app will open the page in the browser
    'FORCE_COLOR': '1',
    'DEBUG_COLORS': '1',
};
const debugWebServer = debug('pw:webserver');
export class WebServerPlugin {
    _isAvailableCallback;
    _killProcess;
    _processExitedPromise;
    _options;
    _checkPortOnly;
    _reporter;
    _waitForStdioPromise;
    name = 'playwright:webserver';
    constructor(options, checkPortOnly) {
        this._options = options;
        this._checkPortOnly = checkPortOnly;
    }
    async setup(config, configDir, reporter) {
        this._reporter = reporter;
        if (this._options.url)
            this._isAvailableCallback = getIsAvailableFunction(this._options.url, this._checkPortOnly, !!this._options.ignoreHTTPSErrors, this._reporter.onStdErr?.bind(this._reporter));
        this._options.cwd = this._options.cwd ? path.resolve(configDir, this._options.cwd) : configDir;
        try {
            await this._startProcess();
            await this._waitForProcess();
        }
        catch (error) {
            await this.teardown();
            throw error;
        }
    }
    async teardown() {
        debugWebServer(`Terminating the WebServer`);
        await this._killProcess?.();
        debugWebServer(`Terminated the WebServer`);
    }
    async _startProcess() {
        let processExitedReject = (error) => { };
        this._processExitedPromise = new Promise((_, reject) => processExitedReject = reject);
        const isAlreadyAvailable = await this._isAvailableCallback?.();
        if (isAlreadyAvailable) {
            debugWebServer(`WebServer is already available`);
            if (this._options.reuseExistingServer)
                return;
            const port = new URL(this._options.url).port;
            throw new Error(`${this._options.url ?? `http://localhost${port ? ':' + port : ''}`} is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.`);
        }
        if (!this._options.command)
            throw new Error('config.webServer.command cannot be empty');
        debugWebServer(`Starting WebServer process ${this._options.command}...`);
        const { launchedProcess, gracefullyClose } = await launchProcess({
            command: this._options.command,
            env: {
                ...DEFAULT_ENVIRONMENT_VARIABLES,
                ...process.env,
                ...this._options.env,
            },
            cwd: this._options.cwd,
            stdio: 'stdin',
            shell: true,
            attemptToGracefullyClose: async () => {
                if (process.platform === 'win32')
                    throw new Error('Graceful shutdown is not supported on Windows');
                if (!this._options.gracefulShutdown)
                    throw new Error('skip graceful shutdown');
                const { signal, timeout = 0 } = this._options.gracefulShutdown;
                // proper usage of SIGINT is to send it to the entire process group, see https://www.cons.org/cracauer/sigint.html
                // there's no such convention for SIGTERM, so we decide what we want. signaling the process group for consistency.
                process.kill(-launchedProcess.pid, signal);
                return new Promise((resolve, reject) => {
                    const timer = timeout !== 0
                        ? setTimeout(() => reject(new Error(`process didn't close gracefully within timeout`)), timeout)
                        : undefined;
                    launchedProcess.once('close', (...args) => {
                        clearTimeout(timer);
                        resolve();
                    });
                });
            },
            log: () => { },
            onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')),
            tempDirectories: [],
        });
        this._killProcess = gracefullyClose;
        debugWebServer(`Process started`);
        if (this._options.wait?.stdout || this._options.wait?.stderr)
            this._waitForStdioPromise = new ManualPromise();
        const stdioWaitCollectors = {
            stdout: this._options.wait?.stdout ? '' : undefined,
            stderr: this._options.wait?.stderr ? '' : undefined,
        };
        launchedProcess.stdout.on('data', data => {
            if (debugWebServer.enabled || this._options.stdout === 'pipe')
                this._reporter.onStdOut?.(prefixOutputLines(data.toString(), this._options.name));
        });
        launchedProcess.stderr.on('data', data => {
            if (debugWebServer.enabled || (this._options.stderr === 'pipe' || !this._options.stderr))
                this._reporter.onStdErr?.(prefixOutputLines(data.toString(), this._options.name));
        });
        const resolveStdioPromise = () => {
            stdioWaitCollectors.stdout = undefined;
            stdioWaitCollectors.stderr = undefined;
            this._waitForStdioPromise?.resolve();
        };
        for (const stdio of ['stdout', 'stderr']) {
            launchedProcess[stdio].on('data', data => {
                if (!this._options.wait?.[stdio] || stdioWaitCollectors[stdio] === undefined)
                    return;
                stdioWaitCollectors[stdio] += data.toString();
                this._options.wait[stdio].lastIndex = 0;
                const result = this._options.wait[stdio].exec(stdioWaitCollectors[stdio]);
                if (result) {
                    for (const [key, value] of Object.entries(result.groups || {}))
                        process.env[key.toUpperCase()] = value;
                    resolveStdioPromise();
                }
            });
        }
    }
    async _waitForProcess() {
        if (!this._isAvailableCallback && !this._waitForStdioPromise) {
            this._processExitedPromise.catch(() => { });
            return;
        }
        debugWebServer(`Waiting for availability...`);
        const launchTimeout = this._options.timeout || 60 * 1000;
        const cancellationToken = { canceled: false };
        const deadline = monotonicTime() + launchTimeout;
        const racingPromises = [this._processExitedPromise];
        if (this._isAvailableCallback)
            racingPromises.push(raceAgainstDeadline(() => waitFor(this._isAvailableCallback, cancellationToken), deadline));
        if (this._waitForStdioPromise)
            racingPromises.push(raceAgainstDeadline(() => this._waitForStdioPromise, deadline));
        const { timedOut } = await Promise.race(racingPromises);
        cancellationToken.canceled = true;
        if (timedOut)
            throw new Error(`Timed out waiting ${launchTimeout}ms from config.webServer.`);
        debugWebServer(`WebServer available`);
    }
}
async function isPortUsed(port) {
    try {
        const socket = await createSocket('localhost', port);
        socket.end();
        return true;
    }
    catch {
        return false;
    }
}
async function waitFor(waitFn, cancellationToken) {
    const logScale = [100, 250, 500];
    while (!cancellationToken.canceled) {
        const connected = await waitFn();
        if (connected)
            return;
        const delay = logScale.shift() || 1000;
        debugWebServer(`Waiting ${delay}ms`);
        await new Promise(x => setTimeout(x, delay));
    }
}
function getIsAvailableFunction(url, checkPortOnly, ignoreHTTPSErrors, onStdErr) {
    const urlObject = new URL(url);
    if (!checkPortOnly)
        return () => isURLAvailable(urlObject, ignoreHTTPSErrors, debugWebServer, onStdErr);
    const port = urlObject.port;
    return () => isPortUsed(+port);
}
export const webServer = (options) => {
    return new WebServerPlugin(options, false);
};
export const webServerPluginsForConfig = (config) => {
    const shouldSetBaseUrl = !!config.config.webServer;
    const webServerPlugins = [];
    for (const webServerConfig of config.webServers) {
        if (webServerConfig.port && webServerConfig.url)
            throw new Error(`Either 'port' or 'url' should be specified in config.webServer.`);
        let url;
        if (webServerConfig.port || webServerConfig.url) {
            url = webServerConfig.url || `http://localhost:${webServerConfig.port}`;
            // We only set base url when only the port is given. That's a legacy mode we have regrets about.
            if (shouldSetBaseUrl && !webServerConfig.url)
                process.env.PLAYWRIGHT_TEST_BASE_URL = url;
        }
        webServerPlugins.push(new WebServerPlugin({ ...webServerConfig, url }, webServerConfig.port !== undefined));
    }
    return webServerPlugins;
};
function prefixOutputLines(output, prefixName = 'WebServer') {
    const lastIsNewLine = output[output.length - 1] === '\n';
    let lines = output.split('\n');
    if (lastIsNewLine)
        lines.pop();
    lines = lines.map(line => colors.dim(`[${prefixName}] `) + line);
    if (lastIsNewLine)
        lines.push('');
    return lines.join('\n');
}
//# sourceMappingURL=webServerPlugin.js.map