wp-browser icon indicating copy to clipboard operation
wp-browser copied to clipboard

[FEATURE REQUEST] WP Mail Module

Open TimothyBJacobs opened this issue 6 years ago • 4 comments

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 avatar Oct 10 '19 19:10 TimothyBJacobs

@TimothyBJacobs this would indeed be useful!

Thanks for sharing the code, I feel some duplication could be avoided by:

  1. using the WPFilesystem module for the WordPress directory checks and the file operations; I would make this an internal dependency, not required in the suite configuration file.
  2. 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" WPMail one and one, extending it with web capabilities, e.g. WPWebMail.

lucatume avatar Oct 14 '19 08:10 lucatume

I will get back to this later, and comment further, trying to explain myself better.

I think this is a great idea!

lucatume avatar Oct 14 '19 08:10 lucatume

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.

TimothyBJacobs avatar Oct 14 '19 15:10 TimothyBJacobs

Ignore the Stalebot notice, this is a good idea and I will tackle it.

lucatume avatar Nov 07 '19 08:11 lucatume

Still a good idea, targeting version 4

lucatume avatar Sep 12 '23 08:09 lucatume

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.

github-actions[bot] avatar Feb 14 '24 01:02 github-actions[bot]

This issue was closed because it has been stalled for 5 days with no activity.

github-actions[bot] avatar Feb 19 '24 01:02 github-actions[bot]