1<?php
2
3namespace ComboStrap;
4
5use ComboStrap\Web\Url;
6use Facebook\WebDriver\Chrome\ChromeOptions;
7use Facebook\WebDriver\Exception\NoSuchElementException;
8use Facebook\WebDriver\Exception\TimeoutException;
9use Facebook\WebDriver\Exception\UnsupportedOperationException;
10use Facebook\WebDriver\Exception\WebDriverCurlException;
11use Facebook\WebDriver\Remote\DesiredCapabilities;
12use Facebook\WebDriver\Remote\RemoteWebDriver;
13use Facebook\WebDriver\WebDriverBy;
14use Facebook\WebDriver\WebDriverDimension;
15
16/**
17 * Download chrome driver with the same version
18 * https://chromedriver.chromium.org/downloads
19 *
20 * Then run:
21 * ```
22 * chromedriver.exe --port=4444
23 * ```
24 */
25class FetcherScreenshot extends FetcherImage
26{
27
28
29    const WEB_DRIVER_ENDPOINT = 'http://localhost:4444/';
30    const CANONICAL = "snapshot";
31    const URL = "url";
32
33    private Url $url;
34
35
36    public static function createSnapshotFromUrl(Url $urlToSnapshot): FetcherScreenshot
37    {
38        return (new FetcherScreenshot())
39            ->setUrlToSnapshot($urlToSnapshot);
40
41    }
42
43    function getFetchUrl(Url $url = null): Url
44    {
45        $url = parent::getFetchUrl($url);
46        try {
47            $url->addQueryParameter(self::URL, $this->getUrlToSnapshot());
48        } catch (ExceptionNotFound $e) {
49            // ok
50        }
51        return $url;
52    }
53
54
55    /**
56     * @throws ExceptionBadSyntax
57     * @throws ExceptionBadArgument
58     */
59    public function buildFromTagAttributes(TagAttributes $tagAttributes): FetcherScreenshot
60    {
61        $urlString = $tagAttributes->getValue(self::URL);
62        if ($urlString === null) {
63            throw new ExceptionBadArgument("The `url` property is mandatory");
64        }
65        $this->url = Url::createFromString($urlString);
66        parent::buildFromTagAttributes($tagAttributes);
67        return $this;
68    }
69
70
71    /**
72     * @return LocalPath
73     * @throws ExceptionNotFound
74     * @throws NoSuchElementException
75     * @throws TimeoutException
76     * @throws UnsupportedOperationException
77     * @throws ExceptionInternal
78     */
79    function getFetchPath(): LocalPath
80    {
81
82        $url = $this->getUrlToSnapshot();
83
84        $capabilities = DesiredCapabilities::chrome();
85        $options = new ChromeOptions();
86        $options->addArguments(['--headless']);
87        $options->addArguments(["window-size=1024,768"]);
88
89        /**
90         *
91         * https://docs.travis-ci.com/user/chrome#sandboxing
92         * For security reasons, Google Chrome is unable to provide sandboxing when it is running in the container-based environment.
93         * Error:
94         *  * unknown error: Chrome failed to start: crashed
95         *  * The SUID sandbox helper binary was found, but is not configured correctly
96         */
97        if (PluginUtility::isCi()) {
98            $options->addArguments(["--no-sandbox"]);
99        }
100
101//      $options->addArguments(['--start-fullscreen']);
102//      $options->addArguments(['--start-maximized']);
103
104        $capabilities->setCapability(ChromeOptions::CAPABILITY, $options);
105
106        try {
107            $webDriver = RemoteWebDriver::create(
108                self::WEB_DRIVER_ENDPOINT,
109                $capabilities,
110                1000
111            );
112        } /** @noinspection PhpRedundantCatchClauseInspection */ catch (WebDriverCurlException $e) {
113            // this exception is thrown even if it's not advertised
114            throw new ExceptionInternal("Web driver is not available at " . self::WEB_DRIVER_ENDPOINT . ". Did you run `chromedriver.exe --port=4444` ? Error: {$e->getMessage()}");
115        }
116        try {
117
118            // navigate to the page
119            $webDriver->get($url->toAbsoluteUrlString());
120
121            // wait until the target page is loaded
122            // https://github.com/php-webdriver/php-webdriver/wiki/HowTo-Wait
123            $webDriver->wait(2, 500)->until(
124                function () use ($webDriver) {
125                    $state = $webDriver->executeScript("return document.readyState");
126                    return $state === "complete";
127                },
128                'The page was not loaded'
129            );
130
131            /**
132             * Scroll to the end to download the image
133             */
134            $body = $webDriver->findElement(WebDriverBy::tagName('body'));
135            $webDriver->executeScript("window.scrollTo(0, document.body.scrollHeight)");
136            // Scrolling by sending keys does not work to download lazy loaded image
137            // $body->sendKeys(WebDriverKeys::encode([WebDriverKeys::CONTROL, WebDriverKeys::END]));
138            // Let the time to the image to download, we could also scroll to each image and get the status ?
139            $webDriver->wait(2, 500)->until(
140                function () use ($webDriver) {
141                    $images = $webDriver->findElements(WebDriverBy::tagName("img"));
142                    foreach ($images as $img) {
143                        $complete = DataType::toBoolean($img->getAttribute("complete"), false);
144                        if ($complete === true) {
145                            $naturalHeight = DataType::toInteger($img->getAttribute("naturalHeight"), 0);
146                            if ($naturalHeight !== 0)
147                                return false;
148                        }
149                    }
150                    return true;
151                },
152                'The image were not loaded on time'
153            );
154
155            /**
156             * Get the new dimension
157             */
158            $bodyOffsetHeight = $body->getDomProperty("offsetHeight");
159            $bodyOffsetWidth = $body->getDomProperty("offsetWidth");
160
161            /**
162             * Because each page has a different height if you want
163             * to take a full height and width, you need to set it manually after
164             * the DOM has rendered
165             */
166            $heightCorrection = 15; // don't know why but yeah
167            $fullPageDimension = new WebDriverDimension($bodyOffsetWidth, $bodyOffsetHeight + $heightCorrection);
168            $webDriver->manage()
169                ->window()
170                ->setSize($fullPageDimension);
171
172
173            if (!PluginUtility::isDevOrTest()) {
174                // Cache
175                $screenShotPath = FetcherCache::createFrom($this)->getFile();
176            } else {
177                // Desktop
178                try {
179                    $lastNameWithoutExtension = $url->getLastNameWithoutExtension();
180                } catch (ExceptionNotFound $e) {
181                    $lastNameWithoutExtension = $url->getHost();
182                }
183                $screenShotPath = LocalPath::createDesktopDirectory()
184                    ->resolve($lastNameWithoutExtension . "." . $this->getMime()->getExtension());
185            }
186            $webDriver->takeScreenshot($screenShotPath);
187            return $screenShotPath;
188
189        } finally {
190            /**
191             * terminate the session and close the browser
192             */
193            $webDriver->quit();
194        }
195
196    }
197
198
199    /**
200     * @throws \ReflectionException
201     * @throws ExceptionNotFound
202     */
203    function getBuster(): string
204    {
205        return FileSystems::getCacheBuster(ClassUtility::getClassPath(FetcherScreenshot::class));
206    }
207
208    public function getMime(): Mime
209    {
210        return Mime::createFromExtension("png");
211    }
212
213    public function getFetcherName(): string
214    {
215        return self::CANONICAL;
216    }
217
218    public function getIntrinsicWidth(): int
219    {
220        try {
221            return $this->getRequestedWidth();
222        } catch (ExceptionNotFound $e) {
223            return 1024;
224        }
225    }
226
227    public function getIntrinsicHeight(): int
228    {
229        try {
230            return $this->getRequestedHeight();
231        } catch (ExceptionNotFound $e) {
232            return 768;
233        }
234    }
235
236    private function setUrlToSnapshot(Url $urlToSnapshot): FetcherScreenshot
237    {
238        $this->url = $urlToSnapshot;
239        return $this;
240    }
241
242    /**
243     * @throws ExceptionNotFound
244     */
245    private function getUrlToSnapshot(): Url
246    {
247        if (!isset($this->url)) {
248            throw new ExceptionNotFound("No url to snapshot could be determined");
249        }
250        return $this->url;
251    }
252
253    public function getLabel(): string
254    {
255        return self::CANONICAL;
256    }
257
258}
259