1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Behat;
6:
7: use Atk4\Core\WarnDynamicPropertyTrait;
8: use Behat\Behat\Context\Context as BehatContext;
9: use Behat\Behat\Hook\Scope\AfterStepScope;
10: use Behat\Behat\Hook\Scope\BeforeStepScope;
11: use Behat\Behat\Hook\Scope\StepScope;
12: use Behat\Gherkin\Node\ScenarioInterface;
13: use Behat\Mink\Element\NodeElement;
14: use Behat\Mink\WebAssert;
15: use Behat\MinkExtension\Context\RawMinkContext;
16:
17: class Context extends RawMinkContext implements BehatContext
18: {
19: use JsCoverageContextTrait;
20: use RwDemosContextTrait;
21: use WarnDynamicPropertyTrait;
22:
23: #[\Override]
24: public function getSession($name = null): MinkSession
25: {
26: return new MinkSession($this->getMink()->getSession($name));
27: }
28:
29: #[\Override]
30: public function assertSession($name = null): WebAssert
31: {
32: return new class($this->getSession($name)) extends WebAssert {
33: #[\Override]
34: protected function cleanUrl($url)
35: {
36: // fix https://github.com/minkphp/Mink/issues/656
37: return $url;
38: }
39: };
40: }
41:
42: protected function getScenario(StepScope $event): ScenarioInterface
43: {
44: foreach ($event->getFeature()->getScenarios() as $scenario) {
45: $scenarioSteps = $scenario->getSteps();
46: if (count($scenarioSteps) > 0
47: && reset($scenarioSteps)->getLine() <= $event->getStep()->getLine()
48: && end($scenarioSteps)->getLine() >= $event->getStep()->getLine()
49: ) {
50: return $scenario;
51: }
52: }
53:
54: throw new \Exception('Unable to find scenario');
55: }
56:
57: /**
58: * @BeforeStep
59: */
60: public function closeAllToasts(BeforeStepScope $event): void
61: {
62: if (!$this->getSession()->isStarted()) {
63: return;
64: }
65:
66: if (!str_starts_with($event->getStep()->getText(), 'Toast display should contain text ')
67: && $event->getStep()->getText() !== 'No toast should be displayed'
68: ) {
69: $this->getSession()->executeScript('jQuery(\'.toast-box > .ui.toast\').toast(\'destroy\')');
70: }
71: }
72:
73: /**
74: * @AfterStep
75: */
76: public function waitUntilLoadingAndAnimationFinished(AfterStepScope $event): void
77: {
78: if (!$this->getSession()->isStarted()) {
79: return;
80: }
81:
82: $this->jqueryWait();
83: $this->disableAnimations();
84:
85: if (!str_contains($this->getScenario($event)->getTitle() ?? '', 'exception is displayed')) {
86: $this->assertNoException();
87: }
88: $this->assertNoDuplicateId();
89:
90: $this->saveJsCoverage();
91: }
92:
93: protected function getFinishedScript(): string
94: {
95: return 'document.readyState === \'complete\' && typeof jQuery !== \'undefined\' && typeof atk !== \'undefined\''
96: . ' && jQuery.active === 0' // no jQuery AJAX request, https://github.com/jquery/jquery/blob/3.6.4/src/ajax.js#L582
97: . ' && jQuery.timers.length === 0' // no jQuery animation, https://github.com/jquery/jquery/blob/3.6.4/src/effects/animatedSelector.js#L10
98: . ' && document.querySelectorAll(\'.ui.animating:not(.looping)\').length === 0' // no Fomantic-UI animation, https://github.com/fomantic/Fomantic-UI/blob/2.9.2/src/definitions/modules/dimmer.js#L358
99: . ' && atk.vueService.areComponentsLoaded()';
100: }
101:
102: /**
103: * Wait till jQuery AJAX request finished and no animation is perform.
104: */
105: protected function jqueryWait(string $extraWaitCondition = 'true', array $args = [], int $maxWaitdurationMs = 5000): void
106: {
107: $finishedScript = '(' . $this->getFinishedScript() . ') && (' . $extraWaitCondition . ')';
108:
109: $s = microtime(true);
110: $c = 0;
111: while (microtime(true) - $s <= $maxWaitdurationMs / 1000) {
112: $this->getSession()->wait($maxWaitdurationMs, $finishedScript, $args);
113: usleep(10_000);
114: if ($this->getSession()->evaluateScript($finishedScript, $args)) { // TODO wait() uses evaluateScript(), dedup
115: if (++$c >= 2) {
116: return;
117: }
118: } else {
119: $c = 0;
120: usleep(20_000);
121: }
122: }
123:
124: throw new \Exception('jQuery did not finish within a time limit');
125: }
126:
127: protected function disableAnimations(): void
128: {
129: // disable all CSS/jQuery animations/transitions
130: $toCssFx = static function (string $selector, array $cssPairs): string {
131: $css = [];
132: foreach ($cssPairs as $k => $v) {
133: foreach ([$k, '-moz-' . $k, '-webkit-' . $k] as $k2) {
134: $css[] = $k2 . ': ' . $v . ' !important;';
135: }
136: }
137:
138: return $selector . ' { ' . implode(' ', $css) . ' }';
139: };
140:
141: $durationAnimation = 0.005;
142: $durationToast = 5;
143: $css = $toCssFx('*', [
144: 'animation-delay' => $durationAnimation . 's',
145: 'animation-duration' => $durationAnimation . 's',
146: 'transition-delay' => $durationAnimation . 's',
147: 'transition-duration' => $durationAnimation . 's',
148: ]) . $toCssFx('.ui.toast-container .toast-box .progressing.wait', [
149: 'animation-duration' => $durationToast . 's',
150: 'transition-duration' => $durationToast . 's',
151: ]);
152:
153: $this->getSession()->executeScript(
154: 'if (Array.prototype.filter.call(document.getElementsByTagName(\'style\'), (e) => e.getAttribute(\'about\') === \'atk4-ui-behat\').length === 0) {'
155: . ' $(\'<style about="atk4-ui-behat">' . $css . '</style>\').appendTo(\'head\');'
156: . ' jQuery.fx.off = true;'
157: // fix self::getFinishedScript() detection for Firefox - document.readyState is updated after at least part of a new page has been loaded
158: . ' window.addEventListener(\'beforeunload\', (event) => jQuery.active++);'
159: . ' }'
160: );
161: }
162:
163: protected function assertNoException(): void
164: {
165: foreach ($this->getSession()->getPage()->findAll('css', 'div.ui.negative.icon.message > div.content > div.header') as $elem) {
166: if ($elem->getText() === 'Critical Error') {
167: echo "\n" . trim(preg_replace(
168: '~(?<=\n)(\d+|Stack Trace\n#FileObjectMethod)(?=\n)~',
169: '',
170: preg_replace(
171: '~(^.*?)?\s*Critical Error\s*\n\s*|(\s*\n)+\s{0,16}~s',
172: "\n",
173: strip_tags($elem->find('xpath', '../../..')->getHtml())
174: )
175: )) . "\n";
176:
177: throw new \Exception('Page contains uncaught exception');
178: }
179: }
180: }
181:
182: protected function assertNoDuplicateId(): void
183: {
184: [$invalidIds, $duplicateIds] = $this->getSession()->evaluateScript(<<<'EOF'
185: (function () {
186: const idRegex = /^[a-z_][0-9a-z_\-]*$/is;
187: const invalidIds = [];
188: const duplicateIds = [];
189: [...(new Set(
190: $('[id]').map(function () {
191: return this.id;
192: })
193: ))].forEach(function (id) {
194: if (!id.match(idRegex)) {
195: invalidIds.push(id);
196: } else {
197: const elems = $('[id="' + id + '"]');
198: if (elems.length > 1) {
199: duplicateIds.push(id);
200: }
201: }
202: });
203:
204: return [invalidIds, duplicateIds];
205: })();
206: EOF);
207:
208: // TODO hack to pass CI testing, fix these issues and remove the error diffs below asap
209: $duplicateIds = array_diff($duplicateIds, ['atk', '_icon', 'atk_icon']); // generated when component is not correctly added to app/layout component tree - should throw, as such name/ID is dangerous to be used
210:
211: if (count($invalidIds) > 0) {
212: throw new \Exception('Page contains element with invalid ID: ' . implode(', ', array_map(static fn ($v) => '"' . $v . '"', $invalidIds)));
213: }
214:
215: if (count($duplicateIds) > 0) {
216: throw new \Exception('Page contains elements with duplicate ID: ' . implode(', ', array_map(static fn ($v) => '"' . $v . '"', $duplicateIds)));
217: }
218: }
219:
220: /**
221: * @return array{ 'css'|'xpath', string }
222: */
223: protected function parseSelector(string $selector): array
224: {
225: if (preg_match('~^\(*//~s', $selector)) {
226: // add support for standard CSS class selector
227: $xpath = preg_replace_callback(
228: '~\'(?:[^\']+|\'\')*+\'\K|"(?:[^"]+|"")*+"\K|(?<=\w|\*)\.([\w\-]+)~s',
229: static function ($matches) {
230: if ($matches[0] === '') {
231: return '';
232: }
233:
234: return '[contains(concat(\' \', normalize-space(@class), \' \'), \' ' . $matches[1] . ' \')]';
235: },
236: $selector
237: );
238:
239: // add NBSP support for normalize-space() xpath function
240: $xpath = preg_replace(
241: '~(?<![\w\-])normalize-space\([^()\'"]*\)~',
242: 'normalize-space(translate($0, \'' . "\u{00a0}" . '\', \' \'))',
243: $xpath
244: );
245:
246: return ['xpath', $xpath];
247: }
248:
249: return ['css', $selector];
250: }
251:
252: /**
253: * @return array<NodeElement>
254: */
255: protected function findElements(?NodeElement $context, string $selector): array
256: {
257: $selectorParsed = $this->parseSelector($selector);
258: $elements = ($context ?? $this->getSession()->getPage())->findAll($selectorParsed[0], $selectorParsed[1]);
259:
260: if (count($elements) === 0) {
261: throw new \Exception('No element found in ' . ($context === null ? 'page' : 'element')
262: . ' using selector: ' . $selector);
263: }
264:
265: return $elements;
266: }
267:
268: protected function findElement(?NodeElement $context, string $selector): NodeElement
269: {
270: $elements = $this->findElements($context, $selector);
271:
272: return $elements[0];
273: }
274:
275: protected function unquoteStepArgument(string $argument): string
276: {
277: // copied from https://github.com/Behat/MinkExtension/blob/v2.2/src/Behat/MinkExtension/Context/MinkContext.php#L567
278: return str_replace('\\"', '"', $argument);
279: }
280:
281: /**
282: * Sleep for a certain time in ms.
283: *
284: * @Then I wait :arg1 ms
285: */
286: public function iWait(int $ms): void
287: {
288: $this->getSession()->wait($ms);
289: }
290:
291: /**
292: * @When I write :arg1 into selector :selector
293: */
294: public function iPressWrite(string $text, string $selector): void
295: {
296: $elem = $this->findElement(null, $selector);
297: $this->getSession()->keyboardWrite($elem, $text);
298: }
299:
300: /**
301: * @When I drag selector :selector onto selector :selectorTarget
302: */
303: public function iDragElementOnto(string $selector, string $selectorTarget): void
304: {
305: $elem = $this->findElement(null, $selector);
306: $elemTarget = $this->findElement(null, $selectorTarget);
307: $this->getSession()->getDriver()->dragTo($elem->getXpath(), $elemTarget->getXpath());
308: }
309:
310: // {{{ button
311:
312: /**
313: * @When I press button :arg1
314: */
315: public function iPressButton(string $buttonLabel): void
316: {
317: $button = $this->findElement(null, '//div[text()="' . $buttonLabel . '"]');
318: $button->click();
319: }
320:
321: /**
322: * @Then I see button :arg1
323: */
324: public function iSeeButton(string $buttonLabel): void
325: {
326: $this->findElement(null, '//div[text()="' . $buttonLabel . '"]');
327: }
328:
329: /**
330: * @Then I don't see button :arg1
331: */
332: public function idontSeeButton(string $text): void
333: {
334: $element = $this->findElement(null, '//div[text()="' . $text . '"]');
335: if (!str_contains($element->getAttribute('style'), 'display: none')) {
336: throw new \Exception('Element with text "' . $text . '" must be invisible');
337: }
338: }
339:
340: // }}}
341:
342: // {{{ link
343:
344: /**
345: * @Given I click link :arg1
346: */
347: public function iClickLink(string $label): void
348: {
349: $this->findElement(null, '//a[text()="' . $label . '"]')->click();
350: }
351:
352: /**
353: * @Then I click using selector :selector
354: */
355: public function iClickUsingSelector(string $selector): void
356: {
357: $element = $this->findElement(null, $selector);
358: $element->click();
359: }
360:
361: /**
362: * \Behat\Mink\Driver\Selenium2Driver::clickOnElement() does not wait until AJAX is completed after scroll.
363: *
364: * One solution can be waiting for AJAX after each \WebDriver\AbstractWebDriver::curl() call.
365: *
366: * @Then PATCH DRIVER I click using selector :selector
367: */
368: public function iClickPatchedUsingSelector(string $selector): void
369: {
370: $element = $this->findElement(null, $selector);
371:
372: $driver = $this->getSession()->getDriver();
373: \Closure::bind(static function () use ($driver, $element) {
374: $driver->mouseOverElement($driver->findElement($element->getXpath()));
375: }, null, MinkSeleniumDriver::class)();
376: $this->jqueryWait();
377:
378: $element->click();
379: }
380:
381: /**
382: * @Then I click paginator page :arg1
383: */
384: public function iClickPaginatorPage(string $pageNumber): void
385: {
386: $element = $this->findElement(null, 'a.item[data-page="' . $pageNumber . '"]');
387: $element->click();
388: }
389:
390: /**
391: * @When I fill field using :selector with :value
392: */
393: public function iFillField(string $selector, string $value): void
394: {
395: $element = $this->findElement(null, $selector);
396: $element->setValue($value);
397: }
398:
399: // }}}
400:
401: // {{{ modal
402:
403: /**
404: * @Then I press Modal button :arg
405: */
406: public function iPressModalButton(string $buttonLabel): void
407: {
408: $modal = $this->findElement(null, '.modal.visible.active.front');
409: $button = $this->findElement($modal, '//div[text()="' . $buttonLabel . '"]');
410: $button->click();
411: }
412:
413: /**
414: * @Then Modal is open with text :arg1
415: * @Then Modal is open with text :arg1 in selector :arg2
416: *
417: * Check if text is present in modal or dynamic modal.
418: */
419: public function modalIsOpenWithText(string $text, string $selector = '*'): void
420: {
421: $textEncoded = str_contains($text, '"')
422: ? 'concat("' . str_replace('"', '", \'"\', "', $text) . '")'
423: : '"' . $text . '"';
424:
425: $modal = $this->findElement(null, '.modal.visible.active.front');
426: $this->findElement($modal, '//' . $selector . '[text()[normalize-space()=' . $textEncoded . ']]');
427: }
428:
429: /**
430: * @When I fill Modal field :arg1 with :arg2
431: */
432: public function iFillModalField(string $fieldName, string $value): void
433: {
434: $modal = $this->findElement(null, '.modal.visible.active.front');
435: $field = $modal->find('named', ['field', $fieldName]);
436: $field->setValue($value);
437: }
438:
439: /**
440: * @Then I click close modal
441: */
442: public function iClickCloseModal(): void
443: {
444: $modal = $this->findElement(null, '.modal.visible.active.front');
445: $closeIcon = $this->findElement($modal, '//i.icon.close');
446: $closeIcon->click();
447: }
448:
449: /**
450: * @Then I hide js modal
451: */
452: public function iHideJsModal(): void
453: {
454: $modal = $this->findElement(null, '.modal.visible.active.front');
455: $this->getSession()->executeScript('$(arguments[0]).modal(\'hide\')', [$modal]);
456: }
457:
458: // }}}
459:
460: // {{{ panel
461:
462: /**
463: * @Then Panel is open
464: */
465: public function panelIsOpen(): void
466: {
467: $this->findElement(null, '.atk-right-panel.atk-visible');
468: }
469:
470: /**
471: * @Then Panel is open with text :arg1
472: * @Then Panel is open with text :arg1 in selector :arg2
473: */
474: public function panelIsOpenWithText(string $text, string $selector = '*'): void
475: {
476: $panel = $this->findElement(null, '.atk-right-panel.atk-visible');
477: $this->findElement($panel, '//' . $selector . '[text()[normalize-space()="' . $text . '"]]');
478: }
479:
480: /**
481: * @When I fill Panel field :arg1 with :arg2
482: */
483: public function iFillPanelField(string $fieldName, string $value): void
484: {
485: $panel = $this->findElement(null, '.atk-right-panel.atk-visible');
486: $field = $panel->find('named', ['field', $fieldName]);
487: $field->setValue($value);
488: }
489:
490: /**
491: * @Then I press Panel button :arg
492: */
493: public function iPressPanelButton(string $buttonLabel): void
494: {
495: $panel = $this->findElement(null, '.atk-right-panel.atk-visible');
496: $button = $this->findElement($panel, '//div[text()="' . $buttonLabel . '"]');
497: $button->click();
498: }
499:
500: // }}}
501:
502: // {{{ tab
503:
504: /**
505: * @Given I click tab with title :arg1
506: */
507: public function iClickTabWithTitle(string $tabTitle): void
508: {
509: $tabMenu = $this->findElement(null, '.ui.tabular.menu');
510: $link = $this->findElement($tabMenu, '//div[text()="' . $tabTitle . '"]');
511: $link->click();
512: }
513:
514: /**
515: * @Then Active tab should be :arg1
516: */
517: public function activeTabShouldBe(string $title): void
518: {
519: $tab = $this->findElement(null, '.ui.tabular.menu > .item.active');
520: if ($tab->getText() !== $title) {
521: throw new \Exception('Active tab is not ' . $title);
522: }
523: }
524:
525: // }}}
526:
527: // {{{ input
528:
529: /**
530: * @Then ~^input "([^"]*)" value should start with "([^"]*)"$~
531: */
532: public function inputValueShouldStartWith(string $inputName, string $text): void
533: {
534: $inputName = $this->unquoteStepArgument($inputName);
535: $text = $this->unquoteStepArgument($text);
536:
537: $field = $this->assertSession()->fieldExists($inputName);
538:
539: if (!str_starts_with($field->getValue(), $text)) {
540: throw new \Exception('Field value ' . $field->getValue() . ' does not start with ' . $text);
541: }
542: }
543:
544: /**
545: * @Then I search grid for :arg1
546: */
547: public function iSearchGridFor(string $text): void
548: {
549: $search = $this->findElement(null, 'input.atk-grid-search');
550: $search->setValue($text);
551: }
552:
553: /**
554: * @Then I select value :arg1 in lookup :arg2
555: */
556: public function iSelectValueInLookup(string $value, string $inputName): void
557: {
558: $isSelectorXpath = $this->parseSelector($inputName)[0] === 'xpath';
559:
560: // get dropdown item from Fomantic-UI which is direct parent of input HTML element
561: $lookupElem = $this->findElement(null, ($isSelectorXpath ? $inputName : '//input[@name="' . $inputName . '"]') . '/parent::div');
562:
563: // open dropdown and wait till fully opened (just a click is not triggering it)
564: $this->getSession()->executeScript('$(arguments[0]).dropdown(\'show\')', [$lookupElem]);
565: $this->jqueryWait('$(arguments[0]).hasClass(\'visible\')', [$lookupElem]);
566:
567: // select value
568: if ($value === '') { // TODO impl. native clearable - https://github.com/atk4/ui/issues/572
569: $value = "\u{00a0}";
570: }
571: $valueElem = $this->findElement($lookupElem, '//div[text()="' . $value . '"]');
572: $this->getSession()->executeScript('$(arguments[0]).dropdown(\'set selected\', arguments[1]);', [$lookupElem, $valueElem->getAttribute('data-value')]);
573: $this->jqueryWait();
574:
575: // hide dropdown and wait till fully closed
576: $this->getSession()->executeScript('$(arguments[0]).dropdown(\'hide\');', [$lookupElem]);
577: $this->jqueryWait('!$(arguments[0]).hasClass(\'visible\')', [$lookupElem]);
578: }
579:
580: /**
581: * @When I select file input :arg1 with :arg2 as :arg3
582: */
583: public function iSelectFile(string $inputName, string $fileContent, string $fileName): void
584: {
585: $element = $this->findElement(null, '//input[@name="' . $inputName . '" and @type="hidden"]/following-sibling::input[@type="file"]');
586: $this->getSession()->executeScript(<<<'EOF'
587: const dataTransfer = new DataTransfer();
588: dataTransfer.items.add(new File([new Uint8Array(arguments[1])], arguments[2]));
589: arguments[0].files = dataTransfer.files;
590: $(arguments[0]).trigger('change');
591: EOF, [$element, array_map('ord', str_split($fileContent)), $fileName]);
592: }
593:
594: private function getScopeBuilderRuleElem(string $ruleName): NodeElement
595: {
596: return $this->findElement(null, '.vqb-rule[data-name=' . $ruleName . ']');
597: }
598:
599: /**
600: * Generic ScopeBuilder rule with select operator and input value.
601: *
602: * @Then ~^rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$~
603: */
604: public function scopeBuilderRule(string $name, string $operator, string $value): void
605: {
606: $name = $this->unquoteStepArgument($name);
607: $operator = $this->unquoteStepArgument($operator);
608: $value = $this->unquoteStepArgument($value);
609:
610: $rule = $this->getScopeBuilderRuleElem($name);
611: $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select');
612: $this->assertInputValue($rule, $value);
613: }
614:
615: /**
616: * HasOne reference or enum type rule for ScopeBuilder.
617: *
618: * @Then ~^reference rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$~
619: */
620: public function scopeBuilderReferenceRule(string $name, string $operator, string $value): void
621: {
622: $name = $this->unquoteStepArgument($name);
623: $operator = $this->unquoteStepArgument($operator);
624: $value = $this->unquoteStepArgument($value);
625:
626: $rule = $this->getScopeBuilderRuleElem($name);
627: $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select');
628: $this->assertDropdownValue($rule, $value, '.vqb-rule-input .active.item');
629: }
630:
631: /**
632: * HasOne select or enum type rule for ScopeBuilder.
633: *
634: * @Then ~^select rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$~
635: */
636: public function scopeBuilderSelectRule(string $name, string $operator, string $value): void
637: {
638: $name = $this->unquoteStepArgument($name);
639: $operator = $this->unquoteStepArgument($operator);
640: $value = $this->unquoteStepArgument($value);
641:
642: $rule = $this->getScopeBuilderRuleElem($name);
643: $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select');
644: $this->assertSelectedValue($rule, $value, '.vqb-rule-input select');
645: }
646:
647: /**
648: * Date, Time or Datetime rule for ScopeBuilder.
649: *
650: * @Then ~^date rule "([^"]*)" operator is "([^"]*)" and value is "([^"]*)"$~
651: */
652: public function scopeBuilderDateRule(string $name, string $operator, string $value): void
653: {
654: $name = $this->unquoteStepArgument($name);
655: $operator = $this->unquoteStepArgument($operator);
656: $value = $this->unquoteStepArgument($value);
657:
658: $rule = $this->getScopeBuilderRuleElem($name);
659: $this->assertSelectedValue($rule, $operator, '.vqb-rule-operator select');
660: $this->assertInputValue($rule, $value);
661: }
662:
663: /**
664: * Boolean type rule for ScopeBuilder.
665: *
666: * @Then ~^bool rule "([^"]*)" has value "([^"]*)"$~
667: */
668: public function scopeBuilderBoolRule(string $name, string $value): void
669: {
670: $name = $this->unquoteStepArgument($name);
671: $value = $this->unquoteStepArgument($value);
672:
673: $this->getScopeBuilderRuleElem($name);
674: $idx = ($value === 'Yes') ? 0 : 1;
675: $isChecked = $this->getSession()->evaluateScript('$(\'[data-name="' . $name . '"]\').find(\'input\')[' . $idx . '].checked');
676: if (!$isChecked) {
677: throw new \Exception('Radio value selected is not: ' . $value);
678: }
679: }
680:
681: /**
682: * @Then ~^I check if input value for "([^"]*)" match text "([^"]*)"~
683: */
684: public function compareInputValueToText(string $selector, string $text): void
685: {
686: $selector = $this->unquoteStepArgument($selector);
687: $text = $this->unquoteStepArgument($text);
688:
689: $inputValue = $this->findElement(null, $selector)->getValue();
690: if ($inputValue !== $text) {
691: throw new \Exception('Input value does not match: ' . $inputValue . ', expected: ' . $text);
692: }
693: }
694:
695: /**
696: * @Then ~^I check if input value for "([^"]*)" match text in "([^"]*)"$~
697: */
698: public function compareInputValueToElementText(string $inputName, string $selector): void
699: {
700: $inputName = $this->unquoteStepArgument($inputName);
701: $selector = $this->unquoteStepArgument($selector);
702:
703: $expectedText = $this->findElement(null, $selector)->getText();
704: $input = $this->findElement(null, 'input[name="' . $inputName . '"]');
705: if ($expectedText !== $input->getValue()) {
706: throw new \Exception('Input value does not match: ' . $input->getValue() . ', expected: ' . $expectedText);
707: }
708: }
709:
710: // }}}
711:
712: // {{{ misc
713:
714: /**
715: * @Then dump :arg1
716: */
717: public function dump(string $arg1): void
718: {
719: $element = $this->getSession()->getPage()->find('xpath', '//div[text()="' . $arg1 . '"]');
720: var_dump($element->getOuterHtml());
721: }
722:
723: /**
724: * @Then I click filter column name :arg1
725: */
726: public function iClickFilterColumnName(string $columnName): void
727: {
728: $column = $this->findElement(null, "th[data-column='" . $columnName . "']");
729: $icon = $this->findElement($column, 'i');
730: $icon->click();
731: }
732:
733: /**
734: * @Then ~^container "([^"]*)" should display "([^"]*)" item\(s\)$~
735: */
736: public function containerShouldHaveNumberOfItem(string $selector, int $numberOfitems): void
737: {
738: $selector = $this->unquoteStepArgument($selector);
739:
740: $items = $this->getSession()->getPage()->findAll('css', $selector);
741: $count = 0;
742: foreach ($items as $el => $item) {
743: ++$count;
744: }
745: if ($count !== $numberOfitems) {
746: throw new \Exception('Items does not match. There were ' . $count . ' item in container');
747: }
748: }
749:
750: /**
751: * @Then I scroll to top
752: */
753: public function iScrollToTop(): void
754: {
755: $this->getSession()->executeScript('window.scrollTo(0, 0)');
756: }
757:
758: /**
759: * @Then I scroll to bottom
760: */
761: public function iScrollToBottom(): void
762: {
763: $this->getSession()->executeScript('window.scrollTo(0, 100 * 1000)');
764: }
765:
766: /**
767: * @Then Toast display should contain text :arg1
768: */
769: public function toastDisplayShouldContainText(string $text): void
770: {
771: $toastContainer = $this->findElement(null, '.ui.toast-container');
772: $toastText = $this->findElement($toastContainer, '.content')->getText();
773: if (!str_contains($toastText, $text)) {
774: throw new \Exception('Toast text "' . $toastText . '" does not contain "' . $text . '"');
775: }
776: }
777:
778: /**
779: * @Then No toast should be displayed
780: */
781: public function noToastShouldBeDisplayed(): void
782: {
783: $toasts = $this->getSession()->getPage()->findAll('css', '.ui.toast-container .toast-box');
784: if (count($toasts) > 0) {
785: throw new \Exception('Toast is displayed: "' . $this->findElement(reset($toasts), '.content')->getText() . '"');
786: }
787: }
788:
789: /**
790: * Remove once https://github.com/Behat/MinkExtension/pull/386 and
791: * https://github.com/minkphp/Mink/issues/656 are fixed and released.
792: *
793: * @Then ~^PATCH MINK the (?i)url(?-i) should match "(?P<pattern>(?:[^"]|\\")*)"$~
794: */
795: public function assertUrlRegExp(string $pattern): void
796: {
797: $pattern = $this->unquoteStepArgument($pattern);
798:
799: $this->assertSession()->addressMatches($pattern);
800: }
801:
802: /**
803: * @Then ~^I check if text in "([^"]*)" match text in "([^"]*)"~
804: */
805: public function compareElementText(string $compareSelector, string $compareToSelector): void
806: {
807: $compareSelector = $this->unquoteStepArgument($compareSelector);
808: $compareToSelector = $this->unquoteStepArgument($compareToSelector);
809:
810: if ($this->findElement(null, $compareSelector)->getText() !== $this->findElement(null, $compareToSelector)->getText()) {
811: throw new \Exception('Text does not match between: ' . $compareSelector . ' and ' . $compareToSelector);
812: }
813: }
814:
815: /**
816: * @Then ~^I check if text in "([^"]*)" match text "([^"]*)"~
817: */
818: public function textInContainerShouldMatch(string $selector, string $text): void
819: {
820: $selector = $this->unquoteStepArgument($selector);
821: $text = $this->unquoteStepArgument($text);
822:
823: if ($this->findElement(null, $selector)->getText() !== $text) {
824: throw new \Exception('Container with selector: ' . $selector . ' does not match text: ' . $text);
825: }
826: }
827:
828: /**
829: * @Then ~^I check if text in "([^"]*)" match regex "([^"]*)"~
830: */
831: public function textInContainerShouldMatchRegex(string $selector, string $regex): void
832: {
833: $selector = $this->unquoteStepArgument($selector);
834: $regex = $this->unquoteStepArgument($regex);
835:
836: if (!preg_match($regex, $this->findElement(null, $selector)->getText())) {
837: throw new \Exception('Container with selector: ' . $selector . ' does not match regex: ' . $regex);
838: }
839: }
840:
841: /**
842: * @Then Element :arg1 attribute :arg2 should contain text :arg3
843: */
844: public function elementAttributeShouldContainText(string $selector, string $attribute, string $text): void
845: {
846: $element = $this->findElement(null, $selector);
847: $attr = $element->getAttribute($attribute);
848: if (!str_contains($attr, $text)) {
849: throw new \Exception('Element " . $selector . " attribute "' . $attribute . '" does not contain "' . $text . '"');
850: }
851: }
852:
853: // }}}
854:
855: /**
856: * Find a dropdown component within an HTML element
857: * and check if value is set in dropdown.
858: */
859: private function assertDropdownValue(NodeElement $element, string $value, string $selector): void
860: {
861: if ($this->findElement($element, $selector)->getText() !== $value) {
862: throw new \Exception('Value: "' . $value . '" not set using selector: ' . $selector);
863: }
864: }
865:
866: /**
867: * Find a select input type within an HTML element
868: * and check if value is selected.
869: */
870: private function assertSelectedValue(NodeElement $element, string $value, string $selector): void
871: {
872: if ($this->findElement($element, $selector)->getValue() !== $value) {
873: throw new \Exception('Value: "' . $value . '" not set using selector: ' . $selector);
874: }
875: }
876:
877: /**
878: * Find an input within an HTML element and check
879: * if value is set.
880: */
881: private function assertInputValue(NodeElement $element, string $value, string $selector = 'input'): void
882: {
883: if ($this->findElement($element, $selector)->getValue() !== $value) {
884: throw new \Exception('Input value not is not: ' . $value);
885: }
886: }
887: }
888: