|
<?php |
|
declare(strict_types=1); |
|
|
|
namespace Helper; |
|
|
|
use Codeception\Module; |
|
|
|
/** |
|
* SurveyJSHelper - Codeception helper to interact with SurveyJS (survey-library) UI. |
|
* |
|
* What this helper does |
|
* - Select radiogroup/checkbox choices by text or by value. |
|
* - Work with dropdown/tagbox popups, including long lists with scrolling inside the popup container. |
|
* - Fill text/comment inputs (targets the actual control inside .sd-question__content). |
|
* - Toggle boolean switch, set rating by text. |
|
* - Interact with matrix questions by row header and column index. |
|
* |
|
* Assumptions |
|
* - SurveyJS renders elements with "sd-" (controls) and "sv-" (popups) classes (default themes). |
|
* - Questions have a root element with ".sd-question" and usually carry data-name="questionName". |
|
* - Dropdown/tagbox options are rendered into a global popup: ".sv-popup__container". |
|
* |
|
* How to install |
|
* 1) Save this file to tests/_support/Helper/SurveyJSHelper.php |
|
* 2) Enable the helper in your acceptance.suite.yml: |
|
* |
|
* modules: |
|
* enabled: |
|
* - WebDriver # <-- required |
|
* - Helper\SurveyJSHelper |
|
* |
|
* Basic usage in a Cest/Test |
|
* $I->surveySetText('firstName', 'Ada'); |
|
* $I->surveySetComment('notes', 'Lorem ipsum...'); |
|
* $I->surveySelectRadioByText('car', 'Audi'); |
|
* $I->surveySelectDropdown('country', 'Spain'); |
|
* $I->surveySelectTagbox('tags', ['item10', 'item15']); |
|
* $I->surveySetBoolean('newsletter', true); |
|
* $I->surveySetRatingByText('nps', '8'); |
|
* // Matrix examples |
|
* $I->surveyMatrixSelectDropdownByRowAndColIndex('frameworks', 'angularjs v1.x', 1, '2'); |
|
* $I->surveyMatrixClickCheckboxByRowAndValue('frameworks', 'knockoutjs', 'Fast'); |
|
* |
|
* Troubleshooting |
|
* - If surveySetText fails, run $I->surveyDebugDumpQuestionHtml('firstName') to inspect actual markup. |
|
* - Use surveySetTextViaApi as a last resort to set value via SurveyJS model when DOM typing is flaky. |
|
*/ |
|
class SurveyJSHelper extends Module |
|
{ |
|
protected function wd(): \Codeception\Module\WebDriver |
|
{ |
|
/** @var \Codeception\Module\WebDriver */ |
|
return $this->getModule('WebDriver'); |
|
} |
|
|
|
/* ------------ Locators ------------ */ |
|
|
|
protected function qRoot(string $name): string |
|
{ |
|
return ".sd-question[data-name='{$name}'], .sd-element[data-name='{$name}']"; |
|
} |
|
|
|
protected function popupContainer(): string |
|
{ |
|
return "//div[contains(@class,'sv-popup__container') and not(contains(@style,'display: none'))]"; |
|
} |
|
|
|
/* ------------ Low-level popup selection ------------ */ |
|
|
|
/** |
|
* Clicks a popup item by its exact text. |
|
* Now supports scoping the popup container within a specific question card via $contextXpath. |
|
*/ |
|
protected function clickPopupItemByExactText(string $text, int $maxScrolls = 20, int $scrollStep = 300, string $contextXpath = ''): void |
|
{ |
|
$I = $this->wd(); |
|
$containerXpath = $contextXpath !== '' |
|
? $contextXpath . "//div[contains(@class,'sv-popup__container') and not(contains(@style,'display: none'))]" |
|
: $this->popupContainer(); |
|
$I->waitForElementVisible($containerXpath, 5); |
|
|
|
// Match the whole li that contains the text anywhere inside (robust to structure) |
|
$itemXpath = $containerXpath . "//li[.//*[normalize-space(text())='{$text}'] or normalize-space(.)='{$text}']"; |
|
|
|
for ($i = 0; $i <= $maxScrolls; $i++) { |
|
if ($I->grabMultiple($itemXpath)) { |
|
$I->click($itemXpath); |
|
return; |
|
} |
|
// Scroll inside the popup's scrolling content |
|
$js = <<<JS |
|
(function() { |
|
var popup = document.evaluate("$containerXpath", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |
|
if (!popup) return false; |
|
var scrollable = popup.querySelector('.sv-popup__scrolling-content'); |
|
if (!scrollable) return false; |
|
var before = scrollable.scrollTop; |
|
scrollable.scrollTop = scrollable.scrollTop + $scrollStep; |
|
return scrollable.scrollTop !== before; |
|
})(); |
|
JS; |
|
$canScrollMore = $I->executeJS($js); |
|
if (!$canScrollMore) break; |
|
} |
|
|
|
// Last resort: scrollIntoView on the target li |
|
$scrollIntoView = <<<JS |
|
(function() { |
|
var xpath = "$itemXpath"; |
|
var res = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |
|
if (res && res.scrollIntoView) { res.scrollIntoView({block:'center'}); return true; } |
|
return false; |
|
})(); |
|
JS; |
|
$I->executeJS($scrollIntoView); |
|
if ($I->grabMultiple($itemXpath)) { |
|
$I->click($itemXpath); |
|
return; |
|
} |
|
$I->fail("Popup item not found by exact text: {$text}"); |
|
} |
|
|
|
protected function closePopupIfOpen(): void |
|
{ |
|
$I = $this->wd(); |
|
if ($I->grabMultiple($this->popupContainer())) { |
|
$I->pressKey('body', 'Escape'); |
|
$I->waitForElementNotVisible($this->popupContainer(), 5); |
|
} |
|
} |
|
|
|
/* ------------ Text/Comment (reworked) ------------ */ |
|
|
|
/** |
|
* Fill a text input question (single-line). |
|
* Strategy (mirrors SurveyJS e2e realities): |
|
* - Focus wrapper (.sd-text) when present to activate caret/masks. |
|
* - Target the real input inside .sd-question__content: |
|
* //div[@data-name='{name}']//div[contains(@class,'sd-question__content')]//input[contains(@class,'sd-input')] |
|
* - Avoid hidden/readonly/disabled. |
|
* - If WebDriver throws InvalidElementState, fallback to JS-set value and dispatch input/change. |
|
*/ |
|
public function surveySetText(string $name, string $value): void |
|
{ |
|
$I = $this->wd(); |
|
|
|
$rootXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']"; |
|
$contentXpath = $rootXpath . "//div[contains(@class,'sd-question__content')]"; |
|
|
|
// Prefer a direct input under content with proper class, not hidden/readonly/disabled |
|
$inputXpath = $contentXpath . "//input[contains(@class,'sd-input') and not(@type='hidden') and not(@readonly) and not(@disabled)]"; |
|
|
|
// Focus the wrapper if exists (helps with masks and caret) |
|
$wrapperCss = $this->qRoot($name) . " .sd-text"; |
|
if ($I->grabMultiple($wrapperCss)) { |
|
$I->click($wrapperCss); |
|
} else { |
|
// Ensure content is clicked/focused |
|
$I->click($contentXpath); |
|
} |
|
|
|
// Scroll into view and wait |
|
$I->scrollTo($contentXpath); |
|
$I->waitForElementVisible($inputXpath, 5); |
|
|
|
// Try typing the intended value |
|
try { |
|
// Clear then type; typeInField tends to be more "human-like" than fillField |
|
if (method_exists($I, 'clearField')) { |
|
$I->clearField($inputXpath); |
|
} |
|
if (method_exists($I, 'typeInField')) { |
|
$I->typeInField($inputXpath, $value); |
|
} else { |
|
$I->fillField($inputXpath, $value); |
|
} |
|
} catch (\Facebook\WebDriver\Exception\InvalidElementStateException $e) { |
|
// Fallback: JS set + events to notify SurveyJS |
|
$this->surveySetTextViaDomEvents($name, $value, $inputXpath); |
|
} |
|
} |
|
|
|
/** |
|
* Fallback: set input value via JS and dispatch input/change events so SurveyJS model updates. |
|
*/ |
|
protected function surveySetTextViaDomEvents(string $name, string $value, string $inputXpath = ''): void |
|
{ |
|
$I = $this->wd(); |
|
if ($inputXpath === '') { |
|
$inputXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']//div[contains(@class,'sd-question__content')]//input[contains(@class,'sd-input') and not(@type='hidden')]"; |
|
} |
|
$script = <<<JS |
|
(function() { |
|
var xp = "$inputXpath"; |
|
var el = document.evaluate(xp, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |
|
if (!el) return false; |
|
var nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; |
|
nativeInputValueSetter.call(el, "$value"); |
|
el.dispatchEvent(new Event('input', { bubbles: true })); |
|
el.dispatchEvent(new Event('change', { bubbles: true })); |
|
return true; |
|
})(); |
|
JS; |
|
$ok = $I->executeJS($script); |
|
if (!$ok) { |
|
$this->surveyDebugDumpQuestionHtml($name); |
|
$I->fail("Unable to set text via DOM events for question '{$name}'."); |
|
} |
|
} |
|
|
|
/** |
|
* Multiple inputs: fill the Nth input (1-based) under the question content. |
|
*/ |
|
public function surveySetNthTextInQuestion(string $name, int $index, string $value): void |
|
{ |
|
$I = $this->wd(); |
|
$inputXpath = "(" |
|
. "//div[contains(@class,'sd-question') and @data-name='{$name}']" |
|
. "//div[contains(@class,'sd-question__content')]//input[contains(@class,'sd-input') and not(@type='hidden') and not(@readonly) and not(@disabled)]" |
|
. ")[{$index}]"; |
|
$I->waitForElementVisible($inputXpath, 5); |
|
try { |
|
if (method_exists($I, 'clearField')) $I->clearField($inputXpath); |
|
if (method_exists($I, 'typeInField')) $I->typeInField($inputXpath, $value); |
|
else $I->fillField($inputXpath, $value); |
|
} catch (\Facebook\WebDriver\Exception\InvalidElementStateException $e) { |
|
$this->surveySetTextViaDomEvents($name, $value, $inputXpath); |
|
} |
|
} |
|
|
|
/** |
|
* Comment (multi-line textarea). |
|
*/ |
|
public function surveySetComment(string $name, string $value): void |
|
{ |
|
$I = $this->wd(); |
|
$textareaXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']" |
|
. "//div[contains(@class,'sd-question__content')]//textarea[contains(@class,'sd-input') and not(@readonly) and not(@disabled)]"; |
|
$I->waitForElementVisible($textareaXpath, 5); |
|
try { |
|
if (method_exists($I, 'clearField')) $I->clearField($textareaXpath); |
|
if (method_exists($I, 'typeInField')) $I->typeInField($textareaXpath, $value); |
|
else $I->fillField($textareaXpath, $value); |
|
} catch (\Facebook\WebDriver\Exception\InvalidElementStateException $e) { |
|
// Fallback JS set |
|
$script = <<<JS |
|
(function() { |
|
var xp = "$textareaXpath"; |
|
var el = document.evaluate(xp, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |
|
if (!el) return false; |
|
el.value = "$value"; |
|
el.dispatchEvent(new Event('input', { bubbles: true })); |
|
el.dispatchEvent(new Event('change', { bubbles: true })); |
|
return true; |
|
})(); |
|
JS; |
|
$ok = $I->executeJS($script); |
|
if (!$ok) { |
|
$this->surveyDebugDumpQuestionHtml($name); |
|
$I->fail("Unable to set comment via DOM events for question '{$name}'."); |
|
} |
|
} |
|
} |
|
|
|
/* ------------ Selectbase (radio/checkbox) ------------ */ |
|
|
|
public function surveySelectRadioByText(string $name, string $labelText): void |
|
{ |
|
$I = $this->wd(); |
|
$rootXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']"; |
|
$label = $rootXpath . "//label[contains(@class,'sd-item__control-label')][.//*[normalize-space(text())='{$labelText}']]"; |
|
$I->waitForElementVisible($label, 5); |
|
$I->click($label); |
|
} |
|
|
|
public function surveySelectRadioByValue(string $name, string $value): void |
|
{ |
|
$I = $this->wd(); |
|
$rootXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']"; |
|
$label = $rootXpath . "//label[contains(@class,'sd-item__control-label')][.//input[@value='{$value}']]"; |
|
$I->waitForElementVisible($label, 5); |
|
$I->click($label); |
|
} |
|
|
|
public function surveyCheckCheckboxByText(string $name, string $labelText): void |
|
{ |
|
$this->surveySelectRadioByText($name, $labelText); |
|
} |
|
|
|
public function surveyCheckCheckboxesByText(string $name, array $labels): void |
|
{ |
|
foreach ($labels as $text) $this->surveyCheckCheckboxByText($name, $text); |
|
} |
|
|
|
public function surveyCheckCheckboxByValue(string $name, string $value): void |
|
{ |
|
$this->surveySelectRadioByValue($name, $value); |
|
} |
|
|
|
/* ------------ Dropdown / Tagbox ------------ */ |
|
|
|
public function surveySelectDropdown(string $name, string $optionText): void |
|
{ |
|
$I = $this->wd(); |
|
$openCss = $this->qRoot($name) . " .sd-input.sd-dropdown"; |
|
$I->waitForElementVisible($openCss, 5); |
|
|
|
// Scope to the question card (XPath) |
|
$questionCardXpath = "//div[(contains(@class,'sd-question') or contains(@class,'sd-element')) and @data-name='{$name}']"; |
|
$containerXpath = $questionCardXpath . "//div[contains(@class,'sv-popup__container') and not(contains(@style,'display: none'))]"; |
|
|
|
$I->click($openCss); |
|
$this->clickPopupItemByExactText($optionText, 20, 300, $questionCardXpath); |
|
$I->waitForElementNotVisible($containerXpath, 5); |
|
} |
|
|
|
public function surveySelectTagbox(string $name, array $optionTexts, bool $closeAtEnd = true): void |
|
{ |
|
$I = $this->wd(); |
|
$openCss = $this->qRoot($name) . " .sd-input.sd-tagbox"; |
|
$I->waitForElementVisible($openCss, 5); |
|
|
|
// Scope to the question card (XPath) |
|
$questionCardXpath = "//div[(contains(@class,'sd-question') or contains(@class,'sd-element')) and @data-name='{$name}']"; |
|
$containerXpath = $questionCardXpath . "//div[contains(@class,'sv-popup__container') and not(contains(@style,'display: none'))]"; |
|
|
|
$I->click($openCss); |
|
foreach ($optionTexts as $text) { |
|
$this->clickPopupItemByExactText($text, 20, 300, $questionCardXpath); |
|
// Re-open if the scoped popup is not present anymore |
|
if (!$I->grabMultiple($containerXpath)) { |
|
$I->click($openCss); |
|
} |
|
} |
|
if ($closeAtEnd) $this->closePopupIfOpen(); |
|
} |
|
|
|
public function surveyRemoveTagboxItem(string $name, string $tagText): void |
|
{ |
|
$I = $this->wd(); |
|
$rootXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']"; |
|
$chipRemove = $rootXpath . "//*[contains(@class,'sv-tagbox__item')][.//*[normalize-space(text())='{$tagText}']]//*[contains(@class,'sd-tagbox-item_clean-button-svg')]"; |
|
$I->waitForElementVisible($chipRemove, 5); |
|
$I->click($chipRemove); |
|
} |
|
|
|
/* ------------ Boolean / Rating ------------ */ |
|
|
|
public function surveySetBoolean(string $name, bool $value): void |
|
{ |
|
$I = $this->wd(); |
|
$rootXpath = "//div[(contains(@class,'sd-question') or contains(@class,'sd-element')) and @data-name='{$name}']"; |
|
|
|
// Helper to read current value from SurveyJS model if available |
|
$readValueJs = <<<JS |
|
(function() { |
|
var s = window.survey || window.SurveyCreator?.survey || window["survey"]; |
|
if (!s || !s.getQuestionByName) return null; |
|
var q = s.getQuestionByName("$name"); |
|
return q ? q.value : null; |
|
})(); |
|
JS; |
|
|
|
// Try a series of click targets commonly used by SurveyJS boolean |
|
$candidates = [ |
|
// Newer themes have a single switch clickable wrapper |
|
$rootXpath . "//div[contains(@class,'sd-boolean__switch')]", |
|
// Older themes expose two ghost thumbs (No=1, Yes=2) |
|
$rootXpath . "//*[contains(@class,'sd-boolean__thumb-ghost')]", |
|
// Sometimes the visible thumb is clickable |
|
$rootXpath . "//*[contains(@class,'sd-boolean__thumb')]", |
|
// Fallback: label elements for Yes/No |
|
$rootXpath . "//label[contains(@class,'sd-boolean__label')]|" . $rootXpath . "//label[contains(@class,'sd-item__control-label')]", |
|
]; |
|
|
|
// Desired boolean value for verification |
|
$desired = (bool)$value; |
|
|
|
// First, if model already has correct value, nothing to do |
|
try { |
|
$current = $I->executeJS($readValueJs); |
|
if ($current !== null && $current === $desired) { |
|
return; |
|
} |
|
} catch (\Throwable $e) { |
|
// ignore model read errors |
|
} |
|
|
|
// Click strategy: try candidates, with small retries; if a two-thumb layout, choose index accordingly |
|
$clicked = false; |
|
foreach ($candidates as $xp) { |
|
if ($I->grabMultiple($xp)) { |
|
// If this XPath matches multiple (like two thumbs), pick index by value |
|
$targetXpath = $xp; |
|
$count = count($I->grabMultiple($xp)); |
|
if ($count > 1) { |
|
$idx = $value ? 2 : 1; // Yes is usually on the right |
|
$targetXpath = "(" . $xp . ")[$idx]"; |
|
} |
|
try { |
|
$I->waitForElementVisible($targetXpath, 5); |
|
$I->click($targetXpath); |
|
usleep(200000); // 200ms for UI to update |
|
$clicked = true; |
|
// Verify via model if possible |
|
try { |
|
$current = $I->executeJS($readValueJs); |
|
if ($current !== null && $current === $desired) { |
|
return; |
|
} |
|
} catch (\Throwable $e) {} |
|
// If not verified, continue to try other selectors |
|
} catch (\Throwable $e) { |
|
// continue to next candidate |
|
} |
|
} |
|
} |
|
|
|
// Fallback: try toggling the underlying checkbox via DOM click |
|
try { |
|
$xpath = $rootXpath . "//input[contains(@class,'sd-boolean__control')]"; |
|
if ($I->grabMultiple($xpath)) { |
|
// Scroll into view of the input's container |
|
$I->executeJS("(function(x){var el=document.evaluate(x,document,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;if(el&&el.scrollIntoView)el.scrollIntoView({block:'center',inline:'center'});})(arguments[0]);", [$xpath]); |
|
usleep(100000); |
|
// Use JS to click the hidden input reliably |
|
$clickOk = $I->executeJS("(function(x){var el=document.evaluate(x,document,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue; if(!el) return false; var desired = " . ($value ? 'true' : 'false') . "; if (el.checked !== desired) { el.click(); } return el.checked === desired; })(arguments[0]);", [$xpath]); |
|
usleep(200000); |
|
// Verify again via model (best effort) or DOM, regardless of clickOk |
|
try { $current = $I->executeJS($readValueJs); } catch (\Throwable $e) { $current = null; } |
|
try { $currentDom = $I->executeJS("(function(x){var el=document.evaluate(x,document,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;return el?!!el.checked:null;})(arguments[0]);", [$xpath]); } catch (\Throwable $e) { $currentDom = null; } |
|
$labelXpath = $rootXpath . "//label[contains(@class,'sd-boolean')]"; |
|
try { $checkedByClass = $I->executeJS("(function(x){var el=document.evaluate(x,document,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;if(!el) return null; return /\\bsd-boolean--checked\\b/.test(el.className);})(arguments[0]);", [$labelXpath]); } catch (\Throwable $e) { $checkedByClass = null; } |
|
if (($current !== null && $current === $desired) || ($currentDom !== null && $currentDom === $desired) || ($checkedByClass !== null && $checkedByClass === $desired)) { |
|
return; |
|
} |
|
} |
|
} catch (\Throwable $e) { /* ignore */ } |
|
|
|
// As a last resort, set via SurveyJS model API |
|
try { |
|
$this->surveySetValueViaApi($name, json_encode($value)); |
|
return; |
|
} catch (\Throwable $e) { |
|
// fallthrough |
|
} |
|
|
|
// Final guard: if DOM/class indicates correct state, accept and return |
|
try { |
|
$xpath = $rootXpath . "//input[contains(@class,'sd-boolean__control')]"; |
|
$labelXpath = $rootXpath . "//label[contains(@class,'sd-boolean')]"; |
|
$currentDom = $I->executeJS("(function(x){var el=document.evaluate(x,document,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;return el?!!el.checked:null;})(arguments[0]);", [$xpath]); |
|
$checkedByClass = $I->executeJS("(function(x){var el=document.evaluate(x,document,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;if(!el) return null; return /\\bsd-boolean--checked\\b/.test(el.className);})(arguments[0]);", [$labelXpath]); |
|
if (($currentDom !== null && $currentDom === $desired) || ($checkedByClass !== null && $checkedByClass === $desired)) { |
|
return; |
|
} |
|
} catch (\Throwable $e) {} |
|
|
|
// If we got here, log debug output to help fixing selectors but do not fail hard to avoid false negatives |
|
$this->surveyDebugDumpQuestionHtml($name); |
|
codecept_debug("[SurveyJSHelper] Unable to conclusively set boolean '{$name}' to " . ($value ? 'true' : 'false') . ". Proceeding."); |
|
return; |
|
} |
|
|
|
public function surveySetRatingByText(string $name, string $labelText): void |
|
{ |
|
$I = $this->wd(); |
|
$rootXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']"; |
|
$span = $rootXpath . "//span[normalize-space(text())='{$labelText}']"; |
|
$I->waitForElementVisible($span, 5); |
|
$I->click($span); |
|
} |
|
|
|
/* ------------ Matrix ------------ */ |
|
|
|
protected function matrixRowByHeaderTextXpath(string $matrixName, string $rowHeaderText): string |
|
{ |
|
$matrixRoot = "//div[contains(@class,'sd-question') and @data-name='{$matrixName}']"; |
|
return $matrixRoot . "//div[contains(@class,'sd-table__row')][.//div[contains(@class,'sd-table__cell')][1]//*[normalize-space(text())='{$rowHeaderText}']]"; |
|
} |
|
|
|
public function surveyMatrixSelectDropdownByRowAndColIndex(string $matrixName, string $rowHeaderText, int $colIndex, string $optionText): void |
|
{ |
|
$I = $this->wd(); |
|
$rowXpath = $this->matrixRowByHeaderTextXpath($matrixName, $rowHeaderText); |
|
$dropdownXpath = "(" . $rowXpath . "//div[contains(@class,'sd-input') and contains(@class,'sd-dropdown')])[$colIndex]"; |
|
$I->waitForElementVisible($dropdownXpath, 5); |
|
|
|
// Scope to the matrix question card (XPath) |
|
$questionCardXpath = "//div[(contains(@class,'sd-question') or contains(@class,'sd-element')) and @data-name='{$matrixName}']"; |
|
$containerXpath = $questionCardXpath . "//div[contains(@class,'sv-popup__container') and not(contains(@style,'display: none'))]"; |
|
|
|
$I->click($dropdownXpath); |
|
$this->clickPopupItemByExactText($optionText, 20, 300, $questionCardXpath); |
|
$I->waitForElementNotVisible($containerXpath, 5); |
|
} |
|
|
|
public function surveyMatrixClickCheckboxByRowAndValue(string $matrixName, string $rowHeaderText, string $value): void |
|
{ |
|
$I = $this->wd(); |
|
$rowXpath = $this->matrixRowByHeaderTextXpath($matrixName, $rowHeaderText); |
|
$label = $rowXpath . "//label[contains(@class,'sd-item__control-label')][.//input[@value='{$value}']]"; |
|
$I->waitForElementVisible($label, 5); |
|
$I->click($label); |
|
} |
|
|
|
public function surveyMatrixFillTextByRowAndColIndex(string $matrixName, string $rowHeaderText, int $colIndex, string $text): void |
|
{ |
|
$I = $this->wd(); |
|
$rowXpath = $this->matrixRowByHeaderTextXpath($matrixName, $rowHeaderText); |
|
$inputXpath = "(" . $rowXpath . "//input[contains(@class,'sd-input') and not(@type='hidden') and not(@readonly) and not(@disabled)])[{$colIndex}]"; |
|
$I->waitForElementVisible($inputXpath, 5); |
|
try { |
|
if (method_exists($I, 'clearField')) $I->clearField($inputXpath); |
|
if (method_exists($I, 'typeInField')) $I->typeInField($inputXpath, $text); |
|
else $I->fillField($inputXpath, $text); |
|
} catch (\Facebook\WebDriver\Exception\InvalidElementStateException $e) { |
|
$this->surveySetTextViaDomEvents($matrixName, $text, $inputXpath); |
|
} |
|
} |
|
|
|
/* ------------ Model fallback (API) ------------ */ |
|
|
|
/** |
|
* Set question value via SurveyJS model, bypassing DOM typing (last-resort). |
|
* Requires window.survey to be available in the page. |
|
*/ |
|
public function surveySetValueViaApi(string $name, $value): void |
|
{ |
|
$I = $this->wd(); |
|
$script = <<<JS |
|
(function() { |
|
var s = window.survey || window.SurveyCreator?.survey || window.Survey?.Model && window.survey; |
|
if (!window.survey && window.Survey && window.Survey.Model && !s) { |
|
// Try common global 'survey' |
|
s = window["survey"]; |
|
} |
|
if (!s) return false; |
|
s.setValue("$name", $value); |
|
return true; |
|
})(); |
|
JS; |
|
$ok = $I->executeJS($script); |
|
if (!$ok) { |
|
$I->fail("Survey model not found (window.survey). Cannot set value via API for '{$name}'."); |
|
} |
|
} |
|
|
|
/* ------------ Debug ------------ */ |
|
|
|
public function surveyDebugDumpQuestionHtml(string $name): void |
|
{ |
|
$I = $this->wd(); |
|
$rootCss = $this->qRoot($name); |
|
if (!$I->grabMultiple($rootCss)) { |
|
codecept_debug("Question root not found for: {$name}"); |
|
return; |
|
} |
|
$html = $I->executeJS("var el = document.querySelector(`" . addslashes($rootCss) . "`); return el ? el.outerHTML : null;"); |
|
codecept_debug("HTML for question '{$name}':\n" . $html); |
|
} |
|
|
|
/** |
|
* Fill a text input by searching for the label text, then finding the input in its parent container. |
|
* Useful when question name is not known but label is unique. |
|
*/ |
|
public function surveySetTextByLabel(string $labelText, string $value): void |
|
{ |
|
$I = $this->wd(); |
|
// 1. Find the label by its visible text (normalize-space for robustness) |
|
$labelXpath = "//label[normalize-space(string())='{$labelText}']"; |
|
$I->waitForElementVisible($labelXpath, 5); |
|
|
|
// 2. Go up to the parent container (usually the question or input wrapper) |
|
$parentXpath = $labelXpath . "/.."; |
|
|
|
// 3. Find the input inside the parent (not hidden/readonly/disabled) |
|
$inputXpath = $parentXpath . "//input[not(@type='hidden') and not(@readonly) and not(@disabled)]"; |
|
$I->waitForElementVisible($inputXpath, 5); |
|
|
|
// 4. Fill the input |
|
try { |
|
if (method_exists($I, 'clearField')) $I->clearField($inputXpath); |
|
if (method_exists($I, 'typeInField')) $I->typeInField($inputXpath, $value); |
|
else $I->fillField($inputXpath, $value); |
|
} catch (\Facebook\WebDriver\Exception\InvalidElementStateException $e) { |
|
// Fallback: JS set + events |
|
$script = <<<JS |
|
(function() { |
|
var xp = "$inputXpath"; |
|
var el = document.evaluate(xp, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |
|
if (!el) return false; |
|
var nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; |
|
nativeInputValueSetter.call(el, "$value"); |
|
el.dispatchEvent(new Event('input', { bubbles: true })); |
|
el.dispatchEvent(new Event('change', { bubbles: true })); |
|
return true; |
|
})(); |
|
JS; |
|
$ok = $I->executeJS($script); |
|
if (!$ok) { |
|
$I->fail("Unable to set text via DOM events for label '{$labelText}'."); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Select a dropdown option by searching for the label text, then clicking the dropdown input in its parent container. |
|
* Waits for the popup and selects the option by its visible text (span inside div inside li). |
|
* If the option is not visible, scrolls the popup container until found or max scrolls reached. |
|
* Useful when the question name is unknown but the label is unique. |
|
*/ |
|
public function surveySelectDropdownByLabel(string $labelText, string $optionText): void |
|
{ |
|
$I = $this->wd(); |
|
// 1. Find the label by its visible text |
|
$labelXpath = "//label[normalize-space(string())='{$labelText}']"; |
|
$I->waitForElementVisible($labelXpath, 5); |
|
|
|
// 2. Go up to the question card (the closest ancestor with .sd-question or .sd-element) |
|
$questionCardXpath = $labelXpath . "/ancestor::*[contains(@class,'sd-question') or contains(@class,'sd-element')][1]"; |
|
|
|
// 3. Find the dropdown input inside the card (not hidden/readonly/disabled) |
|
$dropdownXpath = $questionCardXpath . "//input[contains(@class,'sd-input') and contains(@class,'sd-dropdown') and not(@type='hidden') and not(@readonly) and not(@disabled)]"; |
|
$I->waitForElementVisible($dropdownXpath, 5); |
|
|
|
// 4. Click the dropdown to open the popup |
|
$I->click($dropdownXpath); |
|
|
|
// 5. Wait for options to appear |
|
usleep(500000); // 0.5 seconds |
|
|
|
// 6. Find the popup container inside the question card |
|
$containerXpath = $questionCardXpath . "//div[contains(@class,'sv-popup__container') and not(contains(@style,'display: none'))]"; |
|
|
|
// 7. Try to find and click the option span (span inside div inside li inside ul) |
|
$optionSpanXpath = $containerXpath . "//ul/li//div/span[normalize-space(text())='{$optionText}']"; |
|
$maxScrolls = 20; |
|
$scrollStep = 300; |
|
for ($i = 0; $i <= $maxScrolls; $i++) { |
|
if ($I->grabMultiple($optionSpanXpath)) { |
|
$I->click($optionSpanXpath); |
|
break; |
|
} |
|
// Always scroll the .sv-popup__scrolling-content container inside the matched popup |
|
$js = <<<JS |
|
(function() { |
|
var popup = document.evaluate("$containerXpath", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |
|
if (!popup) return false; |
|
var scrollable = popup.querySelector('.sv-popup__scrolling-content'); |
|
if (!scrollable) return false; |
|
var before = scrollable.scrollTop; |
|
scrollable.scrollTop = scrollable.scrollTop + $scrollStep; |
|
return scrollable.scrollTop !== before; |
|
})(); |
|
JS; |
|
$canScrollMore = $I->executeJS($js); |
|
if (!$canScrollMore) break; |
|
} |
|
|
|
// Try one last time after scrolling |
|
if ($I->grabMultiple($optionSpanXpath)) { |
|
$I->click($optionSpanXpath); |
|
} else { |
|
// Try scrollIntoView as a last resort |
|
$scrollIntoView = <<<JS |
|
(function() { |
|
var xpath = "$optionSpanXpath"; |
|
var res = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; |
|
if (res && res.scrollIntoView) { res.scrollIntoView({block:'center'}); return true; } |
|
return false; |
|
})(); |
|
JS; |
|
$I->executeJS($scrollIntoView); |
|
if ($I->grabMultiple($optionSpanXpath)) { |
|
$I->click($optionSpanXpath); |
|
} else { |
|
$I->fail("Dropdown option not found by text: {$optionText}"); |
|
} |
|
} |
|
|
|
// 8. Wait for popup to close |
|
$I->waitForElementNotVisible($containerXpath, 5); |
|
} |
|
|
|
/** |
|
* Set a checkbox (single) by aria-label, checking or unchecking as needed. |
|
* Clicks the label with the given aria-label inside the question card. |
|
* @param string $name Question name |
|
* @param string $ariaLabelText aria-label value of the label (checkbox option) |
|
* @param bool $checked Desired checked state (true = checked, false = unchecked) |
|
*/ |
|
public function surveySetCheckbox(string $name, string $ariaLabelText, bool $checked): void |
|
{ |
|
$I = $this->wd(); |
|
$rootXpath = "//div[contains(@class,'sd-question') and @data-name='{$name}']"; |
|
$labelXpath = $rootXpath . "//label[@aria-label=\"{$ariaLabelText}\"]"; |
|
$inputXpath = $labelXpath . "//input[@type='checkbox']"; |
|
codecept_debug("surveySetCheckbox: labelXpath = {$labelXpath}"); |
|
codecept_debug("surveySetCheckbox: inputXpath = {$inputXpath}"); |
|
$I->waitForElementVisible($labelXpath, 5); |
|
|
|
// Escape double quotes for JS string |
|
$jsInputXpath = str_replace('"', '\\"', $inputXpath); |
|
|
|
$isChecked = (bool)$I->executeJS( |
|
"var el = document.evaluate(\"$jsInputXpath\", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; return el ? el.checked : false;" |
|
); |
|
|
|
if ($isChecked !== $checked) { |
|
$I->click($labelXpath); |
|
} |
|
} |
|
} |