diff --git a/composer.json b/composer.json index c5cdfff..1bd5e09 100644 --- a/composer.json +++ b/composer.json @@ -25,22 +25,23 @@ } ], "require": { - "php": "^8.0", + "php": "^8.3", "ext-json": "*", + "ext-sockets": "*", + "ext-pcre": "*", + "clue/socket-raw": "^1.6", "composer/semver": "^3.0", - "zoon/rialto": "dev-dmt", "psr/log": "^3.0", "thecodingmachine/safe": "^2.5" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.2", - "monolog/monolog": "^3.0", + "monolog/monolog": "^2.4 || ^3.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.0", - "symfony/console": "^6.3", - "symfony/filesystem": "^6.3", - "symfony/process": "^6.3", - "symfony/var-dumper": "^6.3", + "phpunit/phpunit": "^10 || ^11", + "symfony/console": "^5.4 || ^6.4 || ^7.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", + "symfony/process": "^5.4 || ^6.4 || ^7.0", + "symfony/var-dumper": "^5.4 || ^6.4 || ^7.0", "thecodingmachine/phpstan-safe-rule": "^1.2" }, "autoload": { @@ -54,11 +55,9 @@ } }, "scripts": { - "post-install-cmd": "npm install", "test": "./vendor/bin/phpunit", "update-docs": "php bin/console doc:generate", - "stan": "vendor/bin/phpstan analyze packages/*/src", - "format": "vendor/bin/php-cs-fixer fix" + "stan": "vendor/bin/phpstan analyze packages/*/src" }, "config": { "sort-packages": true diff --git a/examples/01_page_open.php b/examples/01_page_open.php index 53a2416..e31abe1 100644 --- a/examples/01_page_open.php +++ b/examples/01_page_open.php @@ -1,25 +1,29 @@ launch(); $page = $browser->newPage(); -$page->goto('https://example.com'); +$page->goto('https://example.com/'); // Get the "viewport" of the page, as reported by the page. -$dimensions = $page->evaluate(JsFunction::createWithBody(/** @lang JavaScript */" - return { - width: document.documentElement.clientWidth, - height: document.documentElement.clientHeight, - deviceScaleFactor: window.devicePixelRatio - }; -")); +$javascript = <<<'JSFUNC' +/** @lang JavaScript */" +return { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, + deviceScaleFactor: window.devicePixelRatio +}; +JSFUNC; +$dimensions = $page->evaluate(JsFunction::createWithBody($javascript)); printf('Dimensions: %s', print_r($dimensions, true)); -$browser->close(); \ No newline at end of file +$browser->close(); diff --git a/examples/02_page_screenshot.php b/examples/02_page_screenshot.php index 7df957e..6f37f9d 100644 --- a/examples/02_page_screenshot.php +++ b/examples/02_page_screenshot.php @@ -1,14 +1,16 @@ launch(); $page = $browser->newPage(); -$page->goto('https://example.com'); +$page->goto('https://example.com/'); $page->screenshot(['path' => 'example.png']); -$browser->close(); \ No newline at end of file +$browser->close(); diff --git a/package.json b/package.json index 314fb0d..94001ac 100644 --- a/package.json +++ b/package.json @@ -17,18 +17,21 @@ "url": "https://johann.pardanaud.com/" }, "license": "MIT", - "repository": "git+https://github.com/zoonru/puphpeteer.git", + "repository": { + "type": "git", + "url": "https://github.com/zoonru/puphpeteer.git" + }, + "type": "module", + "main": "src/Rialto/node-process/index.mjs", "engines": { "node": ">=18.0.0" }, - "type": "module", "dependencies": { - "@zoon/rialto": "git+https://github.com/mreiden/rialto.git#semver:^1.5.0", - "puppeteer": "^20" + "puppeteer-core": "^22" }, "devDependencies": { "@types/yargs": "^15.0.10", - "typescript": "^4.1.2", + "typescript": "^5.0", "yargs": "^16.1.1" } } diff --git a/phpunit.xml b/phpunit.xml index 7534809..bd084fb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,6 +1,6 @@ diff --git a/src/Command/GenerateDocumentationCommand.php b/src/Command/GenerateDocumentationCommand.php index dfbe722..3a3d8f6 100644 --- a/src/Command/GenerateDocumentationCommand.php +++ b/src/Command/GenerateDocumentationCommand.php @@ -1,24 +1,28 @@ run(); } @@ -70,12 +74,12 @@ private static function getDocumentation(string $puppeteerPath, array $resourceN foreach (self::DOC_FORMATS as $format) { $process = new Process( array_merge( - ['node', self::BUILD_DIR.'/'.self::DOC_FILE_NAME.'.js', $format], + ['node', self::BUILD_DIR . '/' . self::DOC_FILE_NAME . '.js', $format], $commonFiles, $nodeFiles, ['--resources-namespace', self::RESOURCES_NAMESPACE, '--resources'], - $resourceNames - ) + $resourceNames, + ), ); $process->mustRun(); @@ -96,7 +100,7 @@ private static function getResourceNames(): array { return array_map(static function (string $filePath): string { return explode('.', basename($filePath))[0]; - }, glob(self::RESOURCES_DIR.'/*')); + }, glob(self::RESOURCES_DIR . '/*')); } private static function generatePhpDocWithDocumentation(array $classDocumentation): ?string @@ -135,7 +139,7 @@ private static function writePhpDoc(string $className, string $phpDoc): void { $reflectionClass = new \ReflectionClass($className); - if (! $reflectionClass) { + if (!$reflectionClass) { return; } @@ -176,7 +180,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (null !== $classDocumentation) { $phpDoc = self::generatePhpDocWithDocumentation($classDocumentation); if (null !== $phpDoc) { - $resourceClass = self::RESOURCES_NAMESPACE.'\\'.$resourceName; + $resourceClass = self::RESOURCES_NAMESPACE . '\\' . $resourceName; self::writePhpDoc($resourceClass, $phpDoc); } } @@ -208,12 +212,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int private static function rmdirRecursive(string $dir): bool { $files = scandir($dir); - if (! \is_array($files)) { + if (!\is_array($files)) { return false; } $files = array_diff($files, ['.', '..']); foreach ($files as $file) { - (is_dir("$dir/$file")) ? self::rmdirRecursive("$dir/$file") : unlink("$dir/$file"); + is_dir("$dir/$file") ? self::rmdirRecursive("$dir/$file") : unlink("$dir/$file"); } return rmdir($dir); diff --git a/src/Puppeteer.php b/src/Puppeteer.php index 70c2c23..bf22bb3 100644 --- a/src/Puppeteer.php +++ b/src/Puppeteer.php @@ -1,10 +1,12 @@ $options) + * @method-extended Browser connect(array $options) * * @method void registerCustomQueryHandler(string $name, mixed $queryHandler) * @@ -34,9 +36,9 @@ * * @method-extended void clearCustomQueryHandlers() * - * @method \Nesk\Puphpeteer\Resources\Browser launch(array $options = []) + * @method Browser launch(array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\Browser launch(array $options = null) + * @method-extended Browser launch(array $options = null) * * @method string executablePath(string $channel = null) * @@ -46,18 +48,16 @@ * * @method-extended string[] defaultArgs(array $options = null) * - * @method \Nesk\Puphpeteer\Resources\BrowserFetcher createBrowserFetcher(array $options) + * @method BrowserFetcher createBrowserFetcher(array $options) * - * @method-extended \Nesk\Puphpeteer\Resources\BrowserFetcher createBrowserFetcher(array $options) + * @method-extended BrowserFetcher createBrowserFetcher(array $options) */ class Puppeteer extends AbstractEntryPoint { /** * Default options. - * - * @var array */ - protected $options = [ + protected array $options = [ 'read_timeout' => 30, // Logs the output of Browser's console methods (console.log, console.debug, etc...) to the PHP logger @@ -72,15 +72,15 @@ class Puppeteer extends AbstractEntryPoint */ public function __construct(array $userOptions = []) { - if (! empty($userOptions['logger']) && $userOptions['logger'] instanceof LoggerInterface) { + if (!empty($userOptions['logger']) && $userOptions['logger'] instanceof LoggerInterface) { $this->checkPuppeteerVersion($userOptions['executable_path'] ?? 'node', $userOptions['logger']); } parent::__construct( - __DIR__.'/PuppeteerConnectionDelegate.mjs', + __DIR__ . '/PuppeteerConnectionDelegate.mjs', new PuppeteerProcessDelegate(), $this->options, - $userOptions + $userOptions, ); } @@ -95,17 +95,17 @@ private function checkPuppeteerVersion(string $nodePath, LoggerInterface $logger return; } - if (! Semver::satisfies($currentVersion, $acceptedVersions)) { + if (!Semver::satisfies($currentVersion, $acceptedVersions)) { $logger->warning( - "The installed version of Puppeteer (v$currentVersion) doesn't match the requirements" - ." ($acceptedVersions), you may encounter issues." + "The installed version of Puppeteer (v$currentVersion) doesn't match the requirements" . + " ($acceptedVersions), you may encounter issues.", ); } } private function currentPuppeteerVersion(string $nodePath): ?string { - $process = new Process([$nodePath, __DIR__.'/get-puppeteer-version.mjs']); + $process = new Process([$nodePath, __DIR__ . '/get-puppeteer-version.mjs']); $process->mustRun(); return json_decode($process->getOutput()); @@ -113,9 +113,9 @@ private function currentPuppeteerVersion(string $nodePath): ?string private function acceptedPuppeteerVersion(): string { - $npmManifestPath = __DIR__.'/../package.json'; + $npmManifestPath = __DIR__ . '/../package.json'; $npmManifest = json_decode(file_get_contents($npmManifestPath)); - return $npmManifest->dependencies->puppeteer; + return $npmManifest->dependencies->{"puppeteer-core"}; } } diff --git a/src/PuppeteerConnectionDelegate.mjs b/src/PuppeteerConnectionDelegate.mjs index 2fdfc12..a80d6ae 100644 --- a/src/PuppeteerConnectionDelegate.mjs +++ b/src/PuppeteerConnectionDelegate.mjs @@ -1,148 +1,135 @@ "use strict"; -// import { ConnectionDelegate } from "@zoon/rialto"; -// import Logger from "@zoon/rialto/src/node-process/Logger.js"; -// import ConsoleInterceptor from "@zoon/rialto/src/node-process/NodeInterceptors/ConsoleInterceptor.js"; -// import StandardStreamsInterceptor from "@zoon/rialto/src/node-process/NodeInterceptors/StandardStreamsInterceptor.js"; - -import ConnectionDelegate from "../../rialto/src/node-process/ConnectionDelegate.mjs"; -import Logger from "../../rialto/src/node-process/Logger.mjs"; -import ConsoleInterceptor from "../../rialto/src/node-process/NodeInterceptors/ConsoleInterceptor.mjs"; -import StandardStreamsInterceptor from "../../rialto/src/node-process/NodeInterceptors/StandardStreamsInterceptor.mjs"; +import ConnectionDelegate from "./Rialto/node-process/ConnectionDelegate.mjs"; +import Logger from "./Rialto/node-process/Logger.mjs"; +import ConsoleInterceptor from "./Rialto/node-process/NodeInterceptors/ConsoleInterceptor.mjs"; +import StandardStreamsInterceptor from "./Rialto/node-process/NodeInterceptors/StandardStreamsInterceptor.mjs"; import puppeteer from "puppeteer-core"; /** * Handle the requests of a connection to control Puppeteer. */ export default class PuppeteerConnectionDelegate extends ConnectionDelegate { - /** - * Constructor. - * - * @param {Object} options - */ - constructor(options) { - super(options); - - this.browsers = new Set(); - - this.addSignalEventListeners(); - } - - /** - * @inheritdoc - */ - async handleInstruction(instruction, responseHandler, errorHandler) { - if (this.options.js_extra) { - eval(this.options.js_extra); - } else { - // const puppeteer = require("puppeteer"); - instruction.setDefaultResource(puppeteer); - } - - let value = null; + /** + * Constructor. + * + * @param {Object} options + */ + constructor(options) { + super(options); - try { - value = await instruction.execute(); - } catch (error) { - if (instruction.shouldCatchErrors()) { - return errorHandler(error); - } + this.browsers = new Set(); - throw error; + this.addSignalEventListeners(); } - if (this.isInstanceOf(value, "Browser")) { - this.browsers.add(value); - - if (this.options.log_browser_console === true) { - const initialPages = await value.pages(); - initialPages.forEach((page) => - page.on("console", this.logConsoleMessage) - ); - } + /** + * @inheritdoc + */ + async handleInstruction(instruction, responseHandler, errorHandler) { + if (this.options.js_extra) { + eval(this.options.js_extra); + } else { + instruction.setDefaultResource(puppeteer); + } + + let value = null; + try { + value = await instruction.execute(); + } catch (error) { + if (instruction.shouldCatchErrors()) { + return errorHandler(error); + } + + throw error; + } + + if (this.isInstanceOf(value, "Browser")) { + this.browsers.add(value); + + if (this.options.log_browser_console === true) { + (await value.pages()).forEach((page) => page.on("console", this.logConsoleMessage)); + } + } + + if (this.options.log_browser_console === true && this.isInstanceOf(value, "Page")) { + value.on("console", this.logConsoleMessage); + } + + responseHandler(value); } - if ( - this.options.log_browser_console === true && - this.isInstanceOf(value, "Page") - ) { - value.on("console", this.logConsoleMessage); + /** + * Checks if a value is an instance of a class. The check must be done with the `[object].constructor.name` + * property because relying on Puppeteer's constructors is not viable since the exports aren't constrained by semver. + * + * @protected + * @param {*} value + * @param {string} className + * + * @see {@link https://github.com/GoogleChrome/puppeteer/issues/3067|Puppeteer's issue about semver on exports} + */ + isInstanceOf(value, className) { + const isInstance = + // constructor name exists + value?.constructor?.name !== undefined && + // constructor name matches ${className} exactly + (value.constructor.name === className || + // constructor name prefixed by a version of "CDP" or "Cdp" matches ${className} + (value.constructor.name.toUpperCase().startsWith("CDP") && + value.constructor.name.substring(3) === className)); + + return isInstance; } - responseHandler(value); - } - - /** - * Checks if a value is an instance of a class. The check must be done with the `[object].constructor.name` - * property because relying on Puppeteer's constructors isn't viable since the exports aren't constrained by semver. - * - * @protected - * @param {*} value - * @param {string} className - * - * @see {@link https://github.com/GoogleChrome/puppeteer/issues/3067|Puppeteer's issue about semver on exports} - */ - isInstanceOf(value, className) { - const nonObjectValues = [undefined, null]; - - return ( - !nonObjectValues.includes(value) && - !nonObjectValues.includes(value.constructor) && - (value.constructor.name === className || - value.constructor.name === "CDP" + className) - ); - } - - /** - * Log the console message. - * - * @param {ConsoleMessage} consoleMessage - */ - async logConsoleMessage(consoleMessage) { - const type = consoleMessage.type(); - - if (!ConsoleInterceptor.typeIsSupported(type)) { - return; + /** + * Log the console message. + * + * @param {ConsoleMessage} consoleMessage + */ + async logConsoleMessage(consoleMessage) { + const type = consoleMessage.type(); + + if (!ConsoleInterceptor.typeIsSupported(type)) { + return; + } + + const level = ConsoleInterceptor.getLevelFromType(type); + const args = await Promise.all(consoleMessage.args().map((arg) => arg.jsonValue())); + + StandardStreamsInterceptor.startInterceptingStrings((message) => { + Logger.log("Browser", level, ConsoleInterceptor.formatMessage(message)); + }); + + ConsoleInterceptor.originalConsole[type](...args); + + StandardStreamsInterceptor.stopInterceptingStrings(); } - const level = ConsoleInterceptor.getLevelFromType(type); - const args = await Promise.all( - consoleMessage.args().map((arg) => arg.jsonValue()) - ); - - StandardStreamsInterceptor.startInterceptingStrings((message) => { - Logger.log("Browser", level, ConsoleInterceptor.formatMessage(message)); - }); - - ConsoleInterceptor.originalConsole[type](...args); - - StandardStreamsInterceptor.stopInterceptingStrings(); - } - - /** - * Listen for process signal events. - * - * @protected - */ - addSignalEventListeners() { - for (let eventName of ["SIGINT", "SIGTERM", "SIGHUP"]) { - process.on(eventName, () => { - this.closeAllBrowsers(); - process.exit(); - }); + /** + * Listen for process signal events. + * + * @protected + */ + addSignalEventListeners() { + for (let eventName of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(eventName, () => { + this.closeAllBrowsers(); + process.exit(); + }); + } } - } - - /** - * Close all the browser instances when the process exits. - * - * Calling this method before exiting Node is mandatory since Puppeteer doesn't seem to handle that properly. - * - * @protected - */ - closeAllBrowsers() { - for (let browser of this.browsers.values()) { - browser.close(); + + /** + * Close all the browser instances when the process exits. + * + * Calling this method before exiting Node is mandatory since Puppeteer doesn't seem to handle that properly. + * + * @protected + */ + closeAllBrowsers() { + for (let browser of this.browsers.values()) { + browser.close(); + } } - } } diff --git a/src/PuppeteerProcessDelegate.php b/src/PuppeteerProcessDelegate.php index 0e57385..f1d633a 100644 --- a/src/PuppeteerProcessDelegate.php +++ b/src/PuppeteerProcessDelegate.php @@ -1,9 +1,10 @@ $options = null) */ -class Accessibility extends BasicResource -{ -} +class Accessibility extends BasicResource {} diff --git a/src/Resources/Browser.php b/src/Resources/Browser.php index 8a33c3f..d58e405 100644 --- a/src/Resources/Browser.php +++ b/src/Resources/Browser.php @@ -1,47 +1,51 @@ $options = null) + * @method-extended BrowserContext createIncognitoBrowserContext(array $options = null) * - * @method \Nesk\Puphpeteer\Resources\BrowserContext[] browserContexts() + * @method BrowserContext[] browserContexts() * - * @method-extended \Nesk\Puphpeteer\Resources\BrowserContext[] browserContexts() + * @method-extended BrowserContext[] browserContexts() * - * @method \Nesk\Puphpeteer\Resources\BrowserContext defaultBrowserContext() + * @method BrowserContext defaultBrowserContext() * - * @method-extended \Nesk\Puphpeteer\Resources\BrowserContext defaultBrowserContext() + * @method-extended BrowserContext defaultBrowserContext() * * @method string wsEndpoint() * * @method-extended string wsEndpoint() * - * @method \Nesk\Puphpeteer\Resources\Page newPage() + * @method Page newPage() * - * @method-extended \Nesk\Puphpeteer\Resources\Page newPage() + * @method-extended Page newPage() * - * @method \Nesk\Puphpeteer\Resources\Target[] targets() + * @method Target[] targets() * - * @method-extended \Nesk\Puphpeteer\Resources\Target[] targets() + * @method-extended Target[] targets() * - * @method \Nesk\Puphpeteer\Resources\Target target() + * @method Target target() * - * @method-extended \Nesk\Puphpeteer\Resources\Target target() + * @method-extended Target target() * - * @method \Nesk\Puphpeteer\Resources\Target waitForTarget(\Nesk\Rialto\Data\JsFunction $predicate, array $options = []) + * @method Target waitForTarget(JsFunction $predicate, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\Target waitForTarget(callable(\Nesk\Puphpeteer\Resources\Target $x): bool|Promise|bool[]|\Nesk\Rialto\Data\JsFunction $predicate, array $options = null) + * @method-extended Target waitForTarget(callable(Target $x): bool|Promise|bool[]|JsFunction $predicate, array $options = null) * - * @method \Nesk\Puphpeteer\Resources\Page[] pages() + * @method Page[] pages() * - * @method-extended \Nesk\Puphpeteer\Resources\Page[] pages() + * @method-extended Page[] pages() * * @method string version() * @@ -63,6 +67,4 @@ * * @method-extended bool isConnected() */ -class Browser extends EventEmitter -{ -} +class Browser extends EventEmitter {} diff --git a/src/Resources/BrowserContext.php b/src/Resources/BrowserContext.php index 49b90b6..1a33e54 100644 --- a/src/Resources/BrowserContext.php +++ b/src/Resources/BrowserContext.php @@ -1,19 +1,23 @@ $options) + * @method-extended ElementHandle addScriptTag(array $options) * - * @method \Nesk\Puphpeteer\Resources\ElementHandle addStyleTag(array $options) + * @method ElementHandle addStyleTag(array $options) * - * @method-extended \Nesk\Puphpeteer\Resources\ElementHandle addStyleTag(array $options) + * @method-extended ElementHandle addStyleTag(array $options) * * @method void click(string $selector, array $options = []) * @@ -91,25 +88,25 @@ * * @method-extended void type(string $selector, string $text, array{ delay: float } $options = null) * - * @method \Nesk\Puphpeteer\Resources\JSHandle|null waitFor(string|float|\Nesk\Rialto\Data\JsFunction $selectorOrFunctionOrTimeout, array|string[]|mixed[] $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method JSHandle|null waitFor(string|float|JsFunction $selectorOrFunctionOrTimeout, array|string[]|mixed[] $options = null, int|float|string|bool|null|array|JSHandle ...$args) * - * @method-extended \Nesk\Puphpeteer\Resources\JSHandle|null waitFor(string|float|callable|\Nesk\Rialto\Data\JsFunction $selectorOrFunctionOrTimeout, array|string[]|mixed[] $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method-extended JSHandle|null waitFor(string|float|callable|JsFunction $selectorOrFunctionOrTimeout, array|string[]|mixed[] $options = null, int|float|string|bool|null|array|JSHandle ...$args) * * @method void waitForTimeout(float $milliseconds) * * @method-extended void waitForTimeout(float $milliseconds) * - * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForSelector(string $selector, array $options = []) + * @method ElementHandle|null waitForSelector(string $selector, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\ElementHandle|null waitForSelector(string $selector, array $options = null) + * @method-extended ElementHandle|null waitForSelector(string $selector, array $options = null) * - * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForXPath(string $xpath, array $options = []) + * @method ElementHandle|null waitForXPath(string $xpath, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\ElementHandle|null waitForXPath(string $xpath, array $options = null) + * @method-extended ElementHandle|null waitForXPath(string $xpath, array $options = null) * - * @method \Nesk\Puphpeteer\Resources\JSHandle waitForFunction(\Nesk\Rialto\Data\JsFunction|string $pageFunction, array $options = [], int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method JSHandle waitForFunction(JsFunction|string $pageFunction, array $options = [], int|float|string|bool|null|array|JSHandle ...$args) * - * @method-extended \Nesk\Puphpeteer\Resources\JSHandle waitForFunction(callable|\Nesk\Rialto\Data\JsFunction|string $pageFunction, array $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method-extended JSHandle waitForFunction(callable|JsFunction|string $pageFunction, array $options = null, int|float|string|bool|null|array|JSHandle ...$args) * * @method string title() * diff --git a/src/Resources/HTTPRequest.php b/src/Resources/HTTPRequest.php index a4839dd..87e6d66 100644 --- a/src/Resources/HTTPRequest.php +++ b/src/Resources/HTTPRequest.php @@ -1,8 +1,10 @@ $options = null) + * @method-extended FileChooser waitForFileChooser(array $options = null) * * @method void setGeolocation(array $options) * * @method-extended void setGeolocation(array $options) * - * @method \Nesk\Puphpeteer\Resources\Target target() + * @method Target target() * - * @method-extended \Nesk\Puphpeteer\Resources\Target target() + * @method-extended Target target() * - * @method \Nesk\Puphpeteer\Resources\Browser browser() + * @method Browser browser() * - * @method-extended \Nesk\Puphpeteer\Resources\Browser browser() + * @method-extended Browser browser() * - * @method \Nesk\Puphpeteer\Resources\BrowserContext browserContext() + * @method BrowserContext browserContext() * - * @method-extended \Nesk\Puphpeteer\Resources\BrowserContext browserContext() + * @method-extended BrowserContext browserContext() * - * @method \Nesk\Puphpeteer\Resources\Frame mainFrame() + * @method Frame mainFrame() * - * @method-extended \Nesk\Puphpeteer\Resources\Frame mainFrame() + * @method-extended Frame mainFrame() * - * @method \Nesk\Puphpeteer\Resources\Frame[] frames() + * @method Frame[] frames() * - * @method-extended \Nesk\Puphpeteer\Resources\Frame[] frames() + * @method-extended Frame[] frames() * - * @method \Nesk\Puphpeteer\Resources\WebWorker[] workers() + * @method WebWorker[] workers() * - * @method-extended \Nesk\Puphpeteer\Resources\WebWorker[] workers() + * @method-extended WebWorker[] workers() * * @method void setRequestInterception(bool $value) * @@ -89,13 +91,13 @@ * * @method-extended void setDefaultTimeout(float $timeout) * - * @method mixed evaluateHandle(\Nesk\Rialto\Data\JsFunction|string $pageFunction, int|float|string|bool|array|\Nesk\Puphpeteer\Resources\JSHandle|null ...$args) + * @method mixed evaluateHandle(JsFunction|string $pageFunction, int|float|string|bool|array|JSHandle|null ...$args) * - * @method-extended mixed evaluateHandle(\Nesk\Rialto\Data\JsFunction|callable|string $pageFunction, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method-extended mixed evaluateHandle(JsFunction|callable|string $pageFunction, int|float|string|bool|null|array|JSHandle ...$args) * - * @method \Nesk\Puphpeteer\Resources\JSHandle queryObjects(\Nesk\Puphpeteer\Resources\JSHandle $prototypeHandle) + * @method JSHandle queryObjects(JSHandle $prototypeHandle) * - * @method-extended \Nesk\Puphpeteer\Resources\JSHandle queryObjects(\Nesk\Puphpeteer\Resources\JSHandle $prototypeHandle) + * @method-extended JSHandle queryObjects(JSHandle $prototypeHandle) * * @method mixed[] cookies(string ...$urls) * @@ -109,17 +111,17 @@ * * @method-extended void setCookie(mixed ...$cookies) * - * @method \Nesk\Puphpeteer\Resources\ElementHandle addScriptTag(array $options) + * @method ElementHandle addScriptTag(array $options) * - * @method-extended \Nesk\Puphpeteer\Resources\ElementHandle addScriptTag(array{ url: string, path: string, content: string, type: string, id: string } $options) + * @method-extended ElementHandle addScriptTag(array{ url: string, path: string, content: string, type: string, id: string } $options) * - * @method \Nesk\Puphpeteer\Resources\ElementHandle addStyleTag(array $options) + * @method ElementHandle addStyleTag(array $options) * - * @method-extended \Nesk\Puphpeteer\Resources\ElementHandle addStyleTag(array{ url: string, path: string, content: string } $options) + * @method-extended ElementHandle addStyleTag(array{ url: string, path: string, content: string } $options) * - * @method void exposeFunction(string $name, \Nesk\Rialto\Data\JsFunction|array $puppeteerFunction) + * @method void exposeFunction(string $name, JsFunction|array $puppeteerFunction) * - * @method-extended void exposeFunction(string $name, callable|\Nesk\Rialto\Data\JsFunction|array{ default: callable|\Nesk\Rialto\Data\JsFunction } $puppeteerFunction) + * @method-extended void exposeFunction(string $name, callable|JsFunction|array{ default: callable|JsFunction } $puppeteerFunction) * * @method void authenticate(mixed $credentials) * @@ -149,41 +151,41 @@ * * @method-extended void setContent(string $html, array $options = null) * - * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null goto(string $url, array $options = []) + * @method HTTPResponse|null goto(string $url, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\HTTPResponse|null goto(string $url, array&array{ referer: string } $options = null) + * @method-extended HTTPResponse|null goto(string $url, array&array{ referer: string } $options = null) * - * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null reload(array $options = []) + * @method HTTPResponse|null reload(array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\HTTPResponse|null reload(array $options = null) + * @method-extended HTTPResponse|null reload(array $options = null) * - * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null waitForNavigation(array $options = []) + * @method HTTPResponse|null waitForNavigation(array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\HTTPResponse|null waitForNavigation(array $options = null) + * @method-extended HTTPResponse|null waitForNavigation(array $options = null) * - * @method \Nesk\Puphpeteer\Resources\HTTPRequest waitForRequest(string|\Nesk\Rialto\Data\JsFunction $urlOrPredicate, array $options = []) + * @method HTTPRequest waitForRequest(string|JsFunction $urlOrPredicate, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\HTTPRequest waitForRequest(string|callable(callable(\Nesk\Puphpeteer\Resources\HTTPRequest $req): bool|Promise|bool[]|\Nesk\Rialto\Data\JsFunction): |\Nesk\Rialto\Data\JsFunction $urlOrPredicate, array{ timeout: float } $options = null) + * @method-extended HTTPRequest waitForRequest(string|callable(callable(HTTPRequest $req): bool|Promise|bool[]|JsFunction): |JsFunction $urlOrPredicate, array{ timeout: float } $options = null) * - * @method \Nesk\Puphpeteer\Resources\HTTPResponse waitForResponse(string|\Nesk\Rialto\Data\JsFunction $urlOrPredicate, array $options = []) + * @method HTTPResponse waitForResponse(string|JsFunction $urlOrPredicate, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\HTTPResponse waitForResponse(string|callable(callable(\Nesk\Puphpeteer\Resources\HTTPResponse $res): bool|Promise|bool[]|\Nesk\Rialto\Data\JsFunction): |\Nesk\Rialto\Data\JsFunction $urlOrPredicate, array{ timeout: float } $options = null) + * @method-extended HTTPResponse waitForResponse(string|callable(callable(HTTPResponse $res): bool|Promise|bool[]|JsFunction): |JsFunction $urlOrPredicate, array{ timeout: float } $options = null) * * @method void waitForNetworkIdle(array $options = []) * * @method-extended void waitForNetworkIdle(array{ idleTime: float, timeout: float } $options = null) * - * @method \Nesk\Puphpeteer\Resources\Frame waitForFrame(string|\Nesk\Rialto\Data\JsFunction $urlOrPredicate, array $options = []) + * @method Frame waitForFrame(string|JsFunction $urlOrPredicate, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\Frame waitForFrame(string|callable(callable(\Nesk\Puphpeteer\Resources\Frame $frame): bool|Promise|bool[]|\Nesk\Rialto\Data\JsFunction): |\Nesk\Rialto\Data\JsFunction $urlOrPredicate, array{ timeout: float } $options = null) + * @method-extended Frame waitForFrame(string|callable(callable(Frame $frame): bool|Promise|bool[]|JsFunction): |JsFunction $urlOrPredicate, array{ timeout: float } $options = null) * - * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null goBack(array $options = []) + * @method HTTPResponse|null goBack(array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\HTTPResponse|null goBack(array $options = null) + * @method-extended HTTPResponse|null goBack(array $options = null) * - * @method \Nesk\Puphpeteer\Resources\HTTPResponse|null goForward(array $options = []) + * @method HTTPResponse|null goForward(array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\HTTPResponse|null goForward(array $options = null) + * @method-extended HTTPResponse|null goForward(array $options = null) * * @method void bringToFront() * @@ -233,13 +235,13 @@ * * @method-extended mixed|null viewport() * - * @method mixed evaluate(\Nesk\Rialto\Data\JsFunction $pageFunction, int|float|string|bool|array|\Nesk\Puphpeteer\Resources\JSHandle|null ...$args) + * @method mixed evaluate(JsFunction $pageFunction, int|float|string|bool|array|JSHandle|null ...$args) * - * @method-extended mixed evaluate(callable|\Nesk\Rialto\Data\JsFunction $pageFunction, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method-extended mixed evaluate(callable|JsFunction $pageFunction, int|float|string|bool|null|array|JSHandle ...$args) * - * @method void evaluateOnNewDocument(\Nesk\Rialto\Data\JsFunction|string $pageFunction, mixed ...$args) + * @method void evaluateOnNewDocument(JsFunction|string $pageFunction, mixed ...$args) * - * @method-extended void evaluateOnNewDocument(callable|\Nesk\Rialto\Data\JsFunction|string $pageFunction, mixed ...$args) + * @method-extended void evaluateOnNewDocument(callable|JsFunction|string $pageFunction, mixed ...$args) * * @method void setCacheEnabled(bool $enabled = null) * @@ -293,25 +295,25 @@ * * @method-extended void type(string $selector, string $text, array{ delay: float } $options = null) * - * @method \Nesk\Puphpeteer\Resources\JSHandle|null waitFor(string|float|\Nesk\Rialto\Data\JsFunction $selectorOrFunctionOrTimeout, array $options = [], int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method JSHandle|null waitFor(string|float|JsFunction $selectorOrFunctionOrTimeout, array $options = [], int|float|string|bool|null|array|JSHandle ...$args) * - * @method-extended \Nesk\Puphpeteer\Resources\JSHandle|null waitFor(string|float|callable|\Nesk\Rialto\Data\JsFunction $selectorOrFunctionOrTimeout, array{ visible: bool, hidden: bool, timeout: float, polling: string|float } $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method-extended JSHandle|null waitFor(string|float|callable|JsFunction $selectorOrFunctionOrTimeout, array{ visible: bool, hidden: bool, timeout: float, polling: string|float } $options = null, int|float|string|bool|null|array|JSHandle ...$args) * * @method void waitForTimeout(float $milliseconds) * * @method-extended void waitForTimeout(float $milliseconds) * - * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForSelector(string $selector, array $options = []) + * @method ElementHandle|null waitForSelector(string $selector, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\ElementHandle|null waitForSelector(string $selector, array{ visible: bool, hidden: bool, timeout: float } $options = null) + * @method-extended ElementHandle|null waitForSelector(string $selector, array{ visible: bool, hidden: bool, timeout: float } $options = null) * - * @method \Nesk\Puphpeteer\Resources\ElementHandle|null waitForXPath(string $xpath, array $options = []) + * @method ElementHandle|null waitForXPath(string $xpath, array $options = []) * - * @method-extended \Nesk\Puphpeteer\Resources\ElementHandle|null waitForXPath(string $xpath, array{ visible: bool, hidden: bool, timeout: float } $options = null) + * @method-extended ElementHandle|null waitForXPath(string $xpath, array{ visible: bool, hidden: bool, timeout: float } $options = null) * - * @method \Nesk\Puphpeteer\Resources\JSHandle waitForFunction(\Nesk\Rialto\Data\JsFunction|string $pageFunction, array $options = [], int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method JSHandle waitForFunction(JsFunction|string $pageFunction, array $options = [], int|float|string|bool|null|array|JSHandle ...$args) * - * @method-extended \Nesk\Puphpeteer\Resources\JSHandle waitForFunction(callable|\Nesk\Rialto\Data\JsFunction|string $pageFunction, array{ timeout: float, polling: string|float } $options = null, int|float|string|bool|null|array|\Nesk\Puphpeteer\Resources\JSHandle ...$args) + * @method-extended JSHandle waitForFunction(callable|JsFunction|string $pageFunction, array{ timeout: float, polling: string|float } $options = null, int|float|string|bool|null|array|JSHandle ...$args) */ class Page extends EventEmitter { diff --git a/src/Resources/PageTarget.php b/src/Resources/PageTarget.php index e4b5ccb..66dc64a 100644 --- a/src/Resources/PageTarget.php +++ b/src/Resources/PageTarget.php @@ -1,7 +1,7 @@ consolidateOptions($implementationOptions, $userOptions), + ); + $this->setProcessSupervisor($process); + } + + /** + * Clean the user options. + */ + protected function consolidateOptions(array $implementationOptions, array $userOptions): array + { + // Filter out the forbidden option + $userOptions = array_diff_key($userOptions, array_flip($this->forbiddenOptions)); + + // Merge the user options with the implementation ones + return array_merge($implementationOptions, $userOptions); + } +} diff --git a/src/Rialto/Data/BasicResource.php b/src/Rialto/Data/BasicResource.php new file mode 100644 index 0000000..2faf4e6 --- /dev/null +++ b/src/Rialto/Data/BasicResource.php @@ -0,0 +1,27 @@ +getResourceIdentity(); + } +} diff --git a/src/Rialto/Data/JsFunction.php b/src/Rialto/Data/JsFunction.php new file mode 100644 index 0000000..bc8c0b1 --- /dev/null +++ b/src/Rialto/Data/JsFunction.php @@ -0,0 +1,122 @@ +parameters = $parameters; + $this->body = $body; + $this->scope = $scope; + } + + /** + * Return a new instance with the specified parameters. + */ + public function parameters(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + return $clone; + } + + /** + * Return a new instance with the specified body. + */ + public function body(string $body): self + { + $clone = clone $this; + $clone->body = $body; + return $clone; + } + + /** + * Return a new instance with the specified scope. + */ + public function scope(array $scope): self + { + $clone = clone $this; + $clone->scope = $scope; + return $clone; + } + + /** + * Return a new instance with the specified async state. + */ + public function async(bool $isAsync = true): self + { + $clone = clone $this; + $clone->async = $isAsync; + return $clone; + } + + /** + * Serialize the object to a value that can be serialized natively by {@see json_encode}. + */ + public function jsonSerialize(): array + { + return [ + '__rialto_function__' => true, + 'parameters' => (object) $this->parameters, + 'body' => $this->body, + 'scope' => (object) $this->scope, + 'async' => $this->async, + ]; + } + + /** + * Proxy the "createWith*" static method calls to the "*" non-static method calls of a new instance. + */ + public static function __callStatic(string $name, array $arguments) + { + $name = lcfirst(substr($name, strlen('createWith'))); + + if ($name === 'jsonSerialize') { + throw new \BadMethodCallException(); + } + + return call_user_func([new self(), $name], ...$arguments); + } +} diff --git a/src/Rialto/Data/ResourceIdentity.php b/src/Rialto/Data/ResourceIdentity.php new file mode 100644 index 0000000..395151f --- /dev/null +++ b/src/Rialto/Data/ResourceIdentity.php @@ -0,0 +1,55 @@ +className = $className; + $this->uniqueIdentifier = $uniqueIdentifier; + } + + /** + * Return the class name of the resource. + */ + public function className(): string + { + return $this->className; + } + + /** + * Return the unique identifier of the resource. + */ + public function uniqueIdentifier(): string + { + return $this->uniqueIdentifier; + } + + /** + * Serialize the object to a value that can be serialized natively by {@see json_encode}. + */ + public function jsonSerialize(): array + { + return [ + '__rialto_resource__' => true, + 'class_name' => $this->className(), + 'id' => $this->uniqueIdentifier(), + ]; + } +} diff --git a/src/Rialto/Data/UnserializesData.php b/src/Rialto/Data/UnserializesData.php new file mode 100644 index 0000000..0ffaf77 --- /dev/null +++ b/src/Rialto/Data/UnserializesData.php @@ -0,0 +1,51 @@ +options['debug']); + } + + if (($value['__rialto_resource__'] ?? false) === true) { + if ($this->delegate instanceof ShouldHandleProcessDelegation) { + $classPath = + $this->delegate->resourceFromOriginalClassName($value['class_name']) ?: + $this->delegate->defaultResource(); + } else { + $classPath = $this->defaultResource(); + } + + $resource = new $classPath(); + if ($resource instanceof ShouldIdentifyResource) { + $resource->setResourceIdentity(new ResourceIdentity($value['class_name'], $value['id'])); + } + if ($resource instanceof ShouldCommunicateWithProcessSupervisor) { + $resource->setProcessSupervisor($this); + } + + return $resource; + } + + return array_map(fn($value) => $this->unserialize($value), $value); + } +} diff --git a/src/Rialto/Exceptions/IdentifiesProcess.php b/src/Rialto/Exceptions/IdentifiesProcess.php new file mode 100644 index 0000000..57c557c --- /dev/null +++ b/src/Rialto/Exceptions/IdentifiesProcess.php @@ -0,0 +1,23 @@ +process; + } +} diff --git a/src/Rialto/Exceptions/IdleTimeoutException.php b/src/Rialto/Exceptions/IdleTimeoutException.php new file mode 100644 index 0000000..5a5cf36 --- /dev/null +++ b/src/Rialto/Exceptions/IdleTimeoutException.php @@ -0,0 +1,41 @@ +getErrorOutput(), true); + + return $error['message'] === 'The idle timeout has been reached.'; + } + + return false; + } + + /** + * Constructor. + */ + public function __construct(float $timeout, \Throwable $previous = null) + { + $timeout = number_format($timeout, 3); + + parent::__construct( + implode(' ', [ + "The idle timeout ($timeout seconds) has been exceeded.", + 'Maybe you should increase the "idle_timeout" option.', + ]), + 0, + $previous, + ); + } +} diff --git a/src/Rialto/Exceptions/Node/Exception.php b/src/Rialto/Exceptions/Node/Exception.php new file mode 100644 index 0000000..bf673ed --- /dev/null +++ b/src/Rialto/Exceptions/Node/Exception.php @@ -0,0 +1,20 @@ +setTraceAndGetMessage($error, $appendStackTraceToMessage); + + parent::__construct($message); + } +} diff --git a/src/Rialto/Exceptions/Node/FatalException.php b/src/Rialto/Exceptions/Node/FatalException.php new file mode 100644 index 0000000..c2f024f --- /dev/null +++ b/src/Rialto/Exceptions/Node/FatalException.php @@ -0,0 +1,34 @@ +getErrorOutput()); + } + + /** + * Constructor. + */ + public function __construct(Process $process, bool $appendStackTraceToMessage = false) + { + $this->process = $process; + + $message = $this->setTraceAndGetMessage($process->getErrorOutput(), $appendStackTraceToMessage); + + parent::__construct($message); + } +} diff --git a/src/Rialto/Exceptions/Node/HandlesNodeErrors.php b/src/Rialto/Exceptions/Node/HandlesNodeErrors.php new file mode 100644 index 0000000..e44de50 --- /dev/null +++ b/src/Rialto/Exceptions/Node/HandlesNodeErrors.php @@ -0,0 +1,49 @@ +originalTrace = $error['stack'] ?? null; + + $message = $error['message']; + + if ($appendStackTraceToMessage) { + $message .= "\n\n" . $error['stack']; + } + + return $message; + } + + /** + * Return the original stack trace. + */ + public function getOriginalTrace(): ?string + { + return $this->originalTrace; + } +} diff --git a/src/Rialto/Exceptions/ProcessUnexpectedlyTerminatedException.php b/src/Rialto/Exceptions/ProcessUnexpectedlyTerminatedException.php new file mode 100644 index 0000000..1ac5680 --- /dev/null +++ b/src/Rialto/Exceptions/ProcessUnexpectedlyTerminatedException.php @@ -0,0 +1,22 @@ +process = $process; + } +} diff --git a/src/Rialto/Exceptions/ReadSocketTimeoutException.php b/src/Rialto/Exceptions/ReadSocketTimeoutException.php new file mode 100644 index 0000000..67b6e6f --- /dev/null +++ b/src/Rialto/Exceptions/ReadSocketTimeoutException.php @@ -0,0 +1,25 @@ +type = self::TYPE_CALL; + $this->name = $name; + $this->setValue($arguments, $this->type); + + return $this; + } + + /** + * Define a getter. + */ + public function get(string $name): self + { + $this->type = self::TYPE_GET; + $this->name = $name; + $this->setValue(null, $this->type); + + return $this; + } + + /** + * Define a setter. + */ + public function set(string $name, $value): self + { + $this->type = self::TYPE_SET; + $this->name = $name; + $this->setValue($value, $this->type); + + return $this; + } + + /** + * Link the instruction to the provided resource. + */ + public function linkToResource(BasicResource|ResourceIdentity|string|null $resource = null): self + { + $this->resource = $resource; + + return $this; + } + + /** + * Define if instruction errors should be caught. + */ + public function shouldCatchErrors(bool $catch): self + { + $this->shouldCatchErrors = $catch; + + return $this; + } + + /** + * Set the instruction value. + */ + protected function setValue($value, string $type) + { + $this->value = + $type !== self::TYPE_CALL + ? $this->validateValue($value) + : array_map(fn($value) => $this->validateValue($value), $value); + } + + /** + * Validate a value. + * + * @throws InvalidArgumentException if the value contains PHP closures. + */ + protected function validateValue($value) + { + if ($value instanceof Closure) { + throw new InvalidArgumentException('You must use JS function wrappers instead of PHP closures.'); + } + + return $value; + } + + /** + * Serialize the object to a value that can be serialized natively by {@see json_encode}. + */ + public function jsonSerialize(): array + { + $instruction = ['type' => $this->type]; + + if ($this->type !== self::TYPE_NOOP) { + $instruction = array_merge($instruction, [ + 'name' => $this->name, + 'shouldCatchErrors' => $this->shouldCatchErrors, + ]); + + if ($this->type !== self::TYPE_GET) { + $instruction['value'] = $this->value; + } + + if ($this->resource !== null) { + $instruction['resource'] = $this->resource; + } + } + + return $instruction; + } + + /** + * Proxy the "with*" static method calls to the "*" non-static method calls of a new instance. + */ + public static function __callStatic(string $name, array $arguments) + { + $name = lcfirst(substr($name, strlen('with'))); + + if ($name === 'jsonSerialize') { + throw new BadMethodCallException(); + } + + return call_user_func([new self(), $name], ...$arguments); + } +} diff --git a/src/Rialto/Interfaces/ShouldCommunicateWithProcessSupervisor.php b/src/Rialto/Interfaces/ShouldCommunicateWithProcessSupervisor.php new file mode 100644 index 0000000..79f143c --- /dev/null +++ b/src/Rialto/Interfaces/ShouldCommunicateWithProcessSupervisor.php @@ -0,0 +1,17 @@ +log(LogLevel::EMERGENCY, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param string $message + */ + public function alert($message, array $context = []): void + { + $this->log(LogLevel::ALERT, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param string $message + */ + public function critical($message, array $context = []): void + { + $this->log(LogLevel::CRITICAL, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param string $message + */ + public function error($message, array $context = []): void + { + $this->log(LogLevel::ERROR, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param string $message + */ + public function warning($message, array $context = []): void + { + $this->log(LogLevel::WARNING, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param string $message + */ + public function notice($message, array $context = []): void + { + $this->log(LogLevel::NOTICE, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param string $message + */ + public function info($message, array $context = []): void + { + $this->log(LogLevel::INFO, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param string $message + */ + public function debug($message, array $context = []): void + { + $this->log(LogLevel::DEBUG, $message, $context); + } + + /** + * {@inheritDoc} + * + * @param mixed $level + * @param string $message + */ + public function log($level, $message, array $context = []): void + { + if ($this->logger instanceof LoggerInterface) { + $message = $this->interpolate($message, $context); + $this->logger->log($level, $message, $context); + } + } + + /** + * Interpolate context values into the message placeholders. + * + * @see https://www.php-fig.org/psr/psr-3/#12-message + */ + protected function interpolate(string $message, array $context = []): string + { + $replace = []; + + foreach ($context as $key => $val) { + if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { + $replace['{' . $key . '}'] = $val; + } + } + + return strtr($message, $replace); + } +} diff --git a/src/Rialto/ProcessSupervisor.php b/src/Rialto/ProcessSupervisor.php new file mode 100644 index 0000000..bc5d83d --- /dev/null +++ b/src/Rialto/ProcessSupervisor.php @@ -0,0 +1,457 @@ + 'node', + + // How much time (in seconds) the process can stay inactive before being killed (set to null to disable) + 'idle_timeout' => 60, + + // How much time (in seconds) an instruction can take to return a value (set to null to disable) + 'read_timeout' => 30, + + // How much time (in seconds) the process can take to shut down properly before being killed + 'stop_timeout' => 3, + + // A logger instance for debugging (must implement \Psr\Log\LoggerInterface) + 'logger' => null, + + // Logs the output of console methods (console.log, console.debug, console.table, etc...) to the PHP logger + 'log_node_console' => false, + + // Enables debugging mode: + // - adds the --inspect flag to Node command + // - appends stack traces to Node exception messages + 'debug' => false, + ]; + + /** + * The running process. + */ + protected SymfonyProcess $process; + + /** + * The PID of the running process. + */ + protected int $processPid; + + /** + * The client to communicate with the process. + */ + protected Socket $client; + + /** + * The server port. + */ + protected ?int $serverPort = null; + + /** + * The logger instance. + */ + protected LoggerInterface $logger; + + /** + * Constructor. + */ + public function __construct( + string $connectionDelegatePath, + /** + * The process delegate. + */ + private readonly ?ShouldHandleProcessDelegation $delegate = null, + array $options = [], + ) { + $this->logger = new Logger($options['logger'] ?? null); + + $this->applyOptions($options); + + $this->process = $this->createNewProcess($connectionDelegatePath); + + $this->processPid = $this->startProcess($this->process); + + $this->client = $this->createNewClient($this->serverPort()); + + if ($this->options['debug']) { + // Clear error output made by the "--inspect" flag + $this->process->clearErrorOutput(); + } + } + + /** + * Destructor. + */ + public function __destruct() + { + $logContext = ['pid' => $this->processPid]; + + $this->waitForProcessTermination(); + + if ($this->process->isRunning()) { + $this->executeInstruction(Instruction::noop(), false); // Fetch the missing remote logs + + $this->logger->info('Stopping process with PID {pid}...', $logContext); + $this->process->stop($this->options['stop_timeout']); + $this->logger->info('Stopped process with PID {pid}', $logContext); + } else { + $this->logger->warning("The process cannot because be stopped because it's no longer running", $logContext); + } + } + + /** + * Log data from the process standard streams. + */ + protected function logProcessStandardStreams(): void + { + if (!empty(($output = $this->process->getIncrementalOutput()))) { + $this->logger->notice('Received data on stdout: {output}', [ + 'pid' => $this->processPid, + 'stream' => 'stdout', + 'output' => $output, + ]); + } + + if (!empty(($errorOutput = $this->process->getIncrementalErrorOutput()))) { + $this->logger->error('Received data on stderr: {output}', [ + 'pid' => $this->processPid, + 'stream' => 'stderr', + 'output' => $errorOutput, + ]); + } + } + + /** + * Apply the options. + */ + protected function applyOptions(array $options): void + { + $this->logger->info('Applying options...', ['options' => $options]); + + $this->options = array_merge($this->options, $options); + + $this->logger->debug('Options applied and merged with defaults', ['options' => $this->options]); + } + + /** + * Return the script path of the Node process. + * + * In production, the script path must target the NPM package. In local development, the script path targets the + * Composer package (since the NPM package is not installed). + * + * This avoids double declarations of some JS classes in production, due to a require with two different paths (one + * with the NPM path, the other one with the Composer path). + */ + protected function getProcessScriptPath(): string + { + static $scriptPath = null; + + if ($scriptPath !== null) { + return $scriptPath; + } + + // The script path in local development + $scriptPath = __DIR__ . '/node-process/serve.mjs'; + + $process = new SymfonyProcess([$this->options['executable_path'], '-e', $scriptPath]); + $exitCode = $process->run(); + + if ($exitCode === 0) { + // The script path in production + $scriptPath = $process->getOutput(); + } + + return $scriptPath; + } + + /** + * Create a new Node process. + * + * @throws RuntimeException if the path to the connection delegate cannot be found. + */ + protected function createNewProcess(string $connectionDelegatePath): SymfonyProcess + { + $realConnectionDelegatePath = realpath($connectionDelegatePath); + + if ($realConnectionDelegatePath === false) { + throw new RuntimeException("Cannot find file or directory '$connectionDelegatePath'."); + } + + // Remove useless options for the process + $processOptions = array_diff_key($this->options, array_flip(self::USELESS_OPTIONS_FOR_PROCESS)); + + return new SymfonyProcess( + array_merge( + [$this->options['executable_path']], + $this->options['debug'] ? ['--inspect'] : [], + [$this->getProcessScriptPath()], + [$realConnectionDelegatePath], + [json_encode((object) $processOptions)], + ), + ); + } + + /** + * Start the Node process. + */ + protected function startProcess(SymfonyProcess $process): int + { + $this->logger->info('Starting process with command line: {commandline}', [ + 'commandline' => $process->getCommandLine(), + ]); + + $process->start(); + + $pid = $process->getPid(); + + $this->logger->info('Process started with PID {pid}', ['pid' => $pid]); + + return $pid; + } + + /** + * Check if the process is still running without errors. + * + * @throws ProcessFailedException + */ + protected function checkProcessStatus(): void + { + $this->logProcessStandardStreams(); + + $process = $this->process; + + if (!empty($process->getErrorOutput())) { + if (IdleTimeoutException::exceptionApplies($process)) { + throw new IdleTimeoutException( + $this->options['idle_timeout'], + new NodeFatalException($process, $this->options['debug']), + ); + } elseif (NodeFatalException::exceptionApplies($process)) { + throw new NodeFatalException($process, $this->options['debug']); + } elseif ($process->isTerminated() && !$process->isSuccessful()) { + throw new ProcessFailedException($process); + } + } + + if ($process->isTerminated()) { + throw new Exceptions\ProcessUnexpectedlyTerminatedException($process); + } + } + + /** + * Wait for process termination. + * + * The process might take a while to stop itself. So, before trying to check its status or reading its standard + * streams, this method should be executed. + */ + protected function waitForProcessTermination(): void + { + usleep(self::PROCESS_TERMINATION_DELAY * 1000); + } + + /** + * Return the port of the server. + */ + protected function serverPort(): int + { + if ($this->serverPort !== null) { + return $this->serverPort; + } + + $iterator = $this->process->getIterator(SymfonyProcess::ITER_SKIP_ERR | SymfonyProcess::ITER_KEEP_OUTPUT); + + foreach ($iterator as $data) { + $this->serverPort = (int) $data; + return $this->serverPort; + } + + // The process must have failed if the iterator did not execute properly, but check to be sure. + $this->checkProcessStatus(); + + // Return serverPort if checkProcessStatus did not throw an exception + return $this->serverPort; + } + + /** + * Create a new client to communicate with the process. + */ + protected function createNewClient(int $port): Socket + { + // Set the client as non-blocking to handle the exceptions thrown by the process + return (new SocketFactory())->createClient("tcp://127.0.0.1:$port")->setBlocking(false); + } + + /** + * Send an instruction to the process for execution. + */ + public function executeInstruction(Instruction $instruction, bool $instructionShouldBeLogged = true) + { + // Check the process status because it could have crash in idle status. + $this->checkProcessStatus(); + + $serializedInstruction = json_encode($instruction); + + if ($instructionShouldBeLogged) { + $this->logger->debug('Sending an instruction to the port {port}...', [ + 'pid' => $this->processPid, + 'port' => $this->serverPort(), + + // The instruction must be fully encoded and decoded to appear properly in the logs (this way, + // JS functions and resources are serialized too). + 'instruction' => json_decode($serializedInstruction, true), + ]); + } + + $this->client->selectWrite(1); + + $packet = $serializedInstruction . chr(0); + $packetSentByteCount = 0; + while ($packetSentByteCount < strlen($packet)) { + $packetSentByteCount += $this->client->write(substr($packet, $packetSentByteCount)); + } + + $value = $this->readNextProcessValue($instructionShouldBeLogged); + + // Check the process status if the value is null because, if the process crash while executing the instruction, + // the socket closes and returns an empty value (which is converted to `null`). + if ($value === null) { + $this->checkProcessStatus(); + } + + return $value; + } + + /** + * Read the next value written by the process. + * + * @throws ReadSocketTimeoutException if reading the socket exceeded the timeout. + * @throws NodeException if the process returned an error. + */ + protected function readNextProcessValue(bool $valueShouldBeLogged = true) + { + $readTimeout = $this->options['read_timeout']; + $payload = ''; + + try { + $startTimestamp = microtime(true); + + do { + $this->client->selectRead($readTimeout); + $packet = $this->client->read(static::SOCKET_PACKET_SIZE); + + $chunksLeft = (int) substr($packet, 0, static::SOCKET_HEADER_SIZE); + $chunk = substr($packet, static::SOCKET_HEADER_SIZE); + + $payload .= $chunk; + + if ($chunksLeft > 0) { + // The next chunk might be an empty string on slow environments without a short pause. + usleep(self::SOCKET_NEXT_CHUNK_DELAY * 1000); + } + } while ($chunksLeft > 0); + } catch (SocketException $exception) { + $this->waitForProcessTermination(); + $this->checkProcessStatus(); + + // Extract the socket error code to throw more specific exceptions + preg_match('/\(([A-Z_]+?)\)$/', $exception->getMessage(), $socketErrorMatches); + $socketErrorCode = constant($socketErrorMatches[1]); + + $elapsedTime = microtime(true) - $startTimestamp; + if ($socketErrorCode === SOCKET_EAGAIN && $readTimeout !== null && $elapsedTime >= $readTimeout) { + throw new ReadSocketTimeoutException($readTimeout, $exception); + } + + throw $exception; + } + + $this->logProcessStandardStreams(); + + ['logs' => $logs, 'value' => $value] = json_decode(base64_decode($payload), true); + + foreach ($logs ?: [] as $log) { + $level = (new ReflectionClass(LogLevel::class))->getConstant($log['level']); + $messageContainsLineBreaks = str_contains($log['message'], PHP_EOL); + $formattedMessage = $messageContainsLineBreaks ? "\n{log}\n" : '{log}'; + + $this->logger->log($level, "Received a $log[origin] log: $formattedMessage", [ + 'pid' => $this->processPid, + 'port' => $this->serverPort(), + 'log' => $log['message'], + ]); + } + + $value = $this->unserialize($value); + + if ($valueShouldBeLogged) { + $this->logger->debug('Received data from the port {port}...', [ + 'pid' => $this->processPid, + 'port' => $this->serverPort(), + 'data' => $value, + ]); + } + + if ($value instanceof NodeException) { + throw $value; + } + + return $value; + } +} diff --git a/src/Rialto/Traits/CommunicatesWithProcessSupervisor.php b/src/Rialto/Traits/CommunicatesWithProcessSupervisor.php new file mode 100644 index 0000000..31a56a4 --- /dev/null +++ b/src/Rialto/Traits/CommunicatesWithProcessSupervisor.php @@ -0,0 +1,126 @@ +processSupervisor; + } + + /** + * Set the process supervisor. + * + * @throws RuntimeException if the process supervisor has already been set. + */ + public function setProcessSupervisor(ProcessSupervisor $process): void + { + if ($this->processSupervisor !== null) { + throw new RuntimeException('The process supervisor has already been set.'); + } + + $this->processSupervisor = $process; + } + + /** + * Clone the resource and catch its instruction errors. + */ + protected function createCatchingResource() + { + $resource = clone $this; + + $resource->catchInstructionErrors = true; + + return $resource; + } + + /** + * Proxy an action. + */ + protected function proxyAction(string $actionType, string $name, $value = null) + { + switch ($actionType) { + case Instruction::TYPE_CALL: + $value ??= []; + $instruction = Instruction::withCall($name, ...$value); + + //$instruction->linkToResource(Browser::class); + break; + case Instruction::TYPE_GET: + $instruction = Instruction::withGet($name); + break; + case Instruction::TYPE_SET: + $instruction = Instruction::withSet($name, $value); + break; + } + + $instruction->linkToResource($this instanceof ShouldIdentifyResource ? $this : null); + + if ($this->catchInstructionErrors) { + $instruction->shouldCatchErrors(true); + } + + return $this->getProcessSupervisor()->executeInstruction($instruction); + } + + /** + * Proxy the string casting to the process supervisor. + */ + public function __toString(): string + { + return $this->proxyAction(Instruction::TYPE_CALL, 'toString'); + } + + /** + * Proxy the method call to the process supervisor. + */ + public function __call(string $name, array $arguments) + { + return $this->proxyAction(Instruction::TYPE_CALL, $name, $arguments); + } + + /** + * Proxy the property reading to the process supervisor. + */ + public function __get(string $name) + { + if ($name === 'tryCatch' && !$this->catchInstructionErrors) { + return $this->createCatchingResource(); + } + + return $this->proxyAction(Instruction::TYPE_GET, $name); + } + + /** + * Proxy the property writing to the process supervisor. + */ + public function __set(string $name, $value) + { + return $this->proxyAction(Instruction::TYPE_SET, $name, $value); + } +} diff --git a/src/Rialto/Traits/IdentifiesResource.php b/src/Rialto/Traits/IdentifiesResource.php new file mode 100644 index 0000000..7638622 --- /dev/null +++ b/src/Rialto/Traits/IdentifiesResource.php @@ -0,0 +1,38 @@ +resourceIdentity; + } + + /** + * Set the identity of the resource. + * + * @throws RuntimeException if the resource identity has already been set. + */ + public function setResourceIdentity(ResourceIdentity $identity): void + { + if ($this->resourceIdentity !== null) { + throw new RuntimeException('The resource identity has already been set.'); + } + + $this->resourceIdentity = $identity; + } +} diff --git a/src/Rialto/Traits/UsesBasicResourceAsDefault.php b/src/Rialto/Traits/UsesBasicResourceAsDefault.php new file mode 100644 index 0000000..fb14b25 --- /dev/null +++ b/src/Rialto/Traits/UsesBasicResourceAsDefault.php @@ -0,0 +1,18 @@ + { + this.emit("activity"); + + buffer += data; + if (buffer.endsWith("\0")) { + buffer = buffer.slice(0, -1); + this.handleSocketData(buffer); + buffer = ""; + } + }); + + return socket; + } + + /** + * Handle data received on the socket. + * + * @param {string} data + */ + handleSocketData(data) { + const instruction = new Instruction(JSON.parse(data), this.resources, this.dataUnserializer), + { responseHandler, errorHandler } = this.createInstructionHandlers(); + + this.delegate.handleInstruction(instruction, responseHandler, errorHandler); + } + + /** + * Generate response and errors handlers. + * + * @return {Object} + */ + createInstructionHandlers() { + let handlerHasBeenCalled = false; + + const handler = (serializingMethod, value) => { + if (handlerHasBeenCalled) { + throw new Error("You can call only once the response/error handler."); + } + + handlerHasBeenCalled = true; + + this.writeToSocket( + JSON.stringify({ + logs: Logger.logs(), + value: this[serializingMethod](value), + }), + ); + }; + + return { + responseHandler: handler.bind(this, "serializeValue"), + errorHandler: handler.bind(this, "serializeError"), + }; + } + + /** + * Write a string to the socket by slitting it in packets of fixed length. + * + * @param {string} str + */ + writeToSocket(str) { + const payload = Buffer.from(str).toString("base64"); + + const bodySize = Connection.SOCKET_PACKET_SIZE - Connection.SOCKET_HEADER_SIZE, + chunkCount = Math.ceil(payload.length / bodySize); + + for (let i = 0; i < chunkCount; i++) { + const chunk = payload.substr(i * bodySize, bodySize); + + let chunksLeft = String(chunkCount - 1 - i); + chunksLeft = chunksLeft.padStart(Connection.SOCKET_HEADER_SIZE, "0"); + + this.socket.write(`${chunksLeft}${chunk}`); + } + } + + /** + * Serialize a value to return to the client. + * + * @param {*} value + * @return {Object} + */ + serializeValue(value) { + return this.dataSerializer.serialize(value); + } + + /** + * Serialize an error to return to the client. + * + * @param {Error} error + * @return {Object} + */ + serializeError(error) { + return DataSerializer.serializeError(error); + } +} + +/** + * The size of a packet sent through the sockets. + * + * @constant + * @type {number} + */ +Connection.SOCKET_PACKET_SIZE = 1024; + +/** + * The size of the header in each packet sent through the sockets. + * + * @constant + * @type {number} + */ +Connection.SOCKET_HEADER_SIZE = 5; diff --git a/src/Rialto/node-process/ConnectionDelegate.mjs b/src/Rialto/node-process/ConnectionDelegate.mjs new file mode 100644 index 0000000..9869427 --- /dev/null +++ b/src/Rialto/node-process/ConnectionDelegate.mjs @@ -0,0 +1,36 @@ +"use strict"; + +/** + * @callback responseHandler + * @param {*} value + */ + +/** + * @callback errorHandler + * @param {Error} error + */ + +/** + * Handle the requests of a connection. + */ +export default class ConnectionDelegate { + /** + * Constructor. + * + * @param {Object} options + */ + constructor(options) { + this.options = options; + } + + /** + * Handle the provided instruction and respond to it. + * + * @param {Instruction} instruction + * @param {responseHandler} responseHandler + * @param {errorHandler} errorHandler + */ + handleInstruction(instruction, responseHandler, errorHandler) { + responseHandler(null); + } +} diff --git a/src/Rialto/node-process/Data/ResourceIdentity.mjs b/src/Rialto/node-process/Data/ResourceIdentity.mjs new file mode 100644 index 0000000..623f2f2 --- /dev/null +++ b/src/Rialto/node-process/Data/ResourceIdentity.mjs @@ -0,0 +1,54 @@ +"use strict"; + +export default class ResourceIdentity { + /** + * Constructor. + * + * @param {string} uniqueIdentifier + * @param {string|null} className + */ + constructor(uniqueIdentifier, className = null) { + this.resource = { uniqueIdentifier, className }; + } + + /** + * Return the unique identifier of the resource. + * + * @return {string} + */ + uniqueIdentifier() { + return this.resource.uniqueIdentifier; + } + + /** + * Return the class name of the resource. + * + * @return {string|null} + */ + className() { + return this.resource.className; + } + + /** + * Unserialize a resource identity. + * + * @param {Object} identity + * @return {ResourceIdentity} + */ + static unserialize(identity) { + return new ResourceIdentity(identity.id, identity.class_name); + } + + /** + * Serialize the resource identity. + * + * @return {Object} + */ + serialize() { + return { + __rialto_resource__: true, + id: this.uniqueIdentifier(), + class_name: this.className(), + }; + } +} diff --git a/src/Rialto/node-process/Data/ResourceRepository.mjs b/src/Rialto/node-process/Data/ResourceRepository.mjs new file mode 100644 index 0000000..03990e8 --- /dev/null +++ b/src/Rialto/node-process/Data/ResourceRepository.mjs @@ -0,0 +1,102 @@ +"use strict"; + +import ResourceIdentity from "./ResourceIdentity.mjs"; + +export default class ResourceRepository { + /** + * Constructor. + */ + constructor() { + this.resources = new Map(); + } + + /** + * Retrieve a resource with its identity from a specific storage. + * + * @param {Map} storage + * @param {ResourceIdentity} identity + * @return {*} + */ + static retrieveFrom(storage, identity) { + for (let [resource, id] of storage) { + if (identity.uniqueIdentifier() === id) { + return resource; + } + } + + return null; + } + + /** + * Retrieve a resource with its identity from the local storage. + * + * @param {ResourceIdentity} identity + * @return {*} + */ + retrieve(identity) { + return ResourceRepository.retrieveFrom(this.resources, identity); + } + + /** + * Retrieve a resource with its unique identifier from the global storage. + * + * @param {string} uniqueIdentifier + * @return {*} + */ + static retrieveGlobal(uniqueIdentifier) { + const identity = new ResourceIdentity(uniqueIdentifier); + return ResourceRepository.retrieveFrom(ResourceRepository.globalResources, identity); + } + + /** + * Store a resource in a specific storage and return its identity. + * + * @param {Map} storage + * @param {*} resource + * @return {ResourceIdentity} + */ + static storeIn(storage, resource) { + if (storage.has(resource)) { + return ResourceRepository.generateResourceIdentity(resource, storage.get(resource)); + } + + const id = String(Date.now() + Math.random()); + + storage.set(resource, id); + + return ResourceRepository.generateResourceIdentity(resource, id); + } + + /** + * Store a resource in the local storage and return its identity. + * + * @param {*} resource + * @return {ResourceIdentity} + */ + store(resource) { + return ResourceRepository.storeIn(this.resources, resource); + } + + /** + * Store a resource in the global storage and return its unique identifier. + * + * @param {*} resource + * @return {string} + */ + static storeGlobal(resource) { + return ResourceRepository.storeIn(ResourceRepository.globalResources, resource).uniqueIdentifier(); + } + + /** + * Generate a resource identity. + * + * @param {*} resource + * @param {string} uniqueIdentifier + * @return {ResourceIdentity} + */ + static generateResourceIdentity(resource, uniqueIdentifier) { + return new ResourceIdentity(uniqueIdentifier, resource.constructor.name); + } +} + +ResourceRepository.globalResources = new Map(); diff --git a/src/Rialto/node-process/Data/Serializer.mjs b/src/Rialto/node-process/Data/Serializer.mjs new file mode 100644 index 0000000..3a2c14d --- /dev/null +++ b/src/Rialto/node-process/Data/Serializer.mjs @@ -0,0 +1,46 @@ +"use strict"; + +import Value from "./Value.mjs"; + +export default class Serializer { + /** + * Serialize an error to JSON. + * + * @param {Error} error + * @return {Object} + */ + static serializeError(error) { + return { + __rialto_error__: true, + message: error.message, + stack: error.stack, + }; + } + + /** + * Constructor. + * + * @param {ResourceRepository} resources + */ + constructor(resources) { + this.resources = resources; + } + + /** + * Serialize a value. + * + * @param {*} value + * @return {*} + */ + serialize(value) { + value = value === undefined ? null : value; + + if (Value.isContainer(value)) { + return Value.mapContainer(value, this.serialize.bind(this)); + } else if (Value.isScalar(value)) { + return value; + } else { + return this.resources.store(value).serialize(); + } + } +} diff --git a/src/Rialto/node-process/Data/Unserializer.mjs b/src/Rialto/node-process/Data/Unserializer.mjs new file mode 100644 index 0000000..bdd304c --- /dev/null +++ b/src/Rialto/node-process/Data/Unserializer.mjs @@ -0,0 +1,91 @@ +"use strict"; + +import _ from "lodash"; +import ResourceIdentity from "./ResourceIdentity.mjs"; +import ResourceRepository from "./ResourceRepository.mjs"; +import Value from "./Value.mjs"; + +// Some unserialized functions require an access to the ResourceRepository class, so we must put it in the global scope. +global.__rialto_ResourceRepository__ = ResourceRepository; + +export default class Unserializer { + /** + * Constructor. + * + * @param {ResourceRepository} resources + */ + constructor(resources) { + this.resources = resources; + } + + /** + * Unserialize a value. + * + * @param {*} value + * @return {*} + */ + unserialize(value) { + if (_.get(value, "__rialto_resource__") === true) { + return this.resources.retrieve(ResourceIdentity.unserialize(value)); + } else if (_.get(value, "__rialto_function__") === true) { + return this.unserializeFunction(value); + } else if (Value.isContainer(value)) { + return Value.mapContainer(value, this.unserialize.bind(this)); + } else { + return value; + } + } + + /** + * Return a string used to embed a value in a function. + * + * @param {*} value + * @return {string} + */ + embedFunctionValue(value) { + value = this.unserialize(value); + const valueUniqueIdentifier = ResourceRepository.storeGlobal(value); + + const a = Value.isResource(value) + ? ` + __rialto_ResourceRepository__ + .retrieveGlobal(${JSON.stringify(valueUniqueIdentifier)}) + ` + : JSON.stringify(value); + + return a; + } + + /** + * Unserialize a function. + * + * @param {Object} value + * @return {Function} + */ + unserializeFunction(value) { + const scopedVariables = []; + + for (let [varName, varValue] of Object.entries(value.scope)) { + scopedVariables.push(`var ${varName} = ${this.embedFunctionValue(varValue)};`); + } + + const parameters = []; + + for (let [paramKey, paramValue] of Object.entries(value.parameters)) { + if (!isNaN(parseInt(paramKey, 10))) { + parameters.push(paramValue); + } else { + parameters.push(`${paramKey} = ${this.embedFunctionValue(paramValue)}`); + } + } + + const asyncFlag = value.async ? "async" : ""; + + return new Function(` + return ${asyncFlag} function (${parameters.join(", ")}) { + ${scopedVariables.join("\n")} + ${value.body} + }; + `)(); + } +} diff --git a/src/Rialto/node-process/Data/Value.mjs b/src/Rialto/node-process/Data/Value.mjs new file mode 100644 index 0000000..3781d30 --- /dev/null +++ b/src/Rialto/node-process/Data/Value.mjs @@ -0,0 +1,56 @@ +"use strict"; + +import _ from "lodash"; + +export default class Value { + /** + * Determine if the value is a string, a number, a boolean, or null. + * + * @param {*} value + * @return {boolean} + */ + static isScalar(value) { + return _.isString(value) || _.isNumber(value) || _.isBoolean(value) || _.isNull(value); + } + + /** + * Determine if the value is an array or a plain object. + * + * @param {*} value + * @return {boolean} + */ + static isContainer(value) { + return _.isArray(value) || _.isPlainObject(value); + } + + /** + * Map the values of a container. + * + * @param {*} container + * @param {callback} mapper + * @return {array} + */ + static mapContainer(container, mapper) { + if (_.isArray(container)) { + return container.map(mapper); + } else if (_.isPlainObject(container)) { + return Object.entries(container).reduce((finalObject, [key, value]) => { + finalObject[key] = mapper(value); + + return finalObject; + }, {}); + } else { + return container; + } + } + + /** + * Determine if the value is a resource. + * + * @param {*} value + * @return {boolean} + */ + static isResource(value) { + return !Value.isContainer(value) && !Value.isScalar(value); + } +} diff --git a/src/Rialto/node-process/Instruction.mjs b/src/Rialto/node-process/Instruction.mjs new file mode 100644 index 0000000..e32b01b --- /dev/null +++ b/src/Rialto/node-process/Instruction.mjs @@ -0,0 +1,205 @@ +"use strict"; + +import ResourceIdentity from "./Data/ResourceIdentity.mjs"; + +export default class Instruction { + /** + * Constructor. + * + * @param {Object} serializedInstruction + * @param {ResourceRepository} resources + * @param {DataUnserializer} dataUnserializer + */ + constructor(serializedInstruction, resources, dataUnserializer) { + this.instruction = serializedInstruction; + this.resources = resources; + this.dataUnserializer = dataUnserializer; + this.defaultResource = process; + } + + /** + * Return the type of the instruction. + * + * @return {instructionTypeEnum} + */ + type() { + return this.instruction.type; + } + + /** + * Override the type of the instruction. + * + * @param {instructionTypeEnum} type + * @return {this} + */ + overrideType(type) { + this.instruction.type = type; + + return this; + } + + /** + * Return the name of the instruction. + * + * @return {string} + */ + name() { + return this.instruction.name; + } + + /** + * Override the name of the instruction. + * + * @param {string} name + * @return {this} + */ + overrideName(name) { + this.instruction.name = name; + + return this; + } + + /** + * Return the value of the instruction. + * + * @return {*} + */ + value() { + const { value } = this.instruction; + + return value !== undefined ? value : null; + } + + /** + * Override the value of the instruction. + * + * @param {*} value + * @return {this} + */ + overrideValue(value) { + this.instruction.value = value; + + return this; + } + + /** + * Return the resource of the instruction. + * + * @return {Object|null} + */ + resource() { + const { resource } = this.instruction; + + return resource ? this.resources.retrieve(ResourceIdentity.unserialize(resource)) : null; + } + + /** + * Override the resource of the instruction. + * + * @param {Object|null} resource + * @return {this} + */ + overrideResource(resource) { + if (resource !== null) { + this.instruction.resource = this.resources.store(resource); + } + + return this; + } + + /** + * Set the default resource to use. + * + * @param {Object} resource + * @return {this} + */ + setDefaultResource(resource) { + this.defaultResource = resource; + + return this; + } + + /** + * Whether errors thrown by the instruction should be caught. + * + * @return {boolean} + */ + shouldCatchErrors() { + return this.instruction.shouldCatchErrors; + } + + /** + * Execute the instruction. + * + * @return {*} + */ + execute() { + const type = this.type(), + name = this.name(), + value = this.value(), + resource = this.resource() || this.defaultResource; + + let output = null; + + switch (type) { + case Instruction.TYPE_CALL: + output = this.callResourceMethod(resource, name, value || []); + break; + case Instruction.TYPE_GET: + output = resource[name]; + break; + case Instruction.TYPE_SET: + output = resource[name] = this.unserializeValue(value); + break; + } + + return output; + } + + /** + * Call a method on a resource. + * + * @protected + * @param {Object} resource + * @param {string} methodName + * @param {array} args + * @return {*} + */ + callResourceMethod(resource, methodName, args) { + try { + return resource[methodName](...args.map(this.unserializeValue.bind(this))); + } catch (error) { + if (error.message === "resource[methodName] is not a function") { + const resourceName = + resource.constructor.name === "Function" ? resource.name : resource.constructor.name; + + throw new Error(`"${resourceName}.${methodName} is not a function"`); + } + + throw error; + } + } + + /** + * Unserialize a value. + * + * @protected + * @param {Object} value + * @return {*} + */ + unserializeValue(value) { + return this.dataUnserializer.unserialize(value); + } +} + +/** + * Instruction types. + * + * @enum {instructionTypeEnum} + * @readonly + */ +Object.assign(Instruction, { + TYPE_CALL: "call", + TYPE_GET: "get", + TYPE_SET: "set", +}); diff --git a/src/Rialto/node-process/Logger.mjs b/src/Rialto/node-process/Logger.mjs new file mode 100644 index 0000000..1f235f1 --- /dev/null +++ b/src/Rialto/node-process/Logger.mjs @@ -0,0 +1,27 @@ +"use strict"; + +export default class Logger { + /** + * Add a new log to the queue. + * + * @param {string} origin + * @param {string} level + * @param {string} message + */ + static log(origin, level, message) { + this.logsQueue.push({ origin, level, message }); + } + + /** + * Flush and return the logs in the queue. + * + * @return {array} + */ + static logs() { + const logs = this.logsQueue; + this.logsQueue = []; + return logs; + } +} + +Logger.logsQueue = []; diff --git a/src/Rialto/node-process/NodeInterceptors/ConsoleInterceptor.mjs b/src/Rialto/node-process/NodeInterceptors/ConsoleInterceptor.mjs new file mode 100644 index 0000000..746ce9b --- /dev/null +++ b/src/Rialto/node-process/NodeInterceptors/ConsoleInterceptor.mjs @@ -0,0 +1,98 @@ +"use strict"; + +import StandardStreamsInterceptor from "./StandardStreamsInterceptor.mjs"; + +const SUPPORTED_CONSOLE_METHODS = { + debug: "DEBUG", + dir: "DEBUG", + dirxml: "INFO", + error: "ERROR", + info: "INFO", + log: "INFO", + table: "DEBUG", + warn: "WARNING", +}; + +export default class ConsoleInterceptor { + /** + * Log interceptor. + * + * @callback logInterceptor + * @param {string} type + * @param {string} message + */ + + /** + * Replace the global "console" object by a proxy to intercept the logs. + * + * @param {logInterceptor} interceptor + */ + static startInterceptingLogs(interceptor) { + const consoleProxy = new Proxy(console, { + get: (_, type) => this.getLoggingMethod(type, interceptor), + }); + + // Define the property instead of directly setting the property, the latter is forbidden in some environments. + Object.defineProperty(global, "console", { value: consoleProxy }); + } + + /** + * Return an appropriate logging method for the console proxy. + * + * @param {string} type + * @param {logInterceptor} interceptor + * @return {callback} + */ + static getLoggingMethod(type, interceptor) { + const originalMethod = this.originalConsole[type].bind(this.originalConsole); + + if (!this.typeIsSupported(type)) { + return originalMethod; + } + + return (...args) => { + StandardStreamsInterceptor.startInterceptingStrings((message) => interceptor(type, message)); + originalMethod(...args); + StandardStreamsInterceptor.stopInterceptingStrings(); + }; + } + + /** + * Check if the type of the log is supported. + * + * @param {*} type + * @return {boolean} + */ + static typeIsSupported(type) { + return Object.keys(SUPPORTED_CONSOLE_METHODS).includes(type); + } + + /** + * Return a log level based on the provided type. + * + * @param {*} type + * @return {string|null} + */ + static getLevelFromType(type) { + return SUPPORTED_CONSOLE_METHODS[type] || null; + } + + /** + * Format a message from a console method. + * + * @param {string} message + * @return {string} + */ + static formatMessage(message) { + // Remove terminal colors written as escape sequences + // See: https://stackoverflow.com/a/41407246/1513045 + message = message.replace(/\x1b\[\d+m/g, ""); + + // Remove the final new line + message = message.endsWith("\n") ? message.slice(0, -1) : message; + + return message; + } +} + +ConsoleInterceptor.originalConsole = console; diff --git a/src/Rialto/node-process/NodeInterceptors/StandardStreamsInterceptor.mjs b/src/Rialto/node-process/NodeInterceptors/StandardStreamsInterceptor.mjs new file mode 100644 index 0000000..cf399c3 --- /dev/null +++ b/src/Rialto/node-process/NodeInterceptors/StandardStreamsInterceptor.mjs @@ -0,0 +1,51 @@ +"use strict"; + +import _ from "lodash"; + +const STANDARD_STREAMS = [process.stdout, process.stderr]; + +export default class StandardStreamsInterceptor { + /** + * Standard stream interceptor. + * + * @callback standardStreamInterceptor + * @param {string} message + */ + + /** + * Start intercepting data written on the standard streams. + * + * @param {standardStreamInterceptor} interceptor + */ + static startInterceptingStrings(interceptor) { + STANDARD_STREAMS.forEach((stream) => { + this.standardStreamWriters.set(stream, stream.write); + + stream.write = (chunk, encoding, callback) => { + if (_.isString(chunk)) { + interceptor(chunk); + + if (_.isFunction(callback)) { + callback(); + } + + return true; + } + + return stream.write(chunk, encoding, callback); + }; + }); + } + + /** + * Stop intercepting data written on the standard streams. + */ + static stopInterceptingStrings() { + STANDARD_STREAMS.forEach((stream) => { + stream.write = this.standardStreamWriters.get(stream); + this.standardStreamWriters.delete(stream); + }); + } +} + +StandardStreamsInterceptor.standardStreamWriters = new Map(); diff --git a/src/Rialto/node-process/Server.mjs b/src/Rialto/node-process/Server.mjs new file mode 100644 index 0000000..b968297 --- /dev/null +++ b/src/Rialto/node-process/Server.mjs @@ -0,0 +1,67 @@ +"use strict"; + +import net from "net"; +import Connection from "./Connection.mjs"; + +/** + * Listen for new socket connections. + */ +export default class Server { + /** + * Constructor. + * + * @param {ConnectionDelegate} connectionDelegate + * @param {Object} options + */ + constructor(connectionDelegate, options = {}) { + this.options = options; + + this.started = this.start(connectionDelegate); + + this.resetIdleTimeout(); + } + + /** + * Start the server and listen for new connections. + * + * @param {ConnectionDelegate} connectionDelegate + * @return {Promise} + */ + start(connectionDelegate) { + this.server = net.createServer((socket) => { + const connection = new Connection(socket, connectionDelegate); + + connection.on("activity", () => this.resetIdleTimeout()); + + this.resetIdleTimeout(); + }); + + return new Promise((resolve) => { + this.server.listen(() => resolve()); + }); + } + + /** + * Write the listening port on the process output. + */ + writePortToOutput() { + process.stdout.write(`${this.server.address().port}\n`); + } + + /** + * Reset the idle timeout. + * + * @protected + */ + resetIdleTimeout() { + clearTimeout(this.idleTimer); + + const { idle_timeout: idleTimeout } = this.options; + + if (idleTimeout !== null) { + this.idleTimer = setTimeout(() => { + throw new Error("The idle timeout has been reached."); + }, idleTimeout * 1000); + } + } +} diff --git a/src/Rialto/node-process/index.mjs b/src/Rialto/node-process/index.mjs new file mode 100644 index 0000000..54bdcf6 --- /dev/null +++ b/src/Rialto/node-process/index.mjs @@ -0,0 +1,5 @@ +"use strict"; + +import ConnectionDelegate from "./ConnectionDelegate.mjs"; + +export default ConnectionDelegate; diff --git a/src/Rialto/node-process/serve.mjs b/src/Rialto/node-process/serve.mjs new file mode 100644 index 0000000..49b35ff --- /dev/null +++ b/src/Rialto/node-process/serve.mjs @@ -0,0 +1,41 @@ +"use strict"; + +import DataSerializer from "./Data/Serializer.mjs"; +import Logger from "./Logger.mjs"; +import ConsoleInterceptor from "./NodeInterceptors/ConsoleInterceptor.mjs"; +import Server from "./Server.mjs"; + +// Throw unhandled rejections +process.on("unhandledRejection", (error) => { + throw error; +}); + +// Output the exceptions in JSON format +process.on("uncaughtException", (error) => { + process.stderr.write(JSON.stringify(DataSerializer.serializeError(error))); + process.exit(1); +}); + +// Retrieve the options +let options = process.argv.slice(2)[1]; +options = options !== undefined ? JSON.parse(options) : {}; + +// Intercept Node logs +if (options.log_node_console === true) { + ConsoleInterceptor.startInterceptingLogs((type, originalMessage) => { + const level = ConsoleInterceptor.getLevelFromType(type); + const message = ConsoleInterceptor.formatMessage(originalMessage); + + Logger.log("Node", level, message); + }); +} + +// Instanciate the custom connection delegate +const connectionDelegateClass = (await import(`file://${process.argv[2]}`)).default; +const connectionDelegate = new connectionDelegateClass(options); + +// Start the server with the custom connection delegate +const server = new Server(connectionDelegate, options); + +// Write the server port to the process output +server.started.then(() => server.writePortToOutput()); diff --git a/src/Traits/AliasesEvaluationMethods.php b/src/Traits/AliasesEvaluationMethods.php index 7d8de58..53ae732 100644 --- a/src/Traits/AliasesEvaluationMethods.php +++ b/src/Traits/AliasesEvaluationMethods.php @@ -1,13 +1,12 @@ __call('$', $arguments); } - public function querySelectorAll(...$arguments) + /** + * @return ElementHandle[] + */ + public function querySelectorAll(...$arguments): array { return $this->__call('$$', $arguments); } - public function querySelectorXPath(...$arguments) + /** + * @return ElementHandle[] + */ + public function querySelectorXPath(...$arguments): array { - return $this->__call('$x', $arguments); + /** + * Puppeteer 22.0.0 removed the Page.$x function (@see https://github.com/puppeteer/puppeteer/pull/11782) + */ + if (!empty($arguments[0])) { + if (str_starts_with($arguments[0], '//')) { + $arguments[0] = '.' . $arguments[0]; + } + + if (!str_starts_with($arguments[0], 'xpath/')) { + $arguments[0] = 'xpath/' . $arguments[0]; + } + } + return $this->__call('$$', $arguments); } } diff --git a/src/doc-generator.ts b/src/doc-generator.ts index c3f6da8..2acd24e 100644 --- a/src/doc-generator.ts +++ b/src/doc-generator.ts @@ -1,23 +1,23 @@ -import * as ts from 'typescript'; -const yargs = require('yargs/yargs'); -const { hideBin } = require('yargs/helpers'); -const callbackClass = '\\Nesk\\Rialto\\Data\\JsFunction'; +import * as ts from "typescript"; +const yargs = require("yargs/yargs"); +const { hideBin } = require("yargs/helpers"); +const callbackClass = "\\Nesk\\Rialto\\Data\\JsFunction"; -type ObjectMemberAsJson = { [key: string]: string; } +type ObjectMemberAsJson = { [key: string]: string }; type ObjectMembersAsJson = { - properties: ObjectMemberAsJson, - getters: ObjectMemberAsJson, - methods: ObjectMemberAsJson, -} + properties: ObjectMemberAsJson; + getters: ObjectMemberAsJson; + methods: ObjectMemberAsJson; +}; -type ClassAsJson = { name: string } & ObjectMembersAsJson -type MemberContext = 'class'|'literal' -type TypeContext = 'methodReturn' +type ClassAsJson = { name: string } & ObjectMembersAsJson; +type MemberContext = "class" | "literal"; +type TypeContext = "methodReturn"; class TypeNotSupportedError extends Error { constructor(message?: string) { - super(message || 'This type is currently not supported.'); + super(message || "This type is currently not supported."); } } @@ -33,33 +33,33 @@ class JsSupportChecker { class PhpSupportChecker { supportsMethodName(methodName: string): boolean { - return !methodName.includes('$'); + return !methodName.includes("$"); } } interface DocumentationFormatter { - formatProperty(name: string, type: string, context: MemberContext): string - formatGetter(name: string, type: string): string - formatAnonymousFunction(parameters: string, returnType: string): string - formatFunction(name: string, parameters: string, returnType: string): string - formatParameter(name: string, type: string, isVariadic: boolean, isOptional: boolean): string - formatTypeAny(): string - formatTypeUnknown(): string - formatTypeVoid(): string - formatTypeUndefined(): string - formatTypeNull(): string - formatTypeBoolean(): string - formatTypeNumber(): string - formatTypeString(): string - formatTypeReference(type: string): string - formatGeneric(parentType: string, argumentTypes: string[], context?: TypeContext): string - formatQualifiedName(left: string, right: string): string - formatIndexedAccessType(object: string, index: string): string - formatLiteralType(value: string): string - formatUnion(types: string[]): string - formatIntersection(types: string[]): string - formatObject(members: string[]): string - formatArray(type: string): string + formatProperty(name: string, type: string, context: MemberContext): string; + formatGetter(name: string, type: string): string; + formatAnonymousFunction(parameters: string, returnType: string): string; + formatFunction(name: string, parameters: string, returnType: string): string; + formatParameter(name: string, type: string, isVariadic: boolean, isOptional: boolean): string; + formatTypeAny(): string; + formatTypeUnknown(): string; + formatTypeVoid(): string; + formatTypeUndefined(): string; + formatTypeNull(): string; + formatTypeBoolean(): string; + formatTypeNumber(): string; + formatTypeString(): string; + formatTypeReference(type: string): string; + formatGeneric(parentType: string, argumentTypes: string[], context?: TypeContext): string; + formatQualifiedName(left: string, right: string): string; + formatIndexedAccessType(object: string, index: string): string; + formatLiteralType(value: string): string; + formatUnion(types: string[]): string; + formatIntersection(types: string[]): string; + formatObject(members: string[]): string; + formatArray(type: string): string; } class JsDocumentationFormatter implements DocumentationFormatter { @@ -80,39 +80,39 @@ class JsDocumentationFormatter implements DocumentationFormatter { } formatParameter(name: string, type: string, isVariadic: boolean, isOptional: boolean): string { - return `${isVariadic ? '...' : ''}${name}${isOptional ? '?' : ''}: ${type}`; + return `${isVariadic ? "..." : ""}${name}${isOptional ? "?" : ""}: ${type}`; } formatTypeAny(): string { - return 'any'; + return "any"; } formatTypeUnknown(): string { - return 'unknown'; + return "unknown"; } formatTypeVoid(): string { - return 'void'; + return "void"; } formatTypeUndefined(): string { - return 'undefined'; + return "undefined"; } formatTypeNull(): string { - return 'null'; + return "null"; } formatTypeBoolean(): string { - return 'boolean'; + return "boolean"; } formatTypeNumber(): string { - return 'number'; + return "number"; } formatTypeString(): string { - return 'string'; + return "string"; } formatTypeReference(type: string): string { @@ -120,7 +120,7 @@ class JsDocumentationFormatter implements DocumentationFormatter { } formatGeneric(parentType: string, argumentTypes: string[], context?: TypeContext): string { - return `${parentType}<${argumentTypes.join(', ')}>`; + return `${parentType}<${argumentTypes.join(", ")}>`; } formatQualifiedName(left: string, right: string): string { @@ -136,15 +136,15 @@ class JsDocumentationFormatter implements DocumentationFormatter { } formatUnion(types: string[]): string { - return types.join(' | '); + return types.join(" | "); } formatIntersection(types: string[]): string { - return types.join(' & '); + return types.join(" & "); } formatObject(members: string[]): string { - return `{ ${members.join(', ')} }`; + return `{ ${members.join(", ")} }`; } formatArray(type: string): string { @@ -153,7 +153,7 @@ class JsDocumentationFormatter implements DocumentationFormatter { } class PhpDocumentationFormatter implements DocumentationFormatter { - static readonly allowedJsClasses = ['Promise', 'Record', 'Map']; + static readonly allowedJsClasses = ["Promise", "Record", "Map"]; constructor( protected readonly resourcesNamespace: string, @@ -161,9 +161,7 @@ class PhpDocumentationFormatter implements DocumentationFormatter { ) {} formatProperty(name: string, type: string, context: MemberContext): string { - return context === 'class' - ? `${type} ${name}` - : `${name}: ${type}`; + return context === "class" ? `${type} ${name}` : `${name}: ${type}`; } formatGetter(name: string, type: string): string { @@ -179,54 +177,54 @@ class PhpDocumentationFormatter implements DocumentationFormatter { } formatParameter(name: string, type: string, isVariadic: boolean, isOptional: boolean): string { - if (isVariadic && type.endsWith('[]')) { + if (isVariadic && type.endsWith("[]")) { type = type.slice(0, -2); } let defaultValue; switch (type) { - case 'array' : - defaultValue = isOptional ? ' = []' : ''; + case "array": + defaultValue = isOptional ? " = []" : ""; break; default: - defaultValue = isOptional ? ' = null' : ''; + defaultValue = isOptional ? " = null" : ""; break; } - return `${type} ${isVariadic ? '...' : ''}\$${name}${defaultValue}`; + return `${type} ${isVariadic ? "..." : ""}\$${name}${defaultValue}`; } formatTypeAny(): string { - return 'mixed'; + return "mixed"; } formatTypeUnknown(): string { - return 'mixed'; + return "mixed"; } formatTypeVoid(): string { - return 'void'; + return "void"; } formatTypeUndefined(): string { - return 'null'; + return "null"; } formatTypeNull(): string { - return 'null'; + return "null"; } formatTypeBoolean(): string { - return 'bool'; + return "bool"; } formatTypeNumber(): string { - return 'float'; + return "float"; } formatTypeString(): string { - return 'string'; + return "string"; } formatTypeReference(type: string): string { @@ -242,54 +240,54 @@ class PhpDocumentationFormatter implements DocumentationFormatter { // If the type ends with "options" then convert it to an associative array if (/options$/i.test(type)) { - return 'array'; + return "array"; } // Types ending with "Fn" are always callables or strings - if (type.endsWith('Fn')) { - return this.formatUnion([callbackClass, 'string']); + if (type.endsWith("Fn")) { + return this.formatUnion([callbackClass, "string"]); } - if (type === 'Function') { + if (type === "Function") { return callbackClass; } - if (type === 'PuppeteerLifeCycleEvent') { - return 'string'; + if (type === "PuppeteerLifeCycleEvent") { + return "string"; } - if (type === 'Serializable') { - return this.formatUnion(['int', 'float', 'string', 'bool', 'null', 'array']); + if (type === "Serializable") { + return this.formatUnion(["int", "float", "string", "bool", "null", "array"]); } - if (type === 'SerializableOrJSHandle') { - return this.formatUnion([this.formatTypeReference('Serializable'), this.formatTypeReference('JSHandle')]); + if (type === "SerializableOrJSHandle") { + return this.formatUnion([this.formatTypeReference("Serializable"), this.formatTypeReference("JSHandle")]); } - if (type === 'HandleType') { - return this.formatUnion([this.formatTypeReference('JSHandle'), this.formatTypeReference('ElementHandle')]); + if (type === "HandleType") { + return this.formatUnion([this.formatTypeReference("JSHandle"), this.formatTypeReference("ElementHandle")]); } - return 'mixed'; + return "mixed"; } formatGeneric(parentType: string, argumentTypes: string[], context?: TypeContext): string { // Avoid generics with "mixed" as parent type - if (parentType === 'mixed') { - return 'mixed'; + if (parentType === "mixed") { + return "mixed"; } // Unwrap promises for method return types - if (context === 'methodReturn' && parentType === 'Promise' && argumentTypes.length === 1) { + if (context === "methodReturn" && parentType === "Promise" && argumentTypes.length === 1) { return argumentTypes[0]; } // Transform Record and Map types to associative arrays - if (['Record', 'Map'].includes(parentType) && argumentTypes.length === 2) { - parentType = 'array'; + if (["Record", "Map"].includes(parentType) && argumentTypes.length === 2) { + parentType = "array"; } - return `${parentType}|${argumentTypes.join('[]|')}[]`; + return `${parentType}|${argumentTypes.join("[]|")}[]`; } formatQualifiedName(left: string, right: string): string { @@ -306,7 +304,7 @@ class PhpDocumentationFormatter implements DocumentationFormatter { private prepareUnionOrIntersectionTypes(types: string[]): string[] { // Replace "void" type by "null" - types = types.map(type => type === 'void' ? 'null' : type) + types = types.map((type) => (type === "void" ? "null" : type)); // Remove duplicates const uniqueTypes = new Set(types); @@ -314,18 +312,18 @@ class PhpDocumentationFormatter implements DocumentationFormatter { } formatUnion(types: string[]): string { - const result = this.prepareUnionOrIntersectionTypes(types).join('|'); + const result = this.prepareUnionOrIntersectionTypes(types).join("|"); // Convert enums to string type if (/^('\w+'\|)*'\w+'$/.test(result)) { - return 'string'; + return "string"; } return result; } formatIntersection(types: string[]): string { - return this.prepareUnionOrIntersectionTypes(types).join('&'); + return this.prepareUnionOrIntersectionTypes(types).join("&"); } formatObject(members: string[]): string { @@ -355,39 +353,39 @@ class PhpStanDocumentationFormatter extends PhpDocumentationFormatter { // If the type ends with "options" then convert it to an associative array if (/options$/i.test(type)) { - return 'array'; + return "array"; } // Types ending with "Fn" are always callables or strings - if (type.endsWith('Fn')) { - return this.formatUnion([callbackClass, 'callable', 'string']); + if (type.endsWith("Fn")) { + return this.formatUnion([callbackClass, "callable", "string"]); } - if (type === 'Function') { - return this.formatUnion(['callable', callbackClass]); + if (type === "Function") { + return this.formatUnion(["callable", callbackClass]); } - if (type === 'PuppeteerLifeCycleEvent') { - return 'string'; + if (type === "PuppeteerLifeCycleEvent") { + return "string"; } - if (type === 'Serializable') { - return this.formatUnion(['int', 'float', 'string', 'bool', 'null', 'array']); + if (type === "Serializable") { + return this.formatUnion(["int", "float", "string", "bool", "null", "array"]); } - if (type === 'SerializableOrJSHandle') { - return this.formatUnion([this.formatTypeReference('Serializable'), this.formatTypeReference('JSHandle')]); + if (type === "SerializableOrJSHandle") { + return this.formatUnion([this.formatTypeReference("Serializable"), this.formatTypeReference("JSHandle")]); } - if (type === 'HandleType') { - return this.formatUnion([this.formatTypeReference('JSHandle'), this.formatTypeReference('ElementHandle')]); + if (type === "HandleType") { + return this.formatUnion([this.formatTypeReference("JSHandle"), this.formatTypeReference("ElementHandle")]); } - return 'mixed'; + return "mixed"; } formatObject(members: string[]): string { - return `array{ ${members.join(', ')} }`; + return `array{ ${members.join(", ")} }`; } } @@ -397,10 +395,7 @@ class DocumentationGenerator { private readonly formatter: DocumentationFormatter, ) {} - private hasModifierForNode( - node: ts.Node, - modifier: ts.KeywordSyntaxKind - ): boolean { + private hasModifierForNode(node: ts.Node, modifier: ts.KeywordSyntaxKind): boolean { if (!node.modifiers) { return false; } @@ -410,14 +405,18 @@ class DocumentationGenerator { private isNodeAccessible(node: ts.Node): boolean { // @ts-ignore - if (node.name && (this.getNamedDeclarationAsString(node).startsWith('_') || this.getNamedDeclarationAsString(node).startsWith('#'))) { + if ( + node.name && + (this.getNamedDeclarationAsString(node).startsWith("_") || + this.getNamedDeclarationAsString(node).startsWith("#")) + ) { return false; } return ( this.hasModifierForNode(node, ts.SyntaxKind.PublicKeyword) || (!this.hasModifierForNode(node, ts.SyntaxKind.ProtectedKeyword) && - !this.hasModifierForNode(node, ts.SyntaxKind.PrivateKeyword)) + !this.hasModifierForNode(node, ts.SyntaxKind.PrivateKeyword)) ); } @@ -428,7 +427,7 @@ class DocumentationGenerator { public getClassDeclarationAsJson(node: ts.ClassDeclaration): ClassAsJson { return Object.assign( { name: this.getNamedDeclarationAsString(node) }, - this.getMembersAsJson(node.members, 'class'), + this.getMembersAsJson(node.members, "class"), ); } @@ -463,40 +462,34 @@ class DocumentationGenerator { private getPropertySignatureOrDeclarationAsString( node: ts.PropertySignature | ts.PropertyDeclaration, - context: MemberContext + context: MemberContext, ): string { const type = this.getTypeNodeAsString(node.type); const name = this.getNamedDeclarationAsString(node); return this.formatter.formatProperty(name, type, context); } - private getGetAccessorDeclarationAsString( - node: ts.GetAccessorDeclaration - ): string { + private getGetAccessorDeclarationAsString(node: ts.GetAccessorDeclaration): string { const type = this.getTypeNodeAsString(node.type); const name = this.getNamedDeclarationAsString(node); return this.formatter.formatGetter(name, type); } - private getSignatureDeclarationBaseAsString( - node: ts.SignatureDeclarationBase - ): string { + private getSignatureDeclarationBaseAsString(node: ts.SignatureDeclarationBase): string { const name = node.name && this.getNamedDeclarationAsString(node); const parameters = node.parameters - .map(parameter => this.getParameterDeclarationAsString(parameter)) - .join(', '); + .map((parameter) => this.getParameterDeclarationAsString(parameter)) + .join(", "); - const returnType = this.getTypeNodeAsString(node.type, name ? 'methodReturn' : undefined); + const returnType = this.getTypeNodeAsString(node.type, name ? "methodReturn" : undefined); return name ? this.formatter.formatFunction(name, parameters, returnType) : this.formatter.formatAnonymousFunction(parameters, returnType); } - private getEmptyFunctionSignatureAsString( - node: ts.ParenthesizedTypeNode - ): string { - return this.formatter.formatAnonymousFunction(this.getTypeNodeAsString(node.type), ''); + private getEmptyFunctionSignatureAsString(node: ts.ParenthesizedTypeNode): string { + return this.formatter.formatAnonymousFunction(this.getTypeNodeAsString(node.type), ""); } private getParameterDeclarationAsString(node: ts.ParameterDeclaration): string { @@ -506,8 +499,8 @@ class DocumentationGenerator { const isOptional = node.questionToken !== undefined; //fix missing argument type in evaluate* methods. - if (name.includes('Function') && type.includes('mixed')) { - type = this.formatter.formatTypeReference('Function'); + if (name.includes("Function") && type.includes("mixed")) { + type = this.formatter.formatTypeReference("Function"); } return this.formatter.formatParameter(name, type, isVariadic, isOptional); @@ -582,9 +575,7 @@ class DocumentationGenerator { return this.formatter.formatQualifiedName(left, right); } - private getIndexedAccessTypeNodeAsString( - node: ts.IndexedAccessTypeNode - ): string { + private getIndexedAccessTypeNodeAsString(node: ts.IndexedAccessTypeNode): string { const object = this.getTypeNodeAsString(node.objectType); const index = this.getTypeNodeAsString(node.indexType); return this.formatter.formatIndexedAccessType(object, index); @@ -602,17 +593,17 @@ class DocumentationGenerator { } private getUnionTypeNodeAsString(node: ts.UnionTypeNode, context?: TypeContext): string { - const types = node.types.map(typeNode => this.getTypeNodeAsString(typeNode, context)); + const types = node.types.map((typeNode) => this.getTypeNodeAsString(typeNode, context)); return this.formatter.formatUnion(types); } private getIntersectionTypeNodeAsString(node: ts.IntersectionTypeNode, context?: TypeContext): string { - const types = node.types.map(typeNode => this.getTypeNodeAsString(typeNode, context)); + const types = node.types.map((typeNode) => this.getTypeNodeAsString(typeNode, context)); return this.formatter.formatIntersection(types); } private getTypeLiteralNodeAsString(node: ts.TypeLiteralNode): string { - const members = this.getMembersAsJson(node.members, 'literal'); + const members = this.getMembersAsJson(node.members, "literal"); const stringMembers = Object.values(members).map(Object.values); const flattenMembers = stringMembers.reduce((acc, val) => acc.concat(val), []); return this.formatter.formatObject(flattenMembers); @@ -630,28 +621,28 @@ class DocumentationGenerator { return this.getIdentifierAsString(node.name); } - private getIdentifierAsString(node: ts.Identifier|ts.PrivateIdentifier): string { + private getIdentifierAsString(node: ts.Identifier | ts.PrivateIdentifier): string { return String(node.escapedText); } } const { argv } = yargs(hideBin(process.argv)) - .command('$0 ') - .option('resources-namespace', { type: 'string', default: '' }) - .option('resources', { type: 'array', default: [] }) - .option('pretty', { type: 'boolean', default: false }) + .command("$0 ") + .option("resources-namespace", { type: "string", default: "" }) + .option("resources", { type: "array", default: [] }) + .option("pretty", { type: "boolean", default: false }); let supportChecker, formatter; switch (argv.language.toUpperCase()) { - case 'JS': + case "JS": supportChecker = new JsSupportChecker(); formatter = new JsDocumentationFormatter(); break; - case 'PHP': + case "PHP": supportChecker = new PhpSupportChecker(); formatter = new PhpDocumentationFormatter(argv.resourcesNamespace, argv.resources); break; - case 'PHPSTAN': + case "PHPSTAN": supportChecker = new PhpSupportChecker(); formatter = new PhpStanDocumentationFormatter(argv.resourcesNamespace, argv.resources); break; @@ -667,7 +658,7 @@ const classes = {}; for (const fileName of argv.definitionFiles) { const sourceFile = program.getSourceFile(fileName); - ts.forEachChild(sourceFile, node => { + ts.forEachChild(sourceFile, (node) => { if (ts.isClassDeclaration(node)) { const classAsJson = docGenerator.getClassDeclarationAsJson(node); classes[classAsJson.name] = classAsJson; diff --git a/src/get-puppeteer-version.mjs b/src/get-puppeteer-version.mjs index d03f36d..d37658c 100644 --- a/src/get-puppeteer-version.mjs +++ b/src/get-puppeteer-version.mjs @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; import manifest from "puppeteer-core/package.json" with { type: "json" }; diff --git a/tests/DownloadTest.php b/tests/DownloadTest.php index 63c1bef..6bdca9a 100644 --- a/tests/DownloadTest.php +++ b/tests/DownloadTest.php @@ -1,11 +1,10 @@ browser - ->newPage() - ->goto($this->url . '/puphpeteer-logo.png'); + $page = $this->browser->newPage()->goto($this->url . '/puphpeteer-logo.png'); $base64 = $page->buffer()->toString('base64'); $imageString = base64_decode($base64); @@ -40,28 +36,27 @@ public function download_image() $this->assertTrue( mb_strlen($reference) === mb_strlen($imageString), - 'Image is not the same length after download.' + 'Image is not the same length after download.', ); } - /** - * Downloads an image and checks string length. - * - * @test - */ + // /** + // * Downloads an image and checks string length. + // */ + // #[Test] // public function download_large_image() // { // // Download the image // $page = $this->browser // ->newPage() // ->goto($this->url . '/denys-barabanov-jKcFmXCfaQ8-unsplash.jpg'); - + // // $base64 = $page->buffer()->toString('base64'); // $imageString = base64_decode($base64); - + // // // Get the reference image from resources // $reference = file_get_contents('tests/resources/denys-barabanov-jKcFmXCfaQ8-unsplash.jpg'); - + // // $this->assertTrue( // mb_strlen($reference) === mb_strlen($imageString), // 'Large image is not the same length after download.' diff --git a/tests/PuphpeteerTest.php b/tests/PuphpeteerTest.php index cd2f3eb..624a204 100644 --- a/tests/PuphpeteerTest.php +++ b/tests/PuphpeteerTest.php @@ -1,12 +1,13 @@ launchBrowser(); } - /** @test */ + #[Test] public function can_browse_website() { $response = $this->browser->newPage()->goto($this->url); @@ -30,16 +31,14 @@ public function can_browse_website() $this->assertTrue($response->ok(), 'Failed asserting that the response is successful.'); } - /** - * @test - */ + #[Test] public function can_use_method_aliases() { $page = $this->browser->newPage(); $page->goto($this->url); - $select = function($resource) { + $select = function ($resource) { $elements = [ $resource->querySelector('h1'), $resource->querySelectorAll('h1')[0], @@ -49,10 +48,10 @@ public function can_use_method_aliases() $this->assertContainsOnlyInstancesOf(ElementHandle::class, $elements); }; - $evaluate = function($resource) { + $evaluate = function ($resource) { $strings = [ - $resource->querySelectorEval('h1', JsFunction::createWithBody('return "Hello World!";')), - $resource->querySelectorAllEval('h1', JsFunction::createWithBody('return "Hello World!";')), + $resource->querySelectorEval('h1', (new JsFunction())->body('return "Hello World!";')), + $resource->querySelectorAllEval('h1', (new JsFunction())->body('return "Hello World!";')), ]; foreach ($strings as $string) { @@ -68,61 +67,70 @@ public function can_use_method_aliases() } } - /** @test */ + #[Test] public function can_evaluate_a_selection() { $page = $this->browser->newPage(); $page->goto($this->url); - $title = $page->querySelectorEval('h1', JsFunction::createWithParameters(['node']) - ->body('return node.textContent;')); + $title = $page->querySelectorEval( + 'h1', + (new JsFunction())->parameters(['node'])->body('return node.textContent;'), + ); - $titleCount = $page->querySelectorAllEval('h1', JsFunction::createWithParameters(['nodes']) - ->body('return nodes.length;')); + $titleCount = $page->querySelectorAllEval( + 'h1', + (new JsFunction())->parameters(['nodes'])->body('return nodes.length;'), + ); $this->assertEquals('Example Page', $title); $this->assertEquals(1, $titleCount); } - /** @test */ + #[Test] public function can_intercept_requests() { $page = $this->browser->newPage(); $page->setRequestInterception(true); - - $page->on('request', JsFunction::createWithParameters(['request']) - ->body('request.resourceType() === "stylesheet" ? request.abort() : request.continue()')); + $page->on( + 'request', + (new JsFunction()) + ->parameters(['request']) + ->body('request.resourceType() === "stylesheet" ? request.abort() : request.continue()'), + ); $page->goto($this->url); - $backgroundColor = $page->querySelectorEval('h1', JsFunction::createWithParameters(['node']) - ->body('return getComputedStyle(node).textTransform')); + $backgroundColor = $page->querySelectorEval( + 'h1', + (new JsFunction())->parameters(['node'])->body('return getComputedStyle(node).textTransform'), + ); $this->assertNotEquals('lowercase', $backgroundColor); } /** - * @test - * @dataProvider resourceProvider * @dontPopulateProperties browser */ + #[Test] + #[DataProvider('resourceProvider')] public function check_all_resources_are_supported(string $name) { $incompleteTest = false; $resourceInstantiator = new ResourceInstantiator($this->browserOptions, $this->url); - $resource = $resourceInstantiator->{$name}(new Puppeteer, $this->browserOptions); + $resource = $resourceInstantiator->{$name}(new Puppeteer(), $this->browserOptions); if ($resource instanceof UntestableResource) { $incompleteTest = true; - } else if ($resource instanceof RiskyResource) { + } elseif ($resource instanceof RiskyResource) { if (!empty($resource->exception())) { $incompleteTest = true; } else { try { $this->assertInstanceOf("Nesk\\Puphpeteer\\Resources\\$name", $resource->value()); - } catch (ExpectationFailedException $exception) { + } catch (ExpectationFailedException) { $incompleteTest = true; } } @@ -130,29 +138,29 @@ public function check_all_resources_are_supported(string $name) $this->assertInstanceOf("Nesk\\Puphpeteer\\Resources\\$name", $resource); } - if (!$incompleteTest) return; + if (!$incompleteTest) { + return; + } - $reason = "The \"$name\" resource has not been tested properly, probably" - ." for a good reason but you might want to have a look: \n\n "; + $reason = + "The \"$name\" resource has not been tested properly, probably" . + " for a good reason but you might want to have a look: \n\n "; if ($resource instanceof UntestableResource) { $reason .= "\e[33mMarked as untestable.\e[0m"; + } elseif (!empty(($exception = $resource->exception()))) { + $reason .= "\e[31mMarked as risky because of a Node error: {$exception->getMessage()}\e[0m"; } else { - if (!empty($exception = $resource->exception())) { - $reason .= "\e[31mMarked as risky because of a Node error: {$exception->getMessage()}\e[0m"; - } else { - $value = print_r($resource->value(), true); - $reason .= "\e[31mMarked as risky because of an unexpected value: $value\e[0m"; - } + $value = print_r($resource->value(), true); + $reason .= "\e[31mMarked as risky because of an unexpected value: $value\e[0m"; } $this->markTestIncomplete($reason); } - public function resourceProvider(): \Generator + public static function resourceProvider(): Generator { $resourceNames = (new ResourceInstantiator([], ''))->getResourceNames(); - foreach ($resourceNames as $name) { yield [$name]; } @@ -161,10 +169,11 @@ public function resourceProvider(): \Generator private function createBrowserLogger(callable $onBrowserLog): LoggerInterface { $logger = $this->createMock(LoggerInterface::class); - $logger->expects(self::atLeastOnce()) + $logger + ->expects(self::atLeastOnce()) ->method('log') ->willReturnCallback(function (string $level, string $message) use ($onBrowserLog) { - if (\strpos($message, "Received a Browser log:") === 0) { + if (str_starts_with($message, 'Received a Browser log:')) { $onBrowserLog(); } @@ -175,14 +184,14 @@ private function createBrowserLogger(callable $onBrowserLog): LoggerInterface } /** - * @test * @dontPopulateProperties browser */ + #[Test] public function browser_console_calls_are_logged_if_enabled() { - $browserLogOccured = false; - $logger = $this->createBrowserLogger(function () use (&$browserLogOccured) { - $browserLogOccured = true; + $browserLogOccurred = false; + $logger = $this->createBrowserLogger(function () use (&$browserLogOccurred) { + $browserLogOccurred = true; }); $puppeteer = new Puppeteer([ @@ -193,18 +202,18 @@ public function browser_console_calls_are_logged_if_enabled() $this->browser = $puppeteer->launch($this->browserOptions); $this->browser->pages()[0]->goto($this->url); - static::assertTrue($browserLogOccured); + static::assertTrue($browserLogOccurred); } /** - * @test * @dontPopulateProperties browser */ + #[Test] public function browser_console_calls_are_not_logged_if_disabled() { - $browserLogOccured = false; - $logger = $this->createBrowserLogger(function () use (&$browserLogOccured) { - $browserLogOccured = true; + $browserLogOccurred = false; + $logger = $this->createBrowserLogger(function () use (&$browserLogOccurred) { + $browserLogOccurred = true; }); $puppeteer = new Puppeteer([ @@ -215,6 +224,6 @@ public function browser_console_calls_are_not_logged_if_disabled() $this->browser = $puppeteer->launch($this->browserOptions); $this->browser->pages()[0]->goto($this->url); - static::assertFalse($browserLogOccured); + static::assertFalse($browserLogOccurred); } } diff --git a/tests/ResourceInstantiator.php b/tests/ResourceInstantiator.php index 7f2b937..9468eec 100644 --- a/tests/ResourceInstantiator.php +++ b/tests/ResourceInstantiator.php @@ -1,90 +1,51 @@ resources = [ - 'Accessibility' => function ($puppeteer) { - return $this->Page($puppeteer)->accessibility; - }, - 'Browser' => function ($puppeteer) { - return $puppeteer->launch($this->browserOptions); - }, - 'BrowserContext' => function ($puppeteer) { - return $this->Browser($puppeteer)->createIncognitoBrowserContext(); - }, - 'CDPSession' => function ($puppeteer) { - return $this->Target($puppeteer)->createCDPSession(); - }, - 'ConsoleMessage' => function () { - return new UntestableResource; - }, - 'Coverage' => function ($puppeteer) { - return $this->Page($puppeteer)->coverage; - }, - 'Dialog' => function () { - return new UntestableResource; - }, - 'ElementHandle' => function ($puppeteer) { - return $this->Page($puppeteer)->querySelector('body'); - }, - 'EventEmitter' => function ($puppeteer) { - return $puppeteer->launch($this->browserOptions); - }, - 'ExecutionContext' => function ($puppeteer) { - return $this->Frame($puppeteer)->executionContext(); - }, - 'FileChooser' => function () { - return new UntestableResource; - }, - 'Frame' => function ($puppeteer) { - return $this->Page($puppeteer)->mainFrame(); - }, - 'HTTPRequest' => function ($puppeteer) { - return $this->HTTPResponse($puppeteer)->request(); - }, - 'HTTPResponse' => function ($puppeteer) { - return $this->Page($puppeteer)->goto($this->url); - }, - 'JSHandle' => function ($puppeteer) { - return $this->Page($puppeteer)->evaluateHandle(JsFunction::createWithBody('window')); - }, - 'Keyboard' => function ($puppeteer) { - return $this->Page($puppeteer)->keyboard; - }, - 'Mouse' => function ($puppeteer) { - return $this->Page($puppeteer)->mouse; - }, - 'Page' => function ($puppeteer) { - return $this->Browser($puppeteer)->newPage(); - }, - 'SecurityDetails' => function ($puppeteer) { - return new RiskyResource(function () use ($puppeteer) { - return $this->Page($puppeteer)->goto('https://example.com')->securityDetails(); - }); - }, - 'Target' => function ($puppeteer) { - return $this->Page($puppeteer)->target(); - }, - 'TimeoutError' => function () { - return new UntestableResource; - }, - 'Touchscreen' => function ($puppeteer) { - return $this->Page($puppeteer)->touchscreen; - }, - 'Tracing' => function ($puppeteer) { - return $this->Page($puppeteer)->tracing; - }, + 'Accessibility' => fn($puppeteer) => $this->Page($puppeteer)->accessibility, + 'Browser' => fn($puppeteer) => $puppeteer->launch($this->browserOptions), + /** + * Puppeteer v22.0.0 renamed createIncognitoBrowserContext to createBrowserContext + * (@see https://github.com/puppeteer/puppeteer/issues/11834) + */ + 'BrowserContext' => fn($puppeteer) => $this->Browser($puppeteer)->createBrowserContext(), + 'CDPSession' => fn($puppeteer) => $this->Target($puppeteer)->createCDPSession(), + 'ConsoleMessage' => fn() => new UntestableResource(), + 'Coverage' => fn($puppeteer) => $this->Page($puppeteer)->coverage, + 'Dialog' => fn() => new UntestableResource(), + 'ElementHandle' => fn($puppeteer) => $this->Page($puppeteer)->querySelector('body'), + 'EventEmitter' => fn($puppeteer) => $puppeteer->launch($this->browserOptions), + /** + * Puppeteer v17.0.0 removed ExecutionContext (@see https://github.com/puppeteer/puppeteer/pull/8844) + * + * //'ExecutionContext' => fn($puppeteer) => $this->Frame($puppeteer)->executionContext(), + */ + 'FileChooser' => fn() => new UntestableResource(), + 'Frame' => fn($puppeteer) => $this->Page($puppeteer)->mainFrame(), + 'HTTPRequest' => fn($puppeteer) => $this->HTTPResponse($puppeteer)->request(), + 'HTTPResponse' => fn($puppeteer) => $this->Page($puppeteer)->goto($this->url), + 'JSHandle' => fn($puppeteer) => $this->Page($puppeteer)->evaluateHandle((new JsFunction())->body('window')), + 'Keyboard' => fn($puppeteer) => $this->Page($puppeteer)->keyboard, + 'Mouse' => fn($puppeteer) => $this->Page($puppeteer)->mouse, + 'Page' => fn($puppeteer) => $this->Browser($puppeteer)->newPage(), + 'SecurityDetails' => fn($puppeteer) => new RiskyResource( + fn() => $this->Page($puppeteer)->goto('https://example.com/')->securityDetails(), + ), + 'Target' => fn($puppeteer) => $this->Page($puppeteer)->target(), + 'TimeoutError' => fn() => new UntestableResource(), + 'Touchscreen' => fn($puppeteer) => $this->Page($puppeteer)->touchscreen, + 'Tracing' => fn($puppeteer) => $this->Page($puppeteer)->tracing, 'WebWorker' => function ($puppeteer) { $page = $this->Page($puppeteer); $page->goto($this->url, ['waitUntil' => 'networkidle0']); diff --git a/tests/Rialto/Implementation/FsConnectionDelegate.mjs b/tests/Rialto/Implementation/FsConnectionDelegate.mjs new file mode 100644 index 0000000..f8ce543 --- /dev/null +++ b/tests/Rialto/Implementation/FsConnectionDelegate.mjs @@ -0,0 +1,51 @@ +"use strict"; + +import fs from "fs"; +import ConnectionDelegate from "../../../src/Rialto/node-process/ConnectionDelegate.mjs"; + +/** + * Handle the requests of a connection to control the "fs" module. + */ +export default class FsConnectionDelegate extends ConnectionDelegate { + async handleInstruction(instruction, responseHandler, errorHandler) { + instruction.setDefaultResource(this.extendFsModule(fs)); + + let value = null; + + try { + value = await instruction.execute(); + } catch (error) { + if (instruction.shouldCatchErrors()) { + return errorHandler(error); + } + + throw error; + } + + responseHandler(value); + } + + extendFsModule(fs) { + fs.multipleStatSync = (...paths) => paths.map(fs.statSync); + + fs.multipleResourcesIsFile = (resources) => resources.map((resource) => resource.isFile()); + + fs.getHeavyPayloadWithNonAsciiChars = () => { + let payload = ""; + + for (let i = 0; i < 1024; i++) { + payload += "a"; + } + + return `😘${payload}😘`; + }; + + fs.wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + fs.runCallback = (cb) => cb(fs); + + fs.getOption = (name) => this.options[name]; + + return fs; + } +} diff --git a/tests/Rialto/Implementation/FsProcessDelegate.php b/tests/Rialto/Implementation/FsProcessDelegate.php new file mode 100644 index 0000000..3259534 --- /dev/null +++ b/tests/Rialto/Implementation/FsProcessDelegate.php @@ -0,0 +1,19 @@ +dirPath = realpath(__DIR__ . '/resources'); + $this->filePath = "{$this->dirPath}/file"; + + $this->fs = $this->canPopulateProperty('fs') ? new FsWithProcessDelegation() : null; + } + + protected function tearDown(): void + { + $this->fs = null; + } + + #[Test] + public function can_call_method_and_get_its_return_value() + { + $content = $this->fs->readFileSync($this->filePath, 'utf8'); + + $this->assertEquals('Hello world!', $content); + } + + #[Test] + public function can_get_property() + { + $constants = $this->fs->constants; + + $this->assertIsArray($constants); + } + + #[Test] + public function can_set_property() + { + $this->fs->foo = 'bar'; + $this->assertEquals('bar', $this->fs->foo); + + $this->fs->foo = null; + $this->assertNull($this->fs->foo); + } + + #[Test] + public function can_return_basic_resources() + { + $resource = $this->fs->readFileSync($this->filePath); + + $this->assertInstanceOf(BasicResource::class, $resource); + } + + #[Test] + public function can_return_specific_resources() + { + $resource = $this->fs->statSync($this->filePath); + + $this->assertInstanceOf(Stats::class, $resource); + } + + #[Test] + public function can_cast_resources_to_string() + { + $resource = $this->fs->statSync($this->filePath); + + $this->assertEquals('[object Object]', (string) $resource); + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + public function can_omit_process_delegation() + { + $this->fs = new FsWithoutProcessDelegation(); + + $resource = $this->fs->statSync($this->filePath); + + $this->assertInstanceOf(BasicResource::class, $resource); + $this->assertNotInstanceOf(Stats::class, $resource); + } + + #[Test] + public function can_use_nested_resources() + { + $resources = $this->fs->multipleStatSync($this->dirPath, $this->filePath); + + $this->assertCount(2, $resources); + $this->assertContainsOnlyInstancesOf(Stats::class, $resources); + + $isFile = $this->fs->multipleResourcesIsFile($resources); + + $this->assertFalse($isFile[0]); + $this->assertTrue($isFile[1]); + } + + #[Test] + public function can_use_multiple_resources_without_confusion() + { + $dirStats = $this->fs->statSync($this->dirPath); + $fileStats = $this->fs->statSync($this->filePath); + + $this->assertInstanceOf(Stats::class, $dirStats); + $this->assertInstanceOf(Stats::class, $fileStats); + + $this->assertTrue($dirStats->isDirectory()); + $this->assertTrue($fileStats->isFile()); + } + + #[Test] + public function can_return_multiple_times_the_same_resource() + { + $stats1 = $this->fs->Stats; + $stats2 = $this->fs->Stats; + + $this->assertEquals($stats1, $stats2); + } + + #[Test] + #[Group('js-functions')] + public function can_use_js_functions_with_a_body() + { + $functions = [ + $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () { + return JsFunction::create("return 'Simple callback';"); + }), + JsFunction::createWithBody("return 'Simple callback';"), + ]; + + foreach ($functions as $function) { + $value = $this->fs->runCallback($function); + $this->assertEquals('Simple callback', $value); + } + } + + #[Test] + #[Group('js-functions')] + public function can_use_js_functions_with_parameters() + { + $functions = [ + $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () { + return JsFunction::create( + ['fs'], + " + return 'Callback using arguments: ' + fs.constructor.name; + ", + ); + }), + JsFunction::createWithParameters(['fs'])->body( + "return 'Callback using arguments: ' + fs.constructor.name;", + ), + ]; + + foreach ($functions as $function) { + $value = $this->fs->runCallback($function); + $this->assertEquals('Callback using arguments: Object', $value); + } + } + + #[Test] + #[Group('js-functions')] + public function can_use_js_functions_with_scope() + { + $functions = [ + $this->ignoreUserDeprecation(self::JS_FUNCTION_CREATE_DEPRECATION_PATTERN, function () { + return JsFunction::create( + " + return 'Callback using scope: ' + foo; + ", + ['foo' => 'bar'], + ); + }), + JsFunction::createWithScope(['foo' => 'bar'])->body("return 'Callback using scope: ' + foo;"), + ]; + + foreach ($functions as $function) { + $value = $this->fs->runCallback($function); + $this->assertEquals('Callback using scope: bar', $value); + } + } + + #[Test] + #[Group('js-functions')] + public function can_use_resources_in_js_functions() + { + $fileStats = $this->fs->statSync($this->filePath); + + $functions = [ + JsFunction::createWithParameters(['fs', 'fileStats' => $fileStats])->body('return fileStats.isFile();'), + JsFunction::createWithScope(['fileStats' => $fileStats])->body('return fileStats.isFile();'), + ]; + + foreach ($functions as $function) { + $isFile = $this->fs->runCallback($function); + $this->assertTrue($isFile); + } + } + + #[Test] + #[Group('js-functions')] + public function can_use_async_with_js_functions() + { + $function = JsFunction::createWithAsync()->body(" + await Promise.resolve(); + return true; + "); + + $this->assertTrue($this->fs->runCallback($function)); + + $function = $function->async(false); + + $this->expectException(Node\FatalException::class); + $this->expectExceptionMessage('await is only valid in async function'); + + $this->fs->runCallback($function); + } + + #[Test] + #[Group('js-functions')] + public function js_functions_are_sync_by_default() + { + $function = JsFunction::createWithBody('await null'); + + $this->expectException(Node\FatalException::class); + $this->expectExceptionMessage('await is only valid in async function'); + + $this->fs->runCallback($function); + } + + #[Test] + public function can_receive_heavy_payloads_with_non_ascii_chars() + { + $payload = $this->fs->getHeavyPayloadWithNonAsciiChars(); + + $this->assertStringStartsWith('😘', $payload); + $this->assertStringEndsWith('😘', $payload); + } + + #[Test] + public function node_crash_throws_a_fatal_exception() + { + self::expectException(FatalException::class); + self::expectExceptionMessage('Object.__inexistantMethod__ is not a function'); + $this->fs->__inexistantMethod__(); + } + + #[Test] + public function can_catch_errors() + { + self::expectException(Exception::class); + self::expectExceptionMessage('Object.__inexistantMethod__ is not a function'); + $this->fs->tryCatch->__inexistantMethod__(); + } + + #[Test] + public function catching_a_node_exception_doesnt_catch_fatal_exceptions() + { + self::expectException(FatalException::class); + self::expectExceptionMessage('Object.__inexistantMethod__ is not a function'); + try { + $this->fs->__inexistantMethod__(); + } catch (Node\Exception $exception) { + // + } + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + public function in_debug_mode_node_exceptions_contain_stack_trace_in_message() + { + $this->fs = new FsWithProcessDelegation(['debug' => true]); + + $regex = '/\n\nError: "Object\.__inexistantMethod__ is not a function"\n\s+at /'; + + try { + $this->fs->tryCatch->__inexistantMethod__(); + } catch (Node\Exception $exception) { + $this->assertMatchesRegularExpression($regex, $exception->getMessage()); + } + + try { + $this->fs->__inexistantMethod__(); + } catch (Node\FatalException $exception) { + $this->assertMatchesRegularExpression($regex, $exception->getMessage()); + } + } + + #[Test] + public function node_current_working_directory_is_the_same_as_php() + { + $result = $this->fs->accessSync('tests/Rialto/resources/file'); + + $this->assertNull($result); + } + + #[Test] + public function executable_path_option_changes_the_process_prefix() + { + self::expectException(\Symfony\Component\Process\Exception\ProcessFailedException::class); + //self::expectExceptionMessageMatches('/Error Output:\n=+\n.*__inexistant_process__.*not found/'); + self::expectExceptionMessageMatches( + '/Error Output:\n=+\n.*__inexistant_process__.*is not recognized as an internal or external command/', + ); + new FsWithProcessDelegation(['executable_path' => '__inexistant_process__']); + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + public function idle_timeout_option_closes_node_once_timer_is_reached() + { + $this->fs = new FsWithProcessDelegation(['idle_timeout' => 0.5]); + + $this->fs->constants; + + sleep(1); + + $this->expectException(IdleTimeoutException::class); + $this->expectExceptionMessageMatches('/^The idle timeout \(0\.500 seconds\) has been exceeded/'); + + $this->fs->constants; + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + public function read_timeout_option_throws_an_exception_on_long_actions() + { + self::expectException(ReadSocketTimeoutException::class); + self::expectExceptionMessageMatches('/^The timeout \(0\.010 seconds\) has been exceeded/'); + $this->fs = new FsWithProcessDelegation(['read_timeout' => 0.01]); + + $this->fs->wait(20); + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + #[Group('logs')] + public function forbidden_options_are_removed() + { + // any, once, atLeastOnce, exactly, atMost + $matcher = $this->atLeast(2); + $this->fs = new FsWithProcessDelegation([ + 'logger' => $this->loggerMock( + $matcher, + $this->isLogLevel(), + $this->callback(function ($message) use ($matcher) { + $numberOfInvocations = $matcher->numberOfInvocations(); + if ($numberOfInvocations === 1) { + $this->assertSame('Applying options...', $message); + } elseif ($numberOfInvocations === 2) { + $this->assertSame('Options applied and merged with defaults', $message); + } + return true; + }), + $this->callback(function ($context) use ($matcher) { + $numberOfInvocations = $matcher->numberOfInvocations(); + if ($numberOfInvocations === 1) { + $this->assertArrayNotHasKey('foo', $context['options']); + $this->assertArrayHasKey('read_timeout', $context['options']); + $this->assertArrayNotHasKey('stop_timeout', $context['options']); + } elseif ($numberOfInvocations === 2) { + $this->assertArrayNotHasKey('foo', $context['options']); + $this->assertArrayHasKey('idle_timeout', $context['options']); + $this->assertArrayHasKey('read_timeout', $context['options']); + $this->assertArrayHasKey('stop_timeout', $context['options']); + } + return true; + }), + ), + 'read_timeout' => 5, + 'stop_timeout' => 0, + 'foo' => 'bar', + ]); + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + public function connection_delegate_receives_options() + { + $this->fs = new FsWithProcessDelegation([ + 'log_node_console' => true, + 'new_option' => false, + ]); + + $this->assertNull($this->fs->getOption('read_timeout')); // Assert this option is stripped by the supervisor + $this->assertTrue($this->fs->getOption('log_node_console')); + $this->assertFalse($this->fs->getOption('new_option')); + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + #[RequiresOperatingSystem("^(?!Win32|WINNT|Windows).*$")] + public function process_status_is_tracked() + { + if ((new Process(['which', 'pgrep']))->run() !== 0) { + $this->markTestSkipped('The "pgrep" command is not available.'); + } + + $oldPids = $this->getPidsForProcessName('node'); + $this->fs = new FsWithProcessDelegation(); + $newPids = $this->getPidsForProcessName('node'); + + $newNodeProcesses = array_values(array_diff($newPids, $oldPids)); + $newNodeProcessesCount = count($newNodeProcesses); + $this->assertCount( + 1, + $newNodeProcesses, + "One Node process should have been created instead of $newNodeProcessesCount. Try running again.", + ); + + $processKilled = posix_kill($newNodeProcesses[0], SIGKILL); + $this->assertTrue($processKilled); + + \usleep(10_000); # To make sure the process had enough time to be killed. + $this->expectException(ProcessUnexpectedlyTerminatedException::class); + $this->expectExceptionMessage('The process has been unexpectedly terminated.'); + + $this->fs->foo; + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + #[Group('logs')] + public function logger_is_used_when_provided() + { + $this->fs = new FsWithProcessDelegation([ + 'logger' => $this->loggerMock($this->atLeastOnce(), $this->isLogLevel(), $this->isType('string')), + ]); + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + #[Group('logs')] + public function node_console_calls_are_logged() + { + $consoleMessage = 'Hello World!'; + $setups = [[false, "Received data on stdout: $consoleMessage"], [true, "Received a Node log: $consoleMessage"]]; + foreach ($setups as [$logNodeConsole, $startsWith]) { + $matcher = $this->atLeast(6); + $this->fs = new FsWithProcessDelegation([ + 'log_node_console' => $logNodeConsole, + 'logger' => $this->loggerMock( + $matcher, + $this->isLogLevel(), + $this->callback(function ($message) use ($matcher, $startsWith) { + $numberOfInvocations = $matcher->numberOfInvocations(); + if ($numberOfInvocations === 6) { + $this->assertSame($startsWith, rtrim($message, "\r\n")); + } + return true; + }), + ), + ]); + + $this->fs->runCallback(JsFunction::createWithBody("console.log('$consoleMessage')")); + } + } + + /** + * @dontPopulateProperties fs + */ + #[Test] + #[Group('logs')] + public function delayed_node_console_calls_and_data_on_standard_streams_are_logged() + { + $matcher = $this->atLeast(8); + $this->fs = new FsWithProcessDelegation([ + 'log_node_console' => true, + 'logger' => $this->loggerMock( + $matcher, + $this->isLogLevel(), + $this->callback(function ($message) use ($matcher) { + $numberOfInvocations = $matcher->numberOfInvocations(); + if ($numberOfInvocations === 7) { + $this->assertStringStartsWith('Received data on stdout: Hello', $message); + } elseif ($numberOfInvocations === 8) { + $this->assertStringStartsWith('Received a Node log:', $message); + } + return true; + }), + ), + ]); + + $javascript = <<<'JSFUNC' + setTimeout(() => { + process.stdout.write('Hello Stdout!'); + console.log('Hello Console!'); + }); + JSFUNC; + $this->fs->runCallback(JsFunction::createWithBody($javascript)); + + \usleep(10_000); // 10ms, to be sure the delayed instructions just above are executed. + $this->fs = null; + } +} diff --git a/tests/Rialto/TestCase.php b/tests/Rialto/TestCase.php new file mode 100644 index 0000000..aaefd7c --- /dev/null +++ b/tests/Rialto/TestCase.php @@ -0,0 +1,98 @@ +name()); + $docComment = $testMethod->getDocComment(); + + if (!empty($docComment) && preg_match('/@dontPopulateProperties (.*)/', $docComment, $matches)) { + $this->dontPopulateProperties = array_values(array_filter(explode(' ', $matches[1]))); + } + } + + public function canPopulateProperty(string $propertyName): bool + { + return !in_array($propertyName, $this->dontPopulateProperties); + } + + public function ignoreUserDeprecation(string $messagePattern, callable $callback) + { + set_error_handler(function (int $errorNumber, string $errorString, string $errorFile, int $errorLine) use ( + $messagePattern, + ) { + if ($errorNumber !== E_USER_DEPRECATED || preg_match($messagePattern, $errorString) !== 1) { + (new ErrorHandler(true, true, true, true))($errorNumber, $errorString, $errorFile, $errorLine); + } + }); + + $value = $callback(); + + restore_error_handler(); + + return $value; + } + + public function getPidsForProcessName(string $processName) + { + $pgrep = new Process(['pgrep', $processName]); + $pgrep->run(); + + $pids = explode("\n", $pgrep->getOutput()); + $pids = array_filter($pids, fn($pid) => !empty($pid)); + $pids = array_map(fn($pid) => (int) $pid, $pids); + + return $pids; + } + + public function loggerMock(InvocationOrder|array $expectations) + { + $loggerMock = $this->getMockBuilder(Logger::class) + ->setConstructorArgs(['rialto']) + ->onlyMethods(['log']) + ->getMock(); + if ($expectations instanceof InvocationOrder) { + $expectations = [func_get_args()]; + } + + foreach ($expectations as $with) { + $matcher = array_shift($with); + $loggerMock->expects($matcher)->method('log')->with(...$with); + } + + return $loggerMock; + } + + public function isLogLevel(): Callback + { + $psrLogLevels = (new \ReflectionClass(LogLevel::class))->getConstants(); + $monologLevels = (new \ReflectionClass(Logger::class))->getConstants(); + $monologLevels = array_intersect_key($monologLevels, $psrLogLevels); + + return $this->callback(function ($level) use ($psrLogLevels, $monologLevels) { + if (is_string($level)) { + return in_array($level, $psrLogLevels, true); + } elseif (is_int($level)) { + return in_array($level, $monologLevels, true); + } + + return false; + }); + } +} diff --git a/tests/Rialto/resources/file b/tests/Rialto/resources/file new file mode 100644 index 0000000..6769dd6 --- /dev/null +++ b/tests/Rialto/resources/file @@ -0,0 +1 @@ +Hello world! \ No newline at end of file diff --git a/tests/RiskyResource.php b/tests/RiskyResource.php index 8a851c2..648af03 100644 --- a/tests/RiskyResource.php +++ b/tests/RiskyResource.php @@ -1,15 +1,18 @@ value = $resourceRetriever(); } catch (NodeFatalException $exception) { @@ -17,11 +20,13 @@ public function __construct(callable $resourceRetriever) { } } - public function value() { + public function value() + { return $this->value; } - public function exception(): ?NodeFatalException { + public function exception(): ?NodeFatalException + { return $this->exception; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 610487f..3858f6b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,29 +1,39 @@ getName())[0] ?? ''; - $testMethod = new \ReflectionMethod($this, $methodName); + $methodName = explode(' ', $this->name())[0] ?? ''; + $testMethod = new ReflectionMethod($this, $methodName); $docComment = $testMethod->getDocComment(); - if (preg_match('/@dontPopulateProperties (.*)/', $docComment, $matches)) { + if (!empty($docComment) && preg_match('/@dontPopulateProperties (.*)/', $docComment, $matches)) { $this->dontPopulateProperties = array_values(array_filter(explode(' ', $matches[1]))); } } @@ -50,10 +60,6 @@ public function tearDown(): void protected function serveResources(): void { // Spin up a local server to deliver the resources. - $this->host = '127.0.0.1:8089'; - $this->url = "http://{$this->host}"; - $this->serverDir = __DIR__.'/resources'; - $this->servingProcess = new Process(['php', '-S', $this->host, '-t', $this->serverDir]); $this->servingProcess->start(); } @@ -64,17 +70,18 @@ protected function serveResources(): void protected function launchBrowser(): void { /** - * Chrome doesn't support Linux sandbox on many CI environments + * Chrome does not support Linux sandbox on many CI environments * * @see: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-fails-due-to-sandbox-issues */ $this->browserOptions = [ - 'args' => ['--no-sandbox', '--disable-setuid-sandbox'], + 'channel' => 'chrome', 'headless' => 'new', + 'args' => ['--no-sandbox', '--disable-setuid-sandbox'], ]; if ($this->canPopulateProperty('browser')) { - $this->browser = (new Puppeteer)->launch($this->browserOptions); + $this->browser = (new Puppeteer())->launch($this->browserOptions); } } @@ -83,7 +90,8 @@ public function canPopulateProperty(string $propertyName): bool return !in_array($propertyName, $this->dontPopulateProperties); } - public function isLogLevel(): Callback { + public function isLogLevel(): Callback + { $psrLogLevels = (new ReflectionClass(LogLevel::class))->getConstants(); $monologLevels = (new ReflectionClass(Logger::class))->getConstants(); $monologLevels = array_intersect_key($monologLevels, $psrLogLevels); @@ -91,7 +99,7 @@ public function isLogLevel(): Callback { return $this->callback(function ($level) use ($psrLogLevels, $monologLevels) { if (is_string($level)) { return in_array($level, $psrLogLevels, true); - } else if (is_int($level)) { + } elseif (is_int($level)) { return in_array($level, $monologLevels, true); } diff --git a/tests/UntestableResource.php b/tests/UntestableResource.php index 7ac98db..fd51fd0 100644 --- a/tests/UntestableResource.php +++ b/tests/UntestableResource.php @@ -1,8 +1,7 @@ + Document - +

Example Page

diff --git a/tests/resources/worker.mjs b/tests/resources/worker.js similarity index 100% rename from tests/resources/worker.mjs rename to tests/resources/worker.js