wp-browser
wp-browser copied to clipboard
[FEATURE REQUEST] WP Mail Module
Is your feature request related to a problem? Please describe. I want to be able to assert that an email has been sent and interact with the sent emails.
Describe the solution you'd like
A WPMail that can see email messages, subjects, and click on links.
Alternatives considered Setting up some of the existing more general purpose mail modules codeception provides.
** Additional context** I like how simple it is to use a custom module instead of having to worry about setting up an SMTP server of some kind.
I've developed a helper module for the that we use internally. Do you think this would be valuable to have in wp-browser? If so, I can clean it up and add a PR.
<?php
namespace Helper;
use Codeception\Exception\ModuleConfigException;
use Codeception\Lib\Interfaces\DependsOnModule;
use Codeception\Lib\Interfaces\Web;
use Codeception\Module;
use Codeception\TestInterface;
use tad\WPBrowser\Filesystem\Utils;
class WPMail extends Module implements DependsOnModule {
protected $requiredFields = [ 'wpRootFolder' ];
/**
* @var array
*/
protected $config = [
'mu-plugins' => '/wp-content/mu-plugins',
'uploads' => '/wp-content/uploads',
];
protected $prettyName = 'WPMail';
/** @var Web */
protected $web;
protected $pluginFile;
protected $dataFile;
public function _initialize() {
$this->ensureWpRootFolder();
$this->ensureOptionalPaths();
$this->pluginFile = $this->config['mu-plugins'] . 'wp-mail.php';
$this->dataFile = $this->config['uploads'] . 'wp-mail-codeception.json';
}
public function _beforeSuite( $settings = [] ) {
copy( codecept_data_dir( 'mu-plugins/wp-mail.php' ), $this->pluginFile );
}
public function _afterSuite() {
if ( file_exists( $this->pluginFile ) ) {
unlink( $this->pluginFile );
}
}
public function _before( TestInterface $test ) {
$this->ensureOptionalPaths( false );
if ( file_exists( $this->dataFile ) ) {
unlink( $this->dataFile );
}
}
public function _inject( Web $web ) {
$this->web = $web;
}
public function _depends() {
return [ Web::class => 'Browser required' ];
}
/**
* @When I click :link in the last email
*
* @param string $link
* @param string $context
*/
public function clickInLastEmailMessage( $link, $context = '' ) {
$helper = new HtmlMatcherHelper( $this->web, $this->getLatestEmail()['message'] );
$helper->click( $link, $context );
}
/**
* @Then I see :subject in the last email subject
*
* @param string $subject
*/
public function seeInLastEmailSubject( $subject ) {
$email = $this->getLatestEmail();
$this->assertStringContainsString( $subject, $email['subject'] );
}
/**
* @Then I see :contents in the last email message
*
* @param string $message
*/
public function seeInLastEmailMessage( $message ) {
$email = $this->getLatestEmail();
$helper = new HtmlMatcherHelper( $this->web, $email['message'] );
$helper->see( $message );
}
/**
* Checks the WordPress root folder exists and is a WordPress root folder.
*
* @throws \Codeception\Exception\ModuleConfigException if the WordPress root folder does not exist
* or is not a valid WordPress root folder.
*/
protected function ensureWpRootFolder() {
$wpRoot = $this->config['wpRootFolder'];
if ( ! is_dir( $wpRoot ) ) {
$wpRoot = codecept_root_dir( Utils::unleadslashit( $wpRoot ) );
}
$message = "[{$wpRoot}] is not a valid WordPress root folder.\n\nThe WordPress root folder is the one that "
. "contains the 'wp-load.php' file.";
if ( ! ( is_dir( $wpRoot ) && is_readable( $wpRoot ) && is_writable( $wpRoot ) ) ) {
throw new ModuleConfigException( __CLASS__, $message );
}
if ( ! file_exists( $wpRoot . '/wp-load.php' ) ) {
throw new ModuleConfigException( __CLASS__, $message );
}
$this->config['wpRootFolder'] = Utils::untrailslashit( $wpRoot ) . DIRECTORY_SEPARATOR;
}
/**
* Sets and checks that the optional paths, if set, are actually valid.
*
* @param bool $check Whether to check the paths for existence or not.
*
* @throws \Codeception\Exception\ModuleConfigException If one of the paths does not exist.
*/
protected function ensureOptionalPaths( $check = true ) {
$optionalPaths = [
'mu-plugins' => [
'mustExist' => true,
'default' => '/wp-content/mu-plugins',
],
'uploads' => [
'mustExist' => true,
'default' => '/wp-content/uploads',
],
];
$wpRoot = Utils::untrailslashit( $this->config['wpRootFolder'] );
foreach ( $optionalPaths as $configKey => $info ) {
if ( empty( $this->config[ $configKey ] ) ) {
$path = $info['default'];
} else {
$path = $this->config[ $configKey ];
}
if ( ! is_dir( $path ) || ( $configKey === 'mu-plugins' && ! is_dir( dirname( $path ) ) ) ) {
$path = Utils::unleadslashit( str_replace( $wpRoot, '', $path ) );
$absolutePath = $wpRoot . DIRECTORY_SEPARATOR . $path;
} else {
$absolutePath = $path;
}
if ( $check ) {
$mustExistAndIsNotDir = $info['mustExist'] && ! is_dir( $absolutePath );
if ( $mustExistAndIsNotDir ) {
if ( ! mkdir( $absolutePath, 0777, true ) && ! is_dir( $absolutePath ) ) {
throw new ModuleConfigException(
__CLASS__,
"The {$configKey} config path [{$path}] does not exist."
);
}
}
}
$this->config[ $configKey ] = Utils::untrailslashit( $absolutePath ) . DIRECTORY_SEPARATOR;
}
}
/**
* Get the latest email sent.
*
* @return array
*/
protected function getLatestEmail() {
foreach ( array_reverse( $this->getEmails() ) as $email ) {
return $email;
}
$this->fail( 'No email found.' );
}
/**
* Get a list of all the emails sent during the test.
*
* @return array
*/
protected function getEmails() {
$file = $this->dataFile;
$emails = [];
if ( file_exists( $file ) && ( $contents = file_get_contents( $file ) ) && $json = json_decode( $contents, true ) ) {
$emails = $json;
}
$this->debugSection( 'Emails', "\n" . json_encode( $emails, JSON_PRETTY_PRINT ) );
return $emails;
}
protected function debugSection( $title, $message ) {
parent::debugSection( $this->prettyName . ' ' . $title, $message );
}
}
<?php
/*
* Plugin Name: WP Mail Codeception Helper
*/
function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
$file = wp_upload_dir()['basedir'] . '/wp-mail-codeception.json';
if ( ! file_exists( $file ) ) {
touch( $file );
}
$json = [];
$contents = file_get_contents( $file );
if ( $contents && $decoded = json_decode( $contents, true ) ) {
$json = $decoded;
}
$json[] = compact( 'to', 'subject', 'message', 'headers', 'attachments' );
file_put_contents( $file, json_encode( $json ) );
return true;
}
<?php
namespace Helper;
use Codeception\Exception\ElementNotFound;
use Codeception\Exception\MalformedLocatorException;
use Codeception\Exception\ModuleException;
use Codeception\Exception\TestRuntimeException;
use Codeception\Lib\Interfaces\Web;
use Codeception\PHPUnit\Constraint\Crawler as CrawlerConstraint;
use Codeception\PHPUnit\Constraint\CrawlerNot as CrawlerNotConstraint;
use Codeception\PHPUnit\Constraint\Page as PageConstraint;
use Codeception\Util\Locator;
use Codeception\Util\Shared\Asserts;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\DomCrawler\Link;
class HtmlMatcherHelper {
use Asserts;
/** @var Web */
private $web;
/** @var string */
protected $html;
/** @var string */
protected $baseUrl;
/** @var Crawler */
protected $crawler;
/**
* HtmlMatcherHelper constructor.
*
* @param Web $web
* @param string $html
* @param string $baseUrl
*/
public function __construct( Web $web, $html, $baseUrl = 'http://email/' ) {
$this->web = $web;
$this->html = $html;
$this->baseUrl = $baseUrl;
$this->crawler = new Crawler( $this->html, $this->baseUrl );
}
/**
* @return Crawler
* @throws ModuleException
*/
private function getCrawler() {
if ( ! $this->crawler ) {
throw new ModuleException( $this, 'Crawler is null. Perhaps you forgot to call "amOnPage"?' );
}
return $this->crawler;
}
public function click( $link, $context = null ) {
if ( $context ) {
$this->crawler = $this->match( $context );
}
if ( is_array( $link ) ) {
$this->clickByLocator( $link );
return;
}
$anchor = $this->strictMatch( [ 'link' => $link ] );
if ( ! count( $anchor ) ) {
$anchor = $this->getCrawler()->selectLink( $link );
}
if ( count( $anchor ) ) {
$this->openHrefFromDomNode( $anchor->getNode( 0 ) );
return;
}
try {
$this->clickByLocator( $link );
} catch ( MalformedLocatorException $e ) {
throw new ElementNotFound( "name=$link", "'$link' is invalid CSS and XPath selector and Link or Button" );
}
}
/**
* @param $link
*
* @return bool
*/
protected function clickByLocator( $link ) {
$nodes = $this->match( $link );
if ( ! $nodes->count() ) {
throw new ElementNotFound( $link, 'Link or Button by name or CSS or XPath' );
}
foreach ( $nodes as $node ) {
$tag = $node->tagName;
if ( $tag === 'a' ) {
$this->openHrefFromDomNode( $node );
return true;
}
}
}
private function openHrefFromDomNode( \DOMNode $node ) {
$link = new Link( $node, $this->getBaseUrl() );
$this->web->amOnPage( preg_replace( '/#.*/', '', $link->getUri() ) );
}
private function getBaseUrl() {
return $this->baseUrl;
}
public function see( $text, $selector = null ) {
if ( ! $selector ) {
$this->assertPageContains( $text );
return;
}
$nodes = $this->match( $selector );
$this->assertDomContains( $nodes, $this->stringifySelector( $selector ), $text );
}
public function dontSee( $text, $selector = null ) {
if ( ! $selector ) {
$this->assertPageNotContains( $text );
return;
}
$nodes = $this->match( $selector );
$this->assertDomNotContains( $nodes, $this->stringifySelector( $selector ), $text );
}
public function seeInSource( $raw ) {
$this->assertPageSourceContains( $raw );
}
public function dontSeeInSource( $raw ) {
$this->assertPageSourceNotContains( $raw );
}
public function seeLink( $text, $url = null ) {
$crawler = $this->getCrawler()->selectLink( $text );
if ( $crawler->count() === 0 ) {
$this->fail( "No links containing text '$text' were found in page " . $this->_getCurrentUri() );
}
if ( $url ) {
$crawler = $crawler->filterXPath( sprintf( './/a[substring(@href, string-length(@href) - string-length(%1$s) + 1)=%1$s]', Crawler::xpathLiteral( $url ) ) );
if ( $crawler->count() === 0 ) {
$this->fail( "No links containing text '$text' and URL '$url' were found in page " . $this->_getCurrentUri() );
}
}
$this->assertTrue( true );
}
public function dontSeeLink( $text, $url = null ) {
$crawler = $this->getCrawler()->selectLink( $text );
if ( ! $url ) {
if ( $crawler->count() > 0 ) {
$this->fail( "Link containing text '$text' was found in page " . $this->_getCurrentUri() );
}
}
$crawler = $crawler->filterXPath( sprintf( './/a[substring(@href, string-length(@href) - string-length(%1$s) + 1)=%1$s]', Crawler::xpathLiteral( $url ) ) );
if ( $crawler->count() > 0 ) {
$this->fail( "Link containing text '$text' and URL '$url' was found in page " . $this->_getCurrentUri() );
}
}
/**
* @return string
* @throws ModuleException
*/
public function _getCurrentUri() {
return $this->baseUrl;
}
/**
* @param $selector
*
* @return Crawler
*/
protected function match( $selector ) {
if ( is_array( $selector ) ) {
return $this->strictMatch( $selector );
}
if ( Locator::isCSS( $selector ) ) {
return $this->getCrawler()->filter( $selector );
}
if ( Locator::isXPath( $selector ) ) {
return $this->getCrawler()->filterXPath( $selector );
}
throw new MalformedLocatorException( $selector, 'XPath or CSS' );
}
/**
* @param array $by
*
* @return Crawler
* @throws TestRuntimeException
*/
protected function strictMatch( array $by ) {
$type = key( $by );
$locator = $by[ $type ];
switch ( $type ) {
case 'id':
return $this->filterByCSS( "#$locator" );
case 'name':
return $this->filterByXPath( sprintf( './/*[@name=%s]', Crawler::xpathLiteral( $locator ) ) );
case 'css':
return $this->filterByCSS( $locator );
case 'xpath':
return $this->filterByXPath( $locator );
case 'link':
return $this->filterByXPath( sprintf( './/a[.=%s or contains(./@title, %s)]', Crawler::xpathLiteral( $locator ), Crawler::xpathLiteral( $locator ) ) );
case 'class':
return $this->filterByCSS( ".$locator" );
default:
throw new TestRuntimeException(
"Locator type '$by' is not defined. Use either: xpath, css, id, link, class, name"
);
}
}
protected function filterByAttributes( Crawler $nodes, array $attributes ) {
foreach ( $attributes as $attr => $val ) {
$nodes = $nodes->reduce(
function ( Crawler $node ) use ( $attr, $val ) {
return $node->attr( $attr ) == $val;
}
);
}
return $nodes;
}
public function grabTextFrom( $cssOrXPathOrRegex ) {
if ( @preg_match( $cssOrXPathOrRegex, $this->html, $matches ) ) {
return $matches[1];
}
$nodes = $this->match( $cssOrXPathOrRegex );
if ( $nodes->count() ) {
return $nodes->first()->text();
}
throw new ElementNotFound( $cssOrXPathOrRegex, 'Element that matches CSS or XPath or Regex' );
}
public function grabAttributeFrom( $cssOrXpath, $attribute ) {
$nodes = $this->match( $cssOrXpath );
if ( ! $nodes->count() ) {
throw new ElementNotFound( $cssOrXpath, 'Element that matches CSS or XPath' );
}
return $nodes->first()->attr( $attribute );
}
public function grabMultiple( $cssOrXpath, $attribute = null ) {
$result = [];
$nodes = $this->match( $cssOrXpath );
foreach ( $nodes as $node ) {
if ( $attribute !== null ) {
$result[] = $node->getAttribute( $attribute );
} else {
$result[] = $node->textContent;
}
}
return $result;
}
/**
* Grabs current page source code.
*
* @return string Current page source code.
* @throws ModuleException if no page was opened.
*
*/
public function grabPageSource() {
return $this->html;
}
private function stringifySelector( $selector ) {
if ( is_array( $selector ) ) {
return trim( json_encode( $selector ), '{}' );
}
return $selector;
}
public function seeElement( $selector, $attributes = [] ) {
$nodes = $this->match( $selector );
$selector = $this->stringifySelector( $selector );
if ( ! empty( $attributes ) ) {
$nodes = $this->filterByAttributes( $nodes, $attributes );
$selector .= "' with attribute(s) '" . trim( json_encode( $attributes ), '{}' );
}
$this->assertDomContains( $nodes, $selector );
}
public function dontSeeElement( $selector, $attributes = [] ) {
$nodes = $this->match( $selector );
$selector = $this->stringifySelector( $selector );
if ( ! empty( $attributes ) ) {
$nodes = $this->filterByAttributes( $nodes, $attributes );
$selector .= "' with attribute(s) '" . trim( json_encode( $attributes ), '{}' );
}
$this->assertDomNotContains( $nodes, $selector );
}
public function seeNumberOfElements( $selector, $expected ) {
$counted = count( $this->match( $selector ) );
if ( is_array( $expected ) ) {
list( $floor, $ceil ) = $expected;
$this->assertTrue(
$floor <= $counted && $ceil >= $counted,
'Number of elements counted differs from expected range'
);
} else {
$this->assertEquals(
$expected,
$counted,
'Number of elements counted differs from expected number'
);
}
}
protected function assertDomContains( $nodes, $message, $text = '' ) {
$constraint = new CrawlerConstraint( $text, $this->_getCurrentUri() );
$this->assertThat( $nodes, $constraint, $message );
}
protected function assertDomNotContains( $nodes, $message, $text = '' ) {
$constraint = new CrawlerNotConstraint( $text, $this->_getCurrentUri() );
$this->assertThat( $nodes, $constraint, $message );
}
protected function assertPageContains( $needle, $message = '' ) {
$constraint = new PageConstraint( $needle, $this->_getCurrentUri() );
$this->assertThat(
$this->getNormalizedResponseContent(),
$constraint,
$message
);
}
protected function assertPageNotContains( $needle, $message = '' ) {
$constraint = new PageConstraint( $needle, $this->_getCurrentUri() );
$this->assertThatItsNot(
$this->getNormalizedResponseContent(),
$constraint,
$message
);
}
protected function assertPageSourceContains( $needle, $message = '' ) {
$constraint = new PageConstraint( $needle, $this->_getCurrentUri() );
$this->assertThat(
$this->html,
$constraint,
$message
);
}
protected function assertPageSourceNotContains( $needle, $message = '' ) {
$constraint = new PageConstraint( $needle, $this->_getCurrentUri() );
$this->assertThatItsNot(
$this->html,
$constraint,
$message
);
}
/**
* @param $locator
*
* @return Crawler
*/
protected function filterByCSS( $locator ) {
if ( ! Locator::isCSS( $locator ) ) {
throw new MalformedLocatorException( $locator, 'css' );
}
return $this->getCrawler()->filter( $locator );
}
/**
* @param $locator
*
* @return Crawler
*/
protected function filterByXPath( $locator ) {
if ( ! Locator::isXPath( $locator ) ) {
throw new MalformedLocatorException( $locator, 'xpath' );
}
return $this->getCrawler()->filterXPath( $locator );
}
/**
* @return string
*/
protected function getNormalizedResponseContent() {
$content = $this->html;
// Since strip_tags has problems with JS code that contains
// an <= operator the script tags have to be removed manually first.
$content = preg_replace( '#<script(.*?)>(.*?)</script>#is', '', $content );
$content = strip_tags( $content );
$content = html_entity_decode( $content, ENT_QUOTES );
$content = str_replace( "\n", ' ', $content );
$content = preg_replace( '/\s{2,}/', ' ', $content );
return $content;
}
protected function debugSection( $title, $message ) {
if ( is_array( $message ) || is_object( $message ) ) {
$message = stripslashes( json_encode( $message ) );
}
codecept_debug( "[$title] $message" );
}
}
@TimothyBJacobs this would indeed be useful!
Thanks for sharing the code, I feel some duplication could be avoided by:
- using the
WPFilesystemmodule for the WordPress directory checks and the file operations; I would make this an internal dependency, not required in the suite configuration file. - I've got mixed feelings about the module dependency on a Web one; I see how you use it in the context of acceptance tests, and understand its advantages, yet I can also think of someone just willing to assert on email contents w/o clicking on anything. As a first reaction I would "split" the module in two: a "base"
WPMailone and one, extending it with web capabilities, e.g.WPWebMail.
I will get back to this later, and comment further, trying to explain myself better.
I think this is a great idea!
Thanks @lucatume! I agree, splitting the module to have a WPWebMail would make a lot of sense.
I don't totally understand what you mean by using WPFilesystem as an internal dependency.
Ignore the Stalebot notice, this is a good idea and I will tackle it.
Still a good idea, targeting version 4
This issue is marked stale because it has been open 20 days with no activity. Remove stale label or comment or this will be closed in 5 days.
This issue was closed because it has been stalled for 5 days with no activity.