Skip to content

Instantly share code, notes, and snippets.

@Eseperio
Created October 7, 2025 08:50
Show Gist options
  • Save Eseperio/3dee3b851d80f11020e2eb3691b5dad9 to your computer and use it in GitHub Desktop.
Save Eseperio/3dee3b851d80f11020e2eb3691b5dad9 to your computer and use it in GitHub Desktop.

Codeception Acceptance Helper for SurveyJS

Handling interactivity with SurveyJS forms in Codeception acceptance tests can be tricky — so I built this helper to make it simple and reliable. It took a lot of effort to get right, but it works like a charm. 🧩

This helper lets you interact with SurveyJS forms as if you were a real user, filling in questions, navigating pages, and submitting surveys without fragile selectors or complex JavaScript hacks.


Installation

  1. Copy the helper file (e.g., SurveyJsHelper.php) into your Codeception helpers directory, usually:

    tests/_support/Helper/SurveyJsHelper.php
    
  2. Register the helper in your codeception.yml or your suite configuration (for example tests/acceptance.suite.yml):

    actor: AcceptanceTester
    modules:
      enabled:
        - WebDriver:
            url: http://your-app.test
            browser: chrome
        - \Helper\SurveyJsHelper

Usage

Once the helper is configured, you can use its methods directly in your acceptance tests. For example:

<?php
$I = new AcceptanceTester($scenario);
$I->wantTo('complete a survey using SurveyJS helper');

$I->amOnPage('/survey');
$I->fillSurveyJsQuestion('What is your name?', 'Alice');
$I->chooseSurveyJsOption('Do you like pizza?', 'Yes');
$I->goToNextSurveyPage();
$I->submitSurveyJs();
$I->see('Thank you for your response!');

Supported actions

These are some of the built-in actions provided by the helper:

Method Description
fillSurveyJsQuestion($questionTitle, $value) Fills in text, comment, or dropdown questions.
chooseSurveyJsOption($questionTitle, $optionLabel) Selects an option in single-choice or multiple-choice questions.
setSurveyJsValue($questionName, $value) Directly sets a value by SurveyJS question name.
goToNextSurveyPage() Clicks the “Next” button to advance the survey.
submitSurveyJs() Submits the entire survey.
seeSurveyJsQuestion($questionTitle) Verifies that a question is visible on the page.

Configuration options

You can configure default timeouts, selectors, or behaviors by extending the helper class or by defining custom properties in your suite’s configuration file, e.g.:

modules:
  config:
    \Helper\SurveyJsHelper:
      questionSelector: '[data-name]'
      nextButtonSelector: '.sv-next-btn'
      submitButtonSelector: '.sv-complete-btn'
      timeout: 10

Tips

  • Works best with SurveyJS v1.9+.
  • Make sure your survey is fully rendered before interacting with it — the helper automatically waits for the DOM to be ready.
  • You can combine this helper with WebDriver and Asserts for richer test flows.

License

MIT © 2025 — feel free to use and adapt it for your own testing setup.

<?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);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment