├── README.md ├── composer.json └── src ├── Actions ├── AddBundleProductToCart.php ├── AddSimpleProductToCart.php ├── AdminLogin.php ├── FillShippingAddress.php └── NavigateToConfigurationGroup.php ├── DataObjects ├── Address.php ├── BundleOption.php └── BundleOptionList.php ├── Exceptions └── InvalidBundleOption.php └── PaymentMethod ├── Mollie.php └── MoneyOrder.php /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Dusk components for Magento 2 2 | 3 | **Q: What is Laravel Dusk?** 4 | 5 | A: Laravel Dusk is created to write end-to-end tests for your application. This means that when running your test, an actual Chrome browser is started which you can give instructions. Click on a link, fill in fields and submit a form. 6 | 7 | **Q: Huh, Laravel is a framework, what has it to do with Magento?** 8 | 9 | A: Nothing, but Laravel Dusk is capable to visit any site that is accessible from the machine it runs on. 10 | 11 | **Q: So how do i use this?** 12 | 13 | A: You need a full Laravel setup. But where do you install this? There are a few options: 14 | 15 | - **Install it in a seperate directory next to Magento** 16 | 17 | Your folder structure would look like this: 18 | 19 | Websites 20 | - `my-magento2-store` 21 | - `my-laravel-dusk-project` 22 | 23 | This is the cleanest way, but it may be a bit hard to maintain the code as it typically requires 2 code bases. 24 | 25 | - **Install it into the Magento directory** 26 | 27 | Go to your Magento folder and run `laravel new end-to-end-tests`. 28 | 29 | - **Install a standalone Laravel Dusk version in Magento 2** 30 | 31 | I haven't tried this, but in theory it could work: 32 | https://duncan3dc.github.io/dusk/ 33 | 34 | **Q: So how does the test looks like?** 35 | 36 | A: When your Laravel install is ready to go, the next thing to do is install Laravel Dusk in your Laravel project: 37 | 38 | `composer require laravel/dusk` 39 | 40 | Create the file `test/Browser/OrderTest.php` with this contents: 41 | 42 | ``` 43 | namespace Tests\Browser; 44 | 45 | use ControlAltDelete\DuskForMagento2\DataObjects\Address; 46 | use ControlAltDelete\DuskForMagento2\Actions\AddSimpleProductToCart; 47 | use ControlAltDelete\DuskForMagento2\Actions\FillShippingAddress; 48 | use ControlAltDelete\DuskForMagento2\PaymentMethod\MoneyOrder; 49 | use Tests\DuskTestCase; 50 | use Laravel\Dusk\Browser; 51 | 52 | class OrderProductsTest extends DuskTestCase 53 | { 54 | protected function baseUrl() 55 | { 56 | return 'http://my-super-cool-project.test'; 57 | } 58 | 59 | public function testOrderProducts() 60 | { 61 | $this->browse(function (Browser $browser) { 62 | $browser->visit('/'); 63 | 64 | $browser->visit(new AddSimpleProductToCart('/fusion-backpack.html', 'Fusion Backpack'))->addToCart(2); 65 | $browser->visit(new AddSimpleProductToCart('/push-it-messenger-bag.html', 'Push It Messenger Bag'))->addToCart(3); 66 | 67 | $browser->visit('/checkout/'); 68 | 69 | $address = new Address( 70 | 'michiel@controlaltdelete.nl', // E-mail 71 | 'Michiel', // Firstname 72 | 'Gerritsen', // Lastname 73 | ['Simonszand 69'], // Array of streetlines 74 | 'Hoofddorp', // City 75 | '2134ZX', // Postcode 76 | 'NL', // Country id 77 | '0031623925470' // Postcode 78 | ); 79 | 80 | $browser->visit(new FillShippingAddress())->fillShippingAddress($address); 81 | 82 | $browser->press('Next'); 83 | 84 | $browser->waitUntilMissing('.loading-mask'); 85 | $browser->waitFor('.payment-method'); 86 | 87 | $browser->on(new MoneyOrder())->placeOrder(); 88 | 89 | $browser->assertSee('Thank you for your purchase!'); 90 | }); 91 | } 92 | } 93 | ``` 94 | 95 | And run: 96 | 97 | `php artisan dusk` 98 | 99 | When there is a successful test, check your orders. There should be a new one. 100 | 101 | **Q: My test fails, what to do?** 102 | 103 | A: There are a few thing: 104 | 105 | - For starters, try it a few times. There are a few functions in there that might have a smaller timeout than the average Magento installation requires to warm up it's caches. So it might well be the case that when you try it a few times all caches get warmed up and your test succeeds. 106 | - If that doesn't help, check the screenshot. Everytime a test fails, Dusk will create a screenshot in tests/Browser/screenshots. It also tries to give you a hint on wat went wrong, a missing element for example. 107 | - This code is written on a stock Magento 2 installation, so there might be some changes in your installation, elements with a different name for example. Try to tweak it here and there. 108 | 109 | 110 | **Note** 111 | 112 | This is mainly created as a proof of concept. It works in my environment, but there is a decent change it doesn't work right away in yours. Feel free to open an issue or pull request to improve these components. This code is tested on a stock Magento 2.2.6 and 2.3.0 with sample data. 113 | 114 | ## Components overview 115 | 116 | `Actions\AddSimpleProductToCart` 117 | 118 | Add a simple product to you shopping cart with the optional given quantity. It verifies that the product is added to the cart. 119 | 120 | **Usage** 121 | 122 | ``` 123 | $browser->visit(new AddSimpleProductToCart($relativeUrl, $name))->addToCart($quantity = null); 124 | $browser->visit(new AddSimpleProductToCart('/fusion-backpack.html', 'Fusion Backpack'))->addToCart(2); 125 | ``` 126 | 127 | When you enter a quantity it is required to have the quantity field enable on the product page. You can repeat this with different products to create shopping cart with differen items in them. 128 | 129 | --- 130 | 131 | `Actions\AddBundleProductToCart` 132 | 133 | Add a bundle product to you shopping cart with the optional given quantity. It verifies that the product is added to the cart. 134 | 135 | **Usage** 136 | 137 | ``` 138 | $optionList = new BundleOptionList([ 139 | new BundleOption(5, 9), 140 | new BundleOption(6, 13), 141 | ]); 142 | 143 | $browser->visit(new AddBundleProductToCart($relativeUrl, $name))->addToCart($optionList, $quantity = null); 144 | $browser->visit(new AddBundleProductToCart('/fusion-backpack.html', 'Fusion Backpack'))->addToCart($optionList, 2); 145 | ``` 146 | 147 | The IDs refer to the ID of the dropdown, and the ID of the option in the dropdown. 148 | 149 | --- 150 | 151 | `Actions\FillShippingAddress` 152 | 153 | Navigate to the checkout and fill the shipping address. It is required to provide an `\ControlAltDelete\DuskForMagento2\DataObjects\Address` object. 154 | 155 | **Usage** 156 | 157 | ``` 158 | $browser->visit(new FillShippingAddress())->fillShippingAddress($address); 159 | ``` 160 | 161 | --- 162 | 163 | `PaymentMethod\MoneyOrder` 164 | 165 | This selects the moneyorder payment method and clicks the *Place order* button. 166 | 167 | **Usage** 168 | 169 | ```$browser->on(new MoneyOrder())->placeOrder();``` 170 | 171 | --- 172 | 173 | `Actions\AdminLogin` 174 | 175 | Login on the admin panel. 176 | 177 | **Usage** 178 | ``` 179 | $browser->visit(new AdminLogin($frontName))->fillForm($username, $password); 180 | $browser->visit(new AdminLogin('my-custom-frontname'))->fillForm('my-username', 'my-password'); 181 | ``` 182 | 183 | --- 184 | 185 | `Actions\NavigateToConfigurationGroup` 186 | 187 | Navigate to a configuration group. Note: The capitalization is important here. *Payment methods* will fail, while *Payment Methods* will succeed. 188 | 189 | **Usage** 190 | 191 | ``` 192 | $browser->on(new NavigateToConfigurationGroup)->open($tab', $name); 193 | $browser->on(new NavigateToConfigurationGroup)->open('Sales', 'Payment Methods'); 194 | ``` 195 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "michielgerritsen/dusk-for-magento2", 3 | "description": "Ready to go Laravel Dusk components for your Magento 2 store to automated tests against them", 4 | "require-dev": { 5 | "laravel/dusk": "*" 6 | }, 7 | "license": "GPL-3.0-or-later", 8 | "authors": [ 9 | { 10 | "name": "Michiel Gerritsen", 11 | "email": "michiel@controlaltdelete.nl" 12 | } 13 | ], 14 | "require": {}, 15 | "autoload": { 16 | "psr-4": { 17 | "ControlAltDelete\\DuskForMagento2\\": "src" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Actions/AddBundleProductToCart.php: -------------------------------------------------------------------------------- 1 | url = $url; 45 | $this->name = $name; 46 | } 47 | 48 | /** 49 | * Get the URL for the page. 50 | * 51 | * @return string 52 | */ 53 | public function url() 54 | { 55 | return $this->url; 56 | } 57 | 58 | public function addToCart(Browser $browser, BundleOptionList $optionList, $quantity = null) 59 | { 60 | if (Str::contains($browser->resolver->findOrFail('')->getText(), 'OUT OF STOCK')) { 61 | throw new OutOfStockException; 62 | } 63 | 64 | $browser->pause(2000); 65 | 66 | $browser->click('#bundle-slide'); 67 | 68 | $browser->pause(2000); 69 | 70 | /** @var BundleOption $option */ 71 | foreach ($optionList->getOptions() as $option) { 72 | $this->selectOption($browser, $option); 73 | } 74 | 75 | if ($quantity) { 76 | $browser->type('qty', $quantity); 77 | } 78 | 79 | $browser->click('#product-addtocart-button'); 80 | $browser->pause(20000); 81 | 82 | $browser->waitForText('You added ' . $this->name . ' to your', 15); 83 | $browser->pause(2000); 84 | 85 | $browser->visit('/checkout/cart'); 86 | 87 | $browser->assertSee($this->name); 88 | } 89 | 90 | /** 91 | * @param Browser $browser 92 | * @param BundleOption $option 93 | */ 94 | private function selectOption(Browser $browser, BundleOption $option): void 95 | { 96 | $browser->select('bundle_option[' . $option->getOptionId() . ']', $option->getValueId()); 97 | 98 | if ($quantity = $option->getQuantity()) { 99 | $browser->type('bundle_option_qty[' . $option->getOptionId() . ']', $quantity); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Actions/AddSimpleProductToCart.php: -------------------------------------------------------------------------------- 1 | url = $url; 27 | $this->name = $name; 28 | } 29 | 30 | /** 31 | * Get the URL for the page. 32 | * 33 | * @return string 34 | */ 35 | public function url() 36 | { 37 | return $this->url; 38 | } 39 | 40 | /** 41 | * Assert that the browser is on the page. 42 | * 43 | * @param Browser $browser 44 | * @return void 45 | */ 46 | public function assert(Browser $browser) 47 | { 48 | $path = parse_url($this->url, PHP_URL_PATH); 49 | 50 | $browser->assertPathIs($path); 51 | } 52 | 53 | public function addToCart(Browser $browser, $quantity = null) 54 | { 55 | if (Str::contains($browser->resolver->findOrFail('')->getText(), 'OUT OF STOCK')) { 56 | throw new OutOfStockException; 57 | } 58 | 59 | $browser->waitForText('Add to Cart'); 60 | 61 | if ($quantity) { 62 | $browser->type('qty', $quantity); 63 | } 64 | 65 | $browser->press('Add to Cart'); 66 | 67 | $browser->waitForText('You added ' . $this->name . ' to your'); 68 | $browser->pause(2000); 69 | 70 | $browser->visit('/checkout/cart'); 71 | 72 | $browser->assertSee($this->name); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Actions/AdminLogin.php: -------------------------------------------------------------------------------- 1 | frontName = $frontName; 34 | } 35 | 36 | /** 37 | * Get the URL for the page. 38 | * 39 | * @return string 40 | */ 41 | public function url() 42 | { 43 | return '/' . $this->frontName; 44 | } 45 | 46 | public function fillForm(Browser $browser, $username, $password) 47 | { 48 | $browser->type('login[username]', $username); 49 | $browser->type('login[password]', $password); 50 | 51 | $browser->press('Sign in'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Actions/FillShippingAddress.php: -------------------------------------------------------------------------------- 1 | waitFor('#customer-email'); 34 | 35 | $browser->type('#customer-email', $address->getEmail()); 36 | $browser->type('firstname', $address->getFirstname()); 37 | $browser->type('lastname', $address->getLastname()); 38 | 39 | foreach ($address->getStreet() as $index => $value) { 40 | $browser->type('street[' . $index . ']', $value); 41 | } 42 | 43 | if ($address->getRegionId()) { 44 | $browser->select('region_id', $address->getRegionId()); 45 | } 46 | 47 | $browser->type('city', $address->getCity()); 48 | $browser->type('postcode', $address->getPostcode()); 49 | $browser->select('country_id', $address->getCountryId()); 50 | $browser->type('telephone', $address->getTelephone()); 51 | 52 | $browser->pause(5000); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Actions/NavigateToConfigurationGroup.php: -------------------------------------------------------------------------------- 1 | waitForText('STORES'); 40 | $browser->clickLink('Stores'); 41 | 42 | $browser->with('[aria-labelledby="menu-magento-backend-stores"]', function ($browser) { 43 | $browser->clickLink('Configuration'); 44 | }); 45 | 46 | $result = $browser->resolver->all('.config-nav-block'); 47 | 48 | $found = false; 49 | /** @var \Facebook\WebDriver\Remote\RemoteWebElement $element */ 50 | foreach ($result as $element) { 51 | if ($element->getText() !== strtoupper($tab)) { 52 | continue; 53 | } 54 | 55 | $found = true; 56 | if (stripos($element->getAttribute('class'), 'hide')) { 57 | $element->click(); 58 | } 59 | 60 | break; 61 | } 62 | 63 | if (!$found) { 64 | throw new \Exception('No tab named "' . $tab . '" found'); 65 | } 66 | 67 | $browser->with('.admin__page-nav-items', function ($browser) use ($name) { 68 | $browser->clickLink($name); 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/DataObjects/Address.php: -------------------------------------------------------------------------------- 1 | email = $email; 80 | $this->firstname = $firstname; 81 | $this->lastname = $lastname; 82 | $this->street = $street; 83 | $this->city = $city; 84 | $this->region_id = $region_id; 85 | $this->postcode = $postcode; 86 | $this->country_id = $country_id; 87 | $this->telephone = $telephone; 88 | } 89 | 90 | /** 91 | * @return string 92 | */ 93 | public function getEmail(): string 94 | { 95 | return $this->email; 96 | } 97 | 98 | /** 99 | * @return string 100 | */ 101 | public function getFirstname(): string 102 | { 103 | return $this->firstname; 104 | } 105 | 106 | /** 107 | * @return string 108 | */ 109 | public function getLastname(): string 110 | { 111 | return $this->lastname; 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | public function getStreet(): array 118 | { 119 | return $this->street; 120 | } 121 | 122 | /** 123 | * @return string 124 | */ 125 | public function getCity(): string 126 | { 127 | return $this->city; 128 | } 129 | 130 | /** 131 | * @return string 132 | */ 133 | public function getRegionId(): ?string 134 | { 135 | return $this->region_id; 136 | } 137 | 138 | /** 139 | * @return string 140 | */ 141 | public function getPostcode(): string 142 | { 143 | return $this->postcode; 144 | } 145 | 146 | /** 147 | * @return string 148 | */ 149 | public function getTelephone(): string 150 | { 151 | return $this->telephone; 152 | } 153 | 154 | /** 155 | * @return string 156 | */ 157 | public function getCountryId(): string 158 | { 159 | return $this->country_id; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/DataObjects/BundleOption.php: -------------------------------------------------------------------------------- 1 | optionId = $optionId; 41 | $this->valueId = $valueId; 42 | $this->quantity = $quantity; 43 | } 44 | 45 | /** 46 | * @return int 47 | */ 48 | public function getOptionId(): int 49 | { 50 | return $this->optionId; 51 | } 52 | 53 | /** 54 | * @return int 55 | */ 56 | public function getValueId(): int 57 | { 58 | return $this->valueId; 59 | } 60 | 61 | /** 62 | * @return null|int 63 | */ 64 | public function getQuantity() 65 | { 66 | return $this->quantity; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/DataObjects/BundleOptionList.php: -------------------------------------------------------------------------------- 1 | options = $options; 39 | } 40 | 41 | /** 42 | * @return BundleOption[] 43 | */ 44 | public function getOptions(): array 45 | { 46 | return $this->options; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidBundleOption.php: -------------------------------------------------------------------------------- 1 | paymentPageScreenshotName = $name; 41 | 42 | return $browser; 43 | } 44 | 45 | public function placeOrder(\Laravel\Dusk\Browser $browser, $finalState = 'paid') 46 | { 47 | $browser->radio('payment[method]', 'mollie_methods_ideal'); 48 | 49 | $browser->radio('issuer', 'ideal_INGBNL2A'); 50 | 51 | $browser->waitUntilMissing('.loading-mask'); 52 | 53 | $browser->press('Place Order'); 54 | 55 | $browser->waitUntilMissing('.loading-mask'); 56 | $browser->waitForText('iDEAL - Test mode'); 57 | 58 | if ($this->paymentPageScreenshotName) { 59 | $browser->screenshot($this->paymentPageScreenshotName); 60 | } 61 | 62 | $browser->radio('final_state', $finalState); 63 | $browser->press('Continue'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/PaymentMethod/MoneyOrder.php: -------------------------------------------------------------------------------- 1 | resolver->find('payment[method]')) { 39 | $browser->radio('payment[method]', 'checkmo'); 40 | } 41 | 42 | $browser->press('Place Order'); 43 | 44 | $browser->pause(10000); 45 | 46 | $browser->waitForLocation('/checkout/onepage/success/'); 47 | } 48 | } 49 | --------------------------------------------------------------------------------