Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added getOutput method for full output data access on any request #780

Merged
merged 15 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 58 additions & 48 deletions bin/browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,48 +22,47 @@ const consoleMessages = [];

const failedRequests = [];

const getOutput = async (page, request) => {
let output;

if (request.action == 'requestsList') {
output = JSON.stringify(requestsList);

return output;
}

if (request.action == 'redirectHistory') {
output = JSON.stringify(redirectHistory);

return output;
}

if (request.action == 'consoleMessages') {
output = JSON.stringify(consoleMessages);

return output;
const pageErrors = [];

const getOutput = async (request, page = null) => {
let output = {
requestsList,
consoleMessages,
failedRequests,
redirectHistory,
pageErrors,
};

if (
![
'requestsList',
'consoleMessages',
'failedRequests',
'redirectHistory',
'pageErrors',
].includes(request.action) &&
page
) {
if (request.action == 'evaluate') {
output.result = await page.evaluate(request.options.pageFunction);
} else {
output.result = (
await page[request.action](request.options)
).toString('base64');
}
}

if (request.action == 'failedRequests') {
output = JSON.stringify(failedRequests);

return output;
if (page) {
return JSON.stringify(output);
}

if (request.action == 'evaluate') {
output = await page.evaluate(request.options.pageFunction);

return output;
}

output = await page[request.action](request.options);

return output.toString('base64');
// this will allow adding additional error info (only reach this point when there's an exception)
return output;
};

const callChrome = async pup => {
let browser;
let page;
let output;
let remoteInstance;
const puppet = (pup || require('puppeteer'));

Expand Down Expand Up @@ -119,11 +118,21 @@ const callChrome = async pup => {
request.url = contentUrl;
}

page.on('console', message => consoleMessages.push({
type: message.type(),
message: message.text(),
location: message.location()
}));
page.on('console', (message) =>
consoleMessages.push({
type: message.type(),
message: message.text(),
location: message.location(),
stackTrace: message.stackTrace(),
})
);

page.on('pageerror', (msg) => {
pageErrors.push({
name: msg.name || 'unknown error',
message: msg.message || msg.toString(),
});
});

page.on('response', function (response) {
if (response.request().isNavigationRequest() && response.request().frame().parentFrame() === null) {
Expand Down Expand Up @@ -372,12 +381,8 @@ const callChrome = async pup => {
if (request.options.waitForSelector) {
await page.waitForSelector(request.options.waitForSelector, request.options.waitForSelectorOptions ?? undefined);
}

output = await getOutput(page, request);

if (!request.options.path) {
console.log(output);
}

console.log(await getOutput(request, page));

if (remoteInstance && page) {
await page.close();
Expand All @@ -386,21 +391,26 @@ const callChrome = async pup => {
await remoteInstance ? browser.disconnect() : browser.close();
} catch (exception) {
if (browser) {

if (remoteInstance && page) {
await page.close();
}

await remoteInstance ? browser.disconnect() : browser.close();
(await remoteInstance) ? browser.disconnect() : browser.close();
}

if (exception.type === 'UnsuccessfulResponse') {
console.error(exception.status)
const output = await getOutput(request);

if (exception.type === 'UnsuccessfulResponse') {
output.exception = exception.toString();
console.error(exception.status);
console.log(JSON.stringify(output));
process.exit(3);
}

output.exception = exception.toString();

console.error(exception);
console.log(JSON.stringify(output));

if (exception.type === 'ElementNotFound') {
process.exit(2);
Expand Down
98 changes: 89 additions & 9 deletions src/Browsershot.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class Browsershot
protected $writeOptionsToFile = false;
protected $chromiumArguments = [];

/** @var array|null */
private $output = null;
freekmurze marked this conversation as resolved.
Show resolved Hide resolved

/** @var \Spatie\Image\Manipulations */
protected $imageManipulations;

Expand Down Expand Up @@ -675,42 +678,81 @@ public function evaluate(string $pageFunction): string

public function triggeredRequests(): array
{
$requests = $this->output['requestsList'] ?? null;

if ($requests) {
return $requests;
}

$command = $this->createTriggeredRequestsListCommand();
$requests = $this->callBrowser($command);

$this->cleanupTemporaryHtmlFile();

return json_decode($requests, true);
return $this->output['requestsList'] ?? null;
}

public function redirectHistory(): array
{
$command = $this->createRedirectHistoryCommand();

return json_decode($this->callBrowser($command), true);
$this->callBrowser($command);

return $this->output['redirectHistory'] ?? null;
}

/**
* @return array{type: string, message: string, location:array}
*/
public function consoleMessages(): array
{
$messages = $this->output['consoleMessages'] ?? null;

if ($messages) {
return $messages;
}

$command = $this->createConsoleMessagesCommand();
$messages = $this->callBrowser($command);

$this->callBrowser($command);

$this->cleanupTemporaryHtmlFile();

return json_decode($messages, true);
return $this->output['consoleMessages'] ?? null;
}

public function failedRequests(): array
{
$requests = $this->output['failedRequests'] ?? null;

if ($requests) {
return $requests;
}

$command = $this->createFailedRequestsCommand();
$requests = $this->callBrowser($command);

$this->callBrowser($command);

$this->cleanupTemporaryHtmlFile();

return json_decode($requests, true);
return $this->output['failedRequests'] ?? null;
}

public function pageErrors(): array
{
$pageErrors = $this->output['pageErrors'] ?? null;

if ($pageErrors) {
return $pageErrors;
}

$command = $this->createPageErrorsCommand();

$this->callBrowser($command);

$this->cleanupTemporaryHtmlFile();

return $this->output['pageErrors'] ?? null;
}

public function applyManipulations(string $imagePath)
Expand Down Expand Up @@ -825,6 +867,15 @@ public function createFailedRequestsCommand(): array
return $this->createCommand($url, 'failedRequests');
}

public function createPageErrorsCommand(): array
{
$url = $this->html
? $this->createTemporaryHtmlFile()
: $this->url;

return $this->createCommand($url, 'pageErrors');
}

public function setRemoteInstance(string $ip = '127.0.0.1', int $port = 9222): self
{
// assuring that ip and port does actually contains a value
Expand Down Expand Up @@ -933,18 +984,26 @@ protected function callBrowser(array $command): string

$process->setTimeout($this->timeout);

// clear additional output data fetched on last browser request
$this->output = null;

$process->run();

if ($process->isSuccessful()) {
return rtrim($process->getOutput());
$rawOutput = rtrim($process->getOutput());

$this->output = json_decode($rawOutput, true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This output is now an array. Let's make that a bit more structured.

Could you add a class ChromiumResult that takes that array as a constructor argument?

The class should have methods such as pageErrors(), so that stuff like

$this->output['pageErrors'] ?? null

can be replaced with (that null check can be done inside of pageErrors() itself.

$this->output->pageErrors(); 

Let's also rename $this->output to $this->chromiumResult

Maybe also

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created object as suggested.


if ($process->isSuccessful() && $this->output) {
return $this->output['result'] ?? '';
}

$this->cleanupTemporaryOptionsFile();
$process->clearOutput();
$exitCode = $process->getExitCode();
$errorOutput = $process->getErrorOutput();

if ($exitCode === 3) {
throw new UnsuccessfulResponse($this->url, $process->getErrorOutput());
throw new UnsuccessfulResponse($this->url, $errorOutput ?? '');
}

if ($exitCode === 2) {
Expand Down Expand Up @@ -1043,6 +1102,27 @@ public function initialPageNumber(int $initialPage = 1)
->pages($initialPage.'-');
}

/**
* get full output after calling the browser.
freekmurze marked this conversation as resolved.
Show resolved Hide resolved
*
* All present data is always relative to the last browser call.
*
* output is composed in the following way:
*
* - consoleMessages: messages generated with console calls
* - requestsList: list of all requests made
* - failedRequests: list of all failed requests
* - result: result of the last operation called
* - exception: string representation of the exception generated, if any
* - pageErrors: list of all page errors generated during the current command
*
* @return array|null
*/
public function getOutput()
freekmurze marked this conversation as resolved.
Show resolved Hide resolved
{
return $this->output;
}

private function isWindows()
{
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
Expand Down
59 changes: 59 additions & 0 deletions tests/BrowsershotTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1667,3 +1667,62 @@
],
], $command);
});

it('can get the body html and full output data', function () {
$instance = Browsershot::url('https://example.com');
$html = $instance->bodyHtml();

expect($html)->toContain($expectedContent = '<h1>Example Domain</h1>');

$output = $instance->getOutput();

expect($output)->not()->toBeNull();
expect($output['result'])->toContain($expectedContent);
expect($output['consoleMessages'])->toBe([]);
expect($output['requestsList'])->toMatchArray([[
'url' => 'https://example.com/',
]]);
expect($output['failedRequests'])->toBe([]);
expect($output['pageErrors'])->toBe([]);
});

it('can handle a permissions error with full output', function () {
$targetPath = '/cantWriteThisPdf.png';

$this->expectException(ProcessFailedException::class);

$instance = Browsershot::url('https://example.com');

try {
$instance->save($targetPath);
} catch (\Throwable $th) {
$output = $instance->getOutput();

expect($output)->not()->toBeNull();
expect($output['exception'])->not()->toBeEmpty();
expect($output['consoleMessages'])->toBe([]);
expect($output['requestsList'])->toMatchArray([[
'url' => 'https://example.com/',
]]);
expect($output['failedRequests'])->toBe([]);
expect($output['pageErrors'])->toBe([]);

throw $th;
}
});

it("should be able to fetch page errors with pageErrors method", function () {
$errors = Browsershot::html('<!DOCTYPE html>
<html lang="en">
<body>
<script type="text/javascript">
throw "this is not right!";
</script>
</body>
</html>')->pageErrors();

expect($errors)->toBeArray();
expect(count($errors))->toBe(1);
expect($errors[0]['name'])->toBeString();
expect($errors[0]['message'])->toBeString();
});