lucatume / wp-browser

The easy and reliable way to test WordPress with Codeception. 10 years of proven success.
https://wpbrowser.wptestkit.dev/
MIT License
603 stars 88 forks source link

[FEATURE REQUEST] WP Mail Module #321

Closed TimothyBJacobs closed 7 months ago

TimothyBJacobs commented 4 years ago

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" );
    }
}
lucatume commented 4 years ago

@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 commented 4 years ago

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

I think this is a great idea!

TimothyBJacobs commented 4 years ago

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.

lucatume commented 4 years ago

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

lucatume commented 1 year ago

Still a good idea, targeting version 4

github-actions[bot] commented 7 months ago

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] commented 7 months ago

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