1<?php
2
3namespace Facebook\WebDriver\Remote\Service;
4
5use Exception;
6use Facebook\WebDriver\Net\URLChecker;
7use Symfony\Component\Process\Process;
8use Symfony\Component\Process\ProcessBuilder;
9
10/**
11 * Start local WebDriver service (when remote WebDriver server is not used).
12 * This will start new process of respective browser driver and take care of its lifecycle.
13 */
14class DriverService
15{
16    /**
17     * @var string
18     */
19    private $executable;
20
21    /**
22     * @var string
23     */
24    private $url;
25
26    /**
27     * @var array
28     */
29    private $args;
30
31    /**
32     * @var array
33     */
34    private $environment;
35
36    /**
37     * @var Process|null
38     */
39    private $process;
40
41    /**
42     * @param string $executable
43     * @param int $port The given port the service should use.
44     * @param array $args
45     * @param array|null $environment Use the system environment if it is null
46     */
47    public function __construct($executable, $port, $args = [], $environment = null)
48    {
49        $this->setExecutable($executable);
50        $this->url = sprintf('http://localhost:%d', $port);
51        $this->args = $args;
52        $this->environment = $environment ?: $_ENV;
53    }
54
55    /**
56     * @return string
57     */
58    public function getURL()
59    {
60        return $this->url;
61    }
62
63    /**
64     * @return DriverService
65     */
66    public function start()
67    {
68        if ($this->process !== null) {
69            return $this;
70        }
71
72        $this->process = $this->createProcess();
73        $this->process->start();
74
75        $this->checkWasStarted($this->process);
76
77        $checker = new URLChecker();
78        $checker->waitUntilAvailable(20 * 1000, $this->url . '/status');
79
80        return $this;
81    }
82
83    /**
84     * @return DriverService
85     */
86    public function stop()
87    {
88        if ($this->process === null) {
89            return $this;
90        }
91
92        $this->process->stop();
93        $this->process = null;
94
95        $checker = new URLChecker();
96        $checker->waitUntilUnavailable(3 * 1000, $this->url . '/shutdown');
97
98        return $this;
99    }
100
101    /**
102     * @return bool
103     */
104    public function isRunning()
105    {
106        if ($this->process === null) {
107            return false;
108        }
109
110        return $this->process->isRunning();
111    }
112
113    /**
114     * @deprecated Has no effect. Will be removed in next major version. Executable is now checked
115     * when calling setExecutable().
116     * @param string $executable
117     * @return string
118     */
119    protected static function checkExecutable($executable)
120    {
121        return $executable;
122    }
123
124    /**
125     * @param string $executable
126     * @throws Exception
127     */
128    protected function setExecutable($executable)
129    {
130        if ($this->isExecutable($executable)) {
131            $this->executable = $executable;
132
133            return;
134        }
135
136        throw new Exception(
137            sprintf(
138                '"%s" is not executable. Make sure the path is correct or use environment variable to specify'
139                 . ' location of the executable.',
140                $executable
141            )
142        );
143    }
144
145    /**
146     * @param Process $process
147     */
148    protected function checkWasStarted($process)
149    {
150        usleep(10000); // wait 10ms, otherwise the asynchronous process failure may not yet be propagated
151
152        if (!$process->isRunning()) {
153            throw new Exception(
154                sprintf(
155                    'Error starting driver executable "%s": %s',
156                    $process->getCommandLine(),
157                    $process->getErrorOutput()
158                )
159            );
160        }
161    }
162
163    /**
164     * @return Process
165     */
166    private function createProcess()
167    {
168        // BC: ProcessBuilder deprecated since Symfony 3.4 and removed in Symfony 4.0.
169        if (class_exists(ProcessBuilder::class)
170            && mb_strpos('@deprecated', (new \ReflectionClass(ProcessBuilder::class))->getDocComment()) === false
171        ) {
172            $processBuilder = (new ProcessBuilder())
173                ->setPrefix($this->executable)
174                ->setArguments($this->args)
175                ->addEnvironmentVariables($this->environment);
176
177            return $processBuilder->getProcess();
178        }
179        // Safe to use since Symfony 3.3
180        $commandLine = array_merge([$this->executable], $this->args);
181
182        return new Process($commandLine, null, $this->environment);
183    }
184
185    /**
186     * Check whether given file is executable directly or using system PATH
187     *
188     * @param string $filename
189     * @return bool
190     */
191    private function isExecutable($filename)
192    {
193        if (is_executable($filename)) {
194            return true;
195        }
196        if ($filename !== basename($filename)) { // $filename is an absolute path, do no try to search it in PATH
197            return false;
198        }
199
200        $paths = explode(PATH_SEPARATOR, getenv('PATH'));
201        foreach ($paths as $path) {
202            if (is_executable($path . DIRECTORY_SEPARATOR . $filename)) {
203                return true;
204            }
205        }
206
207        return false;
208    }
209}
210