-
-
Notifications
You must be signed in to change notification settings - Fork 14
[#272]: Assert no JS errors. #405
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,192 @@ | ||||||||||||||||||||||||||||||||
<?php | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
declare(strict_types=1); | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
namespace DrevOps\BehatSteps; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
use Behat\Behat\Hook\Scope\AfterScenarioScope; | ||||||||||||||||||||||||||||||||
use Behat\Behat\Hook\Scope\AfterStepScope; | ||||||||||||||||||||||||||||||||
use Behat\Behat\Hook\Scope\BeforeScenarioScope; | ||||||||||||||||||||||||||||||||
use Behat\Behat\Hook\Scope\BeforeStepScope; | ||||||||||||||||||||||||||||||||
use Behat\Mink\Exception\DriverException; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
trait JavascriptTrait | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
private array $jsErrorRegistry = []; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private array $monitoredStepPatterns = [ | ||||||||||||||||||||||||||||||||
'/I visit /i', | ||||||||||||||||||||||||||||||||
'/I go to /i', | ||||||||||||||||||||||||||||||||
'/I open /i', | ||||||||||||||||||||||||||||||||
'/I click /i', | ||||||||||||||||||||||||||||||||
'/I press /i', | ||||||||||||||||||||||||||||||||
]; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
public function addJsMonitorStepPattern(string $regex): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
$this->monitoredStepPatterns[] = $regex; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
/** @BeforeScenario @javascript */ | ||||||||||||||||||||||||||||||||
public function jsCollectorBeforeScenario(BeforeScenarioScope $scope): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
$this->jsErrorRegistry = []; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
/** @BeforeStep @javascript */ | ||||||||||||||||||||||||||||||||
public function jsCollectorBeforeStep(BeforeStepScope $scope): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
if (!$this->stepNeedsMonitoring($scope->getStep()->getText())) { | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
if (!$this->driverIsJsCapable()) { | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
$this->injectJsCollector(); | ||||||||||||||||||||||||||||||||
$this->safeExecute('window.__behatJsErrors = [];'); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
/** @AfterStep @javascript */ | ||||||||||||||||||||||||||||||||
public function jsCollectorAfterStep(AfterStepScope $scope): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
if (!$this->stepNeedsMonitoring($scope->getStep()->getText())) { | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
if (!$this->driverIsJsCapable()) { | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
$this->injectJsCollector(); | ||||||||||||||||||||||||||||||||
$this->harvestErrorsIntoRegistry(); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
/** @AfterScenario @no-js-errors @javascript */ | ||||||||||||||||||||||||||||||||
public function jsCollectorAssertScenarioClean(AfterScenarioScope $scope): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
$this->harvestErrorsIntoRegistry(); | ||||||||||||||||||||||||||||||||
if ($this->flattenRegistry() !== []) { | ||||||||||||||||||||||||||||||||
throw new \RuntimeException( | ||||||||||||||||||||||||||||||||
"JavaScript errors detected during scenario:\n" . | ||||||||||||||||||||||||||||||||
json_encode($this->jsErrorRegistry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) | ||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
/** @Then /^no JavaScript errors should have occurred on this page$/ */ | ||||||||||||||||||||||||||||||||
public function assertNoJsErrorsOnCurrentPage(): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
if (!$this->driverIsJsCapable()) { | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
$this->harvestErrorsIntoRegistry(); | ||||||||||||||||||||||||||||||||
$path = $this->currentPath(); | ||||||||||||||||||||||||||||||||
$errors = $this->jsErrorRegistry[$path] ?? []; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if ($errors !== []) { | ||||||||||||||||||||||||||||||||
throw new \RuntimeException( | ||||||||||||||||||||||||||||||||
"JavaScript errors on page $path:\n" . | ||||||||||||||||||||||||||||||||
json_encode($errors, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) | ||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private function injectJsCollector(): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
$js = <<<'JS' | ||||||||||||||||||||||||||||||||
(function () { | ||||||||||||||||||||||||||||||||
if (window.__behatJsCollectorInstalled) { return; } | ||||||||||||||||||||||||||||||||
window.__behatJsCollectorInstalled = true; | ||||||||||||||||||||||||||||||||
window.__behatJsErrors = window.__behatJsErrors || []; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
function push(msg) { | ||||||||||||||||||||||||||||||||
try { window.__behatJsErrors.push(String(msg)); } catch (e) {} | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
var origConsoleError = console.error; | ||||||||||||||||||||||||||||||||
console.error = function () { | ||||||||||||||||||||||||||||||||
push([].slice.call(arguments).join(' ')); | ||||||||||||||||||||||||||||||||
return origConsoleError.apply(console, arguments); | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
var origOnError = window.onerror; | ||||||||||||||||||||||||||||||||
window.onerror = function (msg, src, line, col) { | ||||||||||||||||||||||||||||||||
push(msg + ' @ ' + src + ':' + line + ':' + col); | ||||||||||||||||||||||||||||||||
if (origOnError) { return origOnError.apply(this, arguments); } | ||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
window.addEventListener('error', function (ev) { | ||||||||||||||||||||||||||||||||
if (ev.target && (ev.target.src || ev.target.href)) { | ||||||||||||||||||||||||||||||||
push('Resource error: ' + (ev.target.src || ev.target.href)); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
}, true); | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
window.addEventListener('unhandledrejection', function (ev) { | ||||||||||||||||||||||||||||||||
push('Unhandled rejection: ' + (ev.reason && ev.reason.message | ||||||||||||||||||||||||||||||||
? ev.reason.message | ||||||||||||||||||||||||||||||||
: ev.reason)); | ||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||
}()); | ||||||||||||||||||||||||||||||||
JS; | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
$this->safeExecute($js); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private function harvestErrorsIntoRegistry(): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||||
$errors = $this->getSession() | ||||||||||||||||||||||||||||||||
->evaluateScript('return window.__behatJsErrors || [];'); | ||||||||||||||||||||||||||||||||
} catch (DriverException $e) { | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if (!is_array($errors) || $errors === []) { | ||||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
$path = $this->currentPath(); | ||||||||||||||||||||||||||||||||
$this->jsErrorRegistry[$path] = array_merge( | ||||||||||||||||||||||||||||||||
$this->jsErrorRegistry[$path] ?? [], | ||||||||||||||||||||||||||||||||
$errors | ||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
$this->safeExecute('window.__behatJsErrors = [];'); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private function stepNeedsMonitoring(string $text): bool | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
foreach ($this->monitoredStepPatterns as $rx) { | ||||||||||||||||||||||||||||||||
if (preg_match($rx, $text)) { | ||||||||||||||||||||||||||||||||
return true; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
return false; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private function flattenRegistry(): array | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
return array_merge(...array_values($this->jsErrorRegistry ?: [[]])); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private function currentPath(): string | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
$url = $this->getSession()->getCurrentUrl(); | ||||||||||||||||||||||||||||||||
return parse_url($url, PHP_URL_PATH) ?: $url; | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private function driverIsJsCapable(): bool | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
return method_exists($this, 'getSession') | ||||||||||||||||||||||||||||||||
&& $this->getSession()->getDriver()->supportsJavascript(); | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
private function safeExecute(string $script): void | ||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||||
$this->getSession()->executeScript($script); | ||||||||||||||||||||||||||||||||
} catch (DriverException $e) { | ||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
Comment on lines
+185
to
+190
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Swallowing driver exceptions hides valuable diagnostics
Consider at least logging the message at } catch (DriverException $e) {
- }
+ // Do not break the test flow, but surface the problem for debugging.
+ error_log(sprintf('[Behat JS-collector] DriverException: %s', $e->getMessage()));
+ } 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
Feature: Check that JavascriptTrait works | ||
As a Behat Steps library developer | ||
I want to provide tools to track and verify JavaScript errors | ||
So that users can ensure their pages are error-free | ||
|
||
@javascript @no-js-errors | ||
Scenario: Assert no JavaScript errors when none exist | ||
When I go to "/" | ||
|
||
|
||
@javascript @no-js-errors | ||
Scenario: Assert JavaScript error is detected when one exists | ||
When I visit "/sites/default/files/relative.html" | ||
And the element "#js-error-trigger" should be displayed | ||
And I click on the element "#js-error-trigger" | ||
|
||
Comment on lines
+11
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The second scenario intentionally triggers a JavaScript error, but the Remove the tag (or replace it with an explicit step that asserts the presence of errors): - @javascript @no-js-errors
+ @javascript
Scenario: Assert JavaScript error is detected when one exists 🤖 Prompt for AI Agents
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Inject the collector before harvesting to avoid
undefined
globalharvestErrorsIntoRegistry()
assumes thatwindow.__behatJsErrors
has already been declared.If
assertNoJsErrorsOnCurrentPage()
is called on a page that has never gone through a monitored step, this variable will beundefined
, triggering a JavaScript reference error in some drivers and masking real problems.📝 Committable suggestion
🤖 Prompt for AI Agents