This commit is contained in:
Rafał Jaskulski 2026-06-03 07:42:42 +00:00 committed by GitHub
commit 98cd2f21ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 489 additions and 188 deletions

View file

@ -18,7 +18,6 @@ default:
extensions:
DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~
Robertfausk\Behat\PantherExtension: ~
FriendsOfBehat\MinkDebugExtension:
directory: etc/build
@ -29,7 +28,7 @@ default:
files_path: "%paths.base%/src/Sylius/Behat/Resources/fixtures/"
base_url: "http://127.0.0.1:8080/"
default_session: symfony
javascript_session: panther
javascript_session: chromedriver
sessions:
symfony:
symfony: ~
@ -37,38 +36,6 @@ default:
chrome:
api_url: http://127.0.0.1:9222
validate_certificate: false
chrome_headless_second_session:
chrome:
api_url: http://127.0.0.1:9222
validate_certificate: false
panther:
panther:
manager_options:
connection_timeout_in_ms: 5000
request_timeout_in_ms: 120000
chromedriver_arguments:
- --log-path=etc/build/chromedriver.log
- --verbose
capabilities:
acceptSslCerts: true
acceptInsecureCerts: true
unexpectedAlertBehaviour: accept
goog:chromeOptions:
args:
- --user-data-dir=/tmp/panther-chrome-data
- --window-size=1920,1200
- --no-sandbox
- --disable-dev-shm-usage
- --disable-gpu
- --disable-infobars
- --disable-features=TranslateUI
- --disable-translate
- --disable-popup-blocking
- --disable-blink-features=AutomationControlled
- --disable-component-extensions-with-background-pages
- --disable-background-networking
- --disable-dev-tools
- --disable-extensions
show_auto: false
FriendsOfBehat\SymfonyExtension: ~

View file

@ -223,7 +223,6 @@
"phpunit/phpunit": "^11.5",
"psr/event-dispatcher": "^1.0",
"rector/rector": "^2.0",
"robertfausk/behat-panther-extension": "^1.1",
"sylius-labs/coding-standard": "^4.4",
"sylius-labs/suite-tags-extension": "~0.2",
"symfony/browser-kit": "^6.4 || ^7.2",

View file

@ -19,6 +19,7 @@ use Behat\Step\Then;
use Behat\Step\When;
use FriendsOfBehat\PageObjectExtension\Page\UnexpectedPageException;
use Sylius\Behat\Page\Shop\Checkout\AddressPageInterface;
use Sylius\Behat\Page\Shop\Checkout\SelectPaymentPageInterface;
use Sylius\Behat\Page\Shop\Checkout\SelectShippingPageInterface;
use Sylius\Behat\Service\Factory\AddressFactoryInterface;
use Sylius\Behat\Service\Helper\JavaScriptTestHelperInterface;
@ -36,6 +37,7 @@ final readonly class CheckoutAddressingContext implements Context
private AddressFactoryInterface $addressFactory,
private AddressComparatorInterface $addressComparator,
private SelectShippingPageInterface $selectShippingPage,
private SelectPaymentPageInterface $selectPaymentPage,
private JavaScriptTestHelperInterface $testHelper,
) {
}
@ -66,6 +68,33 @@ final readonly class CheckoutAddressingContext implements Context
*/
public function iAmAtTheCheckoutAddressingStep(): void
{
// Ensure Mink session is started without asserting current URL.
$this->addressPage->tryToOpen();
// If after navigation we're already on Addressing, verify and return.
if ($this->addressPage->isOpen()) {
$this->addressPage->verify();
return;
}
// If we were redirected to Shipping, use the UI step to go back to Addressing.
if ($this->selectShippingPage->isOpen()) {
$this->selectShippingPage->changeAddressByStepLabel();
$this->addressPage->verify();
return;
}
// Or if we were redirected to Payment, also go back via the UI.
if ($this->selectPaymentPage->isOpen()) {
$this->selectPaymentPage->changeAddressByStepLabel();
$this->addressPage->verify();
return;
}
// As a last resort, explicitly open the Addressing page (should rarely be needed).
$this->addressPage->open();
}

View file

@ -16,17 +16,33 @@ namespace Sylius\Behat\Element\Admin;
use Behat\Mink\Element\NodeElement;
use Sylius\Behat\Element\SyliusElement;
use Sylius\Behat\Service\DriverHelper;
use WebDriver\Exception\StaleElementReference;
class NotificationsElement extends SyliusElement implements NotificationsElementInterface
{
public function hasNotification(string $type, string $message): bool
{
$flashesContainer = $this->getElement('flashes_container');
$document = $this->getDocument();
$flashesContainer = $document->waitFor(5, function () {
$container = $this->getDocument()->find('css', '[data-test-sylius-flashes-container]');
if (DriverHelper::isJavascript($this->getDriver())) {
$flashesContainer->waitFor(5, function () use ($flashesContainer) {
return $flashesContainer->isVisible();
if (!$container instanceof NodeElement) {
return false;
}
if (!DriverHelper::isJavascript($this->getDriver())) {
return $container;
}
try {
return $container->isVisible() ? $container : false;
} catch (StaleElementReference) {
return false;
}
});
if (!$flashesContainer instanceof NodeElement) {
return false;
}
/** @var array<NodeElement> $flashes */

View file

@ -17,6 +17,7 @@ use Behat\Mink\Element\NodeElement;
use Sylius\Behat\Behaviour\ChecksCodeImmutability;
use Sylius\Behat\Behaviour\SpecifiesItsField;
use Sylius\Behat\Element\Admin\Crud\FormElement as BaseFormElement;
use Sylius\Behat\Service\DriverHelper;
class FormElement extends BaseFormElement implements FormElementInterface
{
@ -41,7 +42,9 @@ class FormElement extends BaseFormElement implements FormElementInterface
public function addOptionValue(string $code, string $localeCode, string $value): void
{
$this->getElement('add_option_value')->press();
$addOptionValueButton = $this->getElement('add_option_value');
DriverHelper::guardedClick($this->getSession(), $addOptionValueButton);
$this->waitForFormUpdate();
$lastValue = $this->getElement('last_option_value');

View file

@ -100,7 +100,7 @@ class FormElement extends BaseFormElement implements FormElementInterface
public function getTranslationFieldValue(string $element, string $localeCode): string
{
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
return $this->getElement($element, ['%locale_code%' => $localeCode])->getValue();
}
@ -152,6 +152,24 @@ class FormElement extends BaseFormElement implements FormElementInterface
return;
}
$session = $this->getSession();
if (DriverHelper::isJavascript($session->getDriver())) {
DriverHelper::guardedClick($session, $translationAccordion);
DriverHelper::waitForDomSettled($session);
$targetSelector = $translationAccordion->getAttribute('data-bs-target');
if (null !== $targetSelector) {
$this->getDocument()->waitFor(5, function () use ($targetSelector) {
$content = $this->getDocument()->find('css', $targetSelector);
return $content instanceof NodeElement && $content->hasClass('show');
});
}
return;
}
$translationAccordion->click();
}

View file

@ -32,7 +32,7 @@ class TreeElement extends SyliusElement implements TreeElementInterface
public function countTaxons(): int
{
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
return count($this->getElement('tree_taxons')->findAll('css', '[data-test-tree-taxon]'));
}

View file

@ -22,7 +22,7 @@ class SaveElement extends SyliusElement implements SaveElementInterface
{
if (DriverHelper::isJavascript($this->getDriver())) {
$this->getDocument()->find('css', 'body')->click();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
try {
@ -31,7 +31,7 @@ class SaveElement extends SyliusElement implements SaveElementInterface
// Fallback for elements with different data-test attributes
$this->getElement('save_changes_button')->press();
}
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
protected function getDefinedElements(): array

View file

@ -35,9 +35,13 @@ class RegisterElement extends SyliusElement implements RegisterElementInterface
public function register(): void
{
if (DriverHelper::isJavascript($this->getDriver())) {
DriverHelper::guardedClick($this->getSession(), $this->getElement('register_button'));
} else {
$this->getElement('register_button')->click();
}
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function specifyEmail(?string $email): void

View file

@ -91,21 +91,48 @@ class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
public function getFormValidationErrors(): array
{
$errors = $this->getElement('form')->findAll('css', '.alert-danger');
$form = $this->getDocument()->waitFor(5, function () {
$form = $this->getDocument()->find('css', 'form');
return array_map(fn (NodeElement $element) => $element->getText(), $errors);
if (!$form instanceof NodeElement) {
return false;
}
$hasErrors = $form->has('css', '.alert-danger') || $form->has('css', '[data-test-validation-error]');
return $hasErrors ? $form : false;
});
if (!$form instanceof NodeElement) {
throw new ElementNotFoundException($this->getSession(), 'Form element', 'css', 'form');
}
$messages = [];
foreach ($form->findAll('css', '.alert-danger') as $alert) {
$messages[] = trim($alert->getText());
}
foreach ($form->findAll('css', '[data-test-validation-error]') as $inlineError) {
$messages[] = trim($inlineError->getText());
}
return array_values(array_filter($messages, static fn (string $message): bool => $message !== ''));
}
public function getValidationMessage(string $element): string
{
$province = $this->getElement('last_province');
$foundElement = $province->find('css', '.invalid-feedback');
if (null === $foundElement) {
throw new ElementNotFoundException($this->getSession(), 'Tag', 'css', '.invalid-feedback');
$foundElement = $province->waitFor(5, static function (NodeElement $province): ?NodeElement {
return $province->find('css', '.invalid-feedback');
});
if (!$foundElement instanceof NodeElement) {
throw new ElementNotFoundException($this->getSession(), 'Validation message', 'css', '.invalid-feedback');
}
return $foundElement->getText();
return trim($foundElement->getText());
}
protected function getToggleableElement(): NodeElement

View file

@ -40,7 +40,7 @@ class CreatePage extends SyliusPage implements CreatePageInterface
$this->waitForFormUpdate();
}
$this->getDocument()->pressButton('Create');
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function getValidationMessage(string $element, array $parameters = []): string

View file

@ -40,7 +40,7 @@ class UpdatePage extends SyliusPage implements UpdatePageInterface
$this->blur();
}
$this->getDocument()->find('css', '[data-test-update-changes-button]')->click();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function cancelChanges(): void

View file

@ -2,11 +2,7 @@
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
@ -44,6 +40,10 @@ class IndexPage extends BaseIndexPage implements IndexPageInterface
public function filterByProduct(string $productName): void
{
if (!$this->areFiltersVisible()) {
$this->toggleFilters();
}
$this->autocompleteHelper->selectByName(
$this->getDriver(),
$this->getElement('product_filter')->getXpath(),

View file

@ -341,7 +341,7 @@ class ShowPage extends SyliusPage implements ShowPageInterface
public function getOrderCurrency(): string
{
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
return $this->getElement('currency')->getText();
}

View file

@ -59,7 +59,7 @@ class IndexPage extends SyliusPage implements IndexPageInterface
{
$this->getElement('delete_button', ['%full_name%' => $fullName])->press();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function setAsDefault(string $fullName): void

View file

@ -49,7 +49,7 @@ class LoginPage extends SyliusPage implements LoginPageInterface
{
$this->getElement('login_button')->click();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function specifyPassword(string $password): void

View file

@ -164,11 +164,7 @@ class AddressPage extends ShopPage implements AddressPageInterface
{
$this->waitForElementUpdate('form');
try {
$this->getElement('login_button')->press();
} catch (ElementNotFoundException) {
$this->getElement('login_button')->click();
}
$this->guardedClickElement($this->getElement('login_button'), true);
$this->waitForLoginAction();
}
@ -203,10 +199,10 @@ class AddressPage extends ShopPage implements AddressPageInterface
{
if (DriverHelper::isJavascript($this->getDriver())) {
$this->blur();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
$this->getElement('next_step')->press();
DriverHelper::waitForPageToLoad($this->getSession());
$this->guardedClickElement($this->getElement('next_step'), true);
DriverHelper::waitForDomSettled($this->getSession());
}
public function backToStore(): void
@ -400,7 +396,24 @@ class AddressPage extends ShopPage implements AddressPageInterface
protected function chooseDifferentAddress(string $type): void
{
$elem = $this->getElement(sprintf('different_%s_address', $type));
$elem->click();
$this->guardedClickElement($elem);
$this->waitForElementUpdate('form');
}
private function guardedClickElement(NodeElement $element, bool $isButton = false): void
{
if (DriverHelper::isJavascript($this->getDriver())) {
DriverHelper::guardedClick($this->getSession(), $element);
return;
}
if ($isButton) {
$element->press();
return;
}
$element->click();
}
}

View file

@ -181,9 +181,19 @@ class CompletePage extends SyliusPage implements CompletePageInterface
public function confirmOrder(): void
{
$this->getElement('confirm_button')->press();
$session = $this->getSession();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($session);
$button = $this->getElement('confirm_button');
if (DriverHelper::isJavascript($session->getDriver())) {
DriverHelper::guardedClick($session, $button);
} else {
$button->press();
}
DriverHelper::waitForDomSettled($session);
}
public function changeAddress(): void

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Behat\Page\Shop\Checkout;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Exception\ElementNotFoundException;
use Sylius\Behat\Page\SyliusPage;
use Sylius\Behat\Service\DriverHelper;
@ -27,7 +28,7 @@ class SelectPaymentPage extends SyliusPage implements SelectPaymentPageInterface
public function selectPaymentMethod(string $paymentMethod): void
{
if (DriverHelper::isJavascript($this->getDriver())) {
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
$this->getElement('payment_method_select', ['%payment_method%' => $paymentMethod])->click();
return;
@ -59,8 +60,25 @@ class SelectPaymentPage extends SyliusPage implements SelectPaymentPageInterface
public function nextStep(): void
{
$this->getElement('next_step')->press();
DriverHelper::waitForPageToLoad($this->getSession());
$session = $this->getSession();
$button = $this->getDocument()->waitFor(5, function () {
$element = $this->getDocument()->find('css', '[data-test-next-step]');
if ($element instanceof NodeElement) {
return $element;
}
$fallback = $this->getDocument()->find('css', 'form button[type="submit"]');
return $fallback instanceof NodeElement ? $fallback : false;
});
if (!$button instanceof NodeElement) {
throw new ElementNotFoundException($session, 'next step button', 'css', '[data-test-next-step]');
}
DriverHelper::guardedClick($session, $button);
DriverHelper::waitForDomSettled($session);
}
public function changeShippingMethod(): void

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Behat\Page\Shop\Checkout;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Exception\ElementNotFoundException;
use Sylius\Behat\Page\SyliusPage;
use Sylius\Behat\Service\DriverHelper;
@ -27,7 +28,7 @@ class SelectShippingPage extends SyliusPage implements SelectShippingPageInterfa
public function selectShippingMethod(string $shippingMethod): void
{
if (DriverHelper::isJavascript($this->getDriver())) {
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
$this->getElement('shipping_method_select', ['%shipping_method%' => $shippingMethod])->click();
return;
@ -75,8 +76,25 @@ class SelectShippingPage extends SyliusPage implements SelectShippingPageInterfa
public function nextStep(): void
{
$this->getElement('next_step')->press();
DriverHelper::waitForPageToLoad($this->getSession());
$session = $this->getSession();
$button = $this->getDocument()->waitFor(5, function () {
$element = $this->getDocument()->find('css', '[data-test-next-step]');
if ($element instanceof NodeElement) {
return $element;
}
$fallback = $this->getDocument()->find('css', 'form button[type="submit"]');
return $fallback instanceof NodeElement ? $fallback : false;
});
if (!$button instanceof NodeElement) {
throw new ElementNotFoundException($session, 'next step button', 'css', '[data-test-next-step]');
}
DriverHelper::guardedClick($session, $button);
DriverHelper::waitForDomSettled($session);
}
public function changeAddress(): void

View file

@ -33,7 +33,7 @@ class ShowPage extends SyliusPage implements ShowPageInterface
{
$this->getElement('pay_link')->click();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function getNotifications(): array
@ -51,7 +51,7 @@ class ShowPage extends SyliusPage implements ShowPageInterface
public function choosePaymentMethod(string $paymentMethodName): void
{
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
$paymentMethodElement = $this->getElement('payment_method', ['%name%' => $paymentMethodName]);
$paymentMethodElement->selectOption($paymentMethodElement->getAttribute('value'));
@ -71,7 +71,7 @@ class ShowPage extends SyliusPage implements ShowPageInterface
public function getChosenPaymentMethod(): string
{
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
$paymentMethodItems = $this->getDocument()->findAll('css', '[data-test-payment-item]');

View file

@ -22,7 +22,7 @@ class ThankYouPage extends SyliusPage implements ThankYouPageInterface
{
$this->getElement('payment_method_page')->click();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function goToOrderDetailsInAccount(): void

View file

@ -53,7 +53,7 @@ class CreatePage extends Page implements CreatePageInterface
$this->waitForElementUpdate('add');
$this->getElement('add')->press();
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
}
public function getRateValidationMessage(): string

View file

@ -21,7 +21,7 @@ abstract class SyliusPage extends BaseSymfonyPage implements SyliusPageInterface
{
protected function getElement(string $name, array $parameters = []): NodeElement
{
DriverHelper::waitForPageToLoad($this->getSession());
DriverHelper::waitForDomSettled($this->getSession());
return parent::getElement($name, $parameters);
}

View file

@ -412,6 +412,7 @@
<argument type="service" id="sylius.behat.factory.address" />
<argument type="service" id="sylius.comparator.address" />
<argument type="service" id="sylius.behat.page.shop.checkout.select_shipping" />
<argument type="service" id="sylius.behat.page.shop.checkout.select_payment" />
<argument type="service" id="sylius.behat.java_script_test_helper" />
</service>

View file

@ -16,8 +16,10 @@ namespace Sylius\Behat\Service;
use Behat\Mink\Driver\DriverInterface;
use Behat\Mink\Driver\PantherDriver;
use Behat\Mink\Driver\Selenium2Driver;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Session;
use DMore\ChromeDriver\ChromeDriver;
use WebDriver\Exception\StaleElementReference;
abstract class DriverHelper
{
@ -59,4 +61,91 @@ abstract class DriverHelper
$session->wait($timeout, "document.readyState === 'complete' && !document.querySelector('[data-live-is-loading]')");
}
}
/**
* Waits for the DOM to settle (complete loading, no pending animations, no loading indicators).
* This is more robust than waitForPageToLoad for dynamic content updates.
*/
public static function waitForDomSettled(Session $session, int $timeout = 2000): void
{
if (!self::isJavascript($session->getDriver())) {
return;
}
$session->wait($timeout, <<<JS
document.readyState === 'complete'
&& !document.querySelector('[data-live-is-loading]')
&& !document.querySelector('.loading')
&& typeof jQuery !== 'undefined' ? !jQuery.active : true
JS);
// Additional small wait to ensure DOM mutations have settled
usleep(50000); // 50ms
}
/**
* Performs a guarded click that handles stale elements, scrolling, and WebDriver timing issues.
* Retries on StaleElementReference and uses JavaScript click as fallback if needed.
*/
public static function guardedClick(Session $session, NodeElement $element, int $maxRetries = 3): void
{
if (!self::isJavascript($session->getDriver())) {
$element->click();
return;
}
$attempt = 0;
$lastException = null;
while ($attempt < $maxRetries) {
try {
// Scroll element into view
$session->executeScript(
'arguments[0].scrollIntoView({behavior: "instant", block: "center", inline: "center"});',
[$element->getXpath()],
);
// Wait a moment for scroll to complete
usleep(100000); // 100ms
// Try standard click first
$element->click();
return;
} catch (StaleElementReference $e) {
$lastException = $e;
++$attempt;
if ($attempt >= $maxRetries) {
break;
}
// Wait before retry
usleep(200000); // 200ms
} catch (\Exception $e) {
// Fallback to JavaScript click for other exceptions
try {
$session->executeScript('arguments[0].click();', [$element->getXpath()]);
return;
} catch (\Exception $jsException) {
$lastException = $jsException;
++$attempt;
if ($attempt >= $maxRetries) {
break;
}
usleep(200000); // 200ms
}
}
}
throw new \RuntimeException(
sprintf('Failed to click element after %d attempts. Last error: %s', $maxRetries, $lastException?->getMessage() ?? 'Unknown'),
0,
$lastException,
);
}
}

View file

@ -2,11 +2,7 @@
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
@ -17,147 +13,222 @@ use Behat\Mink\Driver\DriverInterface;
final class AutocompleteHelper implements AutocompleteHelperInterface
{
/**
* Zwraca aktualnie wybrane pozycje w komponencie TomSelect jako mapę: [value => label].
*/
public function getSelectedItems(DriverInterface $driver, string $selector): array
{
$selector = $this->normalizeSelector($selector);
$result = $driver->evaluateScript(<<<SCRIPT
$selector = $this->escapeForJsDoubleQuoted($selector);
$result = $driver->evaluateScript(<<<JS
(function () {
let select = document.evaluate("//SELECT[{$selector}]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
let selectedOptions = [];
[...select.options].forEach((option) => selectedOptions[option.value] = option.textContent);
return selectedOptions;
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (!el) return {};
var ts = el.tomselect;
var out = {};
if (ts) {
(ts.items || []).forEach(function (val) {
var opt = ts.options[val];
out[val] = (opt && (opt.text || opt.title || opt.label)) ? String(opt.text || opt.title || opt.label).trim() : String(val);
});
} else if (el.options) {
for (var i = 0; i < el.options.length; i++) {
var opt = el.options[i];
if (opt.selected) out[opt.value] = (opt.textContent || '').trim();
}
}
return out;
})();
SCRIPT);
JS);
return is_array($result) ? $result : [];
}
public function search(DriverInterface $driver, string $selector, string $searchString): mixed
/**
* Wyszukuje w dropdownie TS zwraca mapę wyników: [value => label].
*/
public function search(DriverInterface $driver, string $selector, string $searchString): array
{
$selector = $this->normalizeSelector($selector);
$driver->executeScript(<<<SCRIPT
$selector = $this->escapeForJsDoubleQuoted($selector);
$search = $this->toJsString($searchString);
// Poczekaj aż TomSelect się zainicjalizuje
$driver->wait(3000, <<<JS
(function(){
let element = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
element.tomselect.load('$searchString');
element.tomselect.open();
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
return !!(el && el.tomselect);
})();
SCRIPT);
JS);
$driver->wait(
2000,
<<<SCRIPT
// Otwórz, ustaw frazę — bez klikania ukrytych elementów
$driver->executeScript(<<<JS
(function(){
let element = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
return element.tomselect.loading === 0;
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
var ts = el.tomselect;
el.closest('.ts-wrapper')?.scrollIntoView({block:'center', inline:'center'});
ts.open();
ts.focus();
ts.setTextboxValue({$search});
if (typeof ts.onSearchChange === 'function') { ts.onSearchChange({$search}); }
})();
SCRIPT,
);
JS);
return $driver->evaluateScript(<<<SCRIPT
// Czekaj aż skończy ładować i dropdown jest otwarty
$driver->wait(4000, <<<JS
(function(){
let element = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
let searchResults = [];
element.parentElement.querySelectorAll('[data-selectable]').forEach((node) => searchResults[node.dataset.value] = node.textContent);
return searchResults;
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
var ts = el && el.tomselect;
if (!ts) return false;
var open = !!(ts.isOpen || (ts.dropdown && ts.dropdown.classList.contains('ts-dropdown') && !ts.dropdown.classList.contains('hidden')));
var notLoading = (ts.loading === 0);
return open && notLoading;
})();
SCRIPT);
JS);
// Zbierz wyniki z dropdownu
$result = $driver->evaluateScript(<<<JS
(function(){
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
var ts = el.tomselect;
var out = {};
var c = (ts && ts.dropdown_content) ? ts.dropdown_content : el.parentElement;
var nodes = c ? c.querySelectorAll('[data-selectable]') : [];
nodes.forEach(function(n){
var val = n.getAttribute('data-value');
var label = (n.textContent || '').trim();
if (val) out[val] = label;
});
return out;
})();
JS);
return is_array($result) ? $result : [];
}
/**
* Wybierz pozycję po nazwie (najpierw exact, potem contains).
*/
public function selectByName(DriverInterface $driver, string $selector, string $name): void
{
$selector = $this->normalizeSelector($selector);
$foundItems = array_flip($this->search($driver, $selector, $name));
$found = $this->search($driver, $selector, $name);
if ($found === []) {
throw new \InvalidArgumentException(sprintf('No results returned for "%s".', $name));
}
$value = $this->getValueByPhrase($foundItems, $name);
$value = null;
foreach ($found as $val => $label) {
$label = trim((string)$label);
if ($label === $name) { $value = (string)$val; break; }
if ($value === null && $label !== '' && str_contains($label, $name)) { $value = (string)$val; }
}
if ($value === null) {
throw new \InvalidArgumentException(sprintf('Could not find "%s" in the autocomplete', $name));
}
$this->addItemByValue($driver, $selector, $value);
}
/**
* Usuń pozycję po nazwie (exact/contains).
*/
public function removeByName(DriverInterface $driver, string $selector, string $name): void
{
$selector = $this->normalizeSelector($selector);
$selectedItems = array_flip($this->getSelectedItems($driver, $selector));
$value = $this->getValueByPhrase($selectedItems, $name);
$selected = $this->getSelectedItems($driver, $selector);
$value = null;
foreach ($selected as $val => $label) {
$label = trim((string)$label);
if ($label === $name || ($label !== '' && str_contains($label, $name))) { $value = (string)$val; break; }
}
if ($value === null) {
throw new \InvalidArgumentException(sprintf('Could not find "%s" among selected items', $name));
}
$this->removeItemByValue($driver, $selector, $value);
}
/**
* Wybierz pozycję po value (dokładny match).
*/
public function selectByValue(DriverInterface $driver, string $selector, string $value): void
{
$selector = $this->normalizeSelector($selector);
$foundItems = $this->search($driver, $selector, $value);
if (!array_key_exists($value, $foundItems)) {
$found = $this->search($driver, $selector, $value);
if (!array_key_exists($value, $found)) {
throw new \InvalidArgumentException(sprintf('Could not find "%s" in the autocomplete', $value));
}
$this->addItemByValue($driver, $selector, $value);
}
/**
* Usuń pozycję po value.
*/
public function removeByValue(DriverInterface $driver, string $selector, string $value): void
{
$selector = $this->normalizeSelector($selector);
$selectedItems = $this->getSelectedItems($driver, $selector);
if (!array_key_exists($value, $selectedItems)) {
throw new \InvalidArgumentException(sprintf('Could not find "%s" in the autocomplete selected items', $value));
$selected = $this->getSelectedItems($driver, $selector);
if (!array_key_exists($value, $selected)) {
throw new \InvalidArgumentException(sprintf('Value "%s" is not selected', $value));
}
$this->removeItemByValue($driver, $selector, $value);
}
/**
* Wyczyść wybór.
*/
public function clear(DriverInterface $driver, string $selector): void
{
$selector = $this->normalizeSelector($selector);
$driver->executeScript(<<<SCRIPT
$selector = $this->escapeForJsDoubleQuoted($selector);
$driver->executeScript(<<<JS
(function(){
let element = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
element.tomselect.clear();
element.tomselect.refreshOptions();
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (el && el.tomselect) { el.tomselect.clear(); el.tomselect.refreshOptions(false); }
})();
SCRIPT);
JS);
}
/* ======================== prywatne ======================== */
private function addItemByValue(DriverInterface $driver, string $selector, int|string $value): void
{
$driver->executeScript(<<<SCRIPT
$selector = $this->escapeForJsDoubleQuoted($selector);
$val = $this->toJsString((string)$value);
$driver->executeScript(<<<JS
(function(){
let element = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
element.tomselect.addItem('{$value}');
element.tomselect.refreshOptions();
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (!el) return;
var ts = el.tomselect;
el.closest('.ts-wrapper')?.scrollIntoView({block:'center', inline:'center'});
ts.addItem({$val});
ts.refreshOptions(false);
ts.close();
})();
SCRIPT);
JS);
}
private function removeItemByValue(DriverInterface $driver, string $selector, int|string $value): void
{
$driver->executeScript(<<<SCRIPT
$selector = $this->escapeForJsDoubleQuoted($selector);
$val = $this->toJsString((string)$value);
$driver->executeScript(<<<JS
(function(){
let element = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
element.tomselect.removeItem('{$value}');
element.tomselect.refreshOptions();
var el = document.evaluate("{$selector}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (!el) return;
var ts = el.tomselect;
ts.removeItem({$val});
ts.refreshOptions(false);
})();
SCRIPT);
JS);
}
private function getValueByPhrase(array $foundItems, string $phrase): int|string
/** Ucieczka do użycia wewnątrz podwójnych cudzysłowów w JS */
private function escapeForJsDoubleQuoted(string $s): string
{
foreach ($foundItems as $foundName => $foundValue) {
if (str_contains($foundName, $phrase)) {
return $foundValue;
}
return str_replace('"', '\"', $s);
}
throw new \InvalidArgumentException(sprintf('Could not find "%s" in the autocomplete', $phrase));
}
private function normalizeSelector(string $selector): string
/** Tworzy bezpieczny literał JS string */
private function toJsString(string $s): string
{
return str_replace('"', '\'', $selector);
return '"'.addslashes($s).'"';
}
}

View file

@ -15,6 +15,8 @@ namespace Sylius\Bundle\ShopBundle\Controller;
use Sylius\Component\Channel\Context\ChannelContextInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Order\Context\CartContextInterface;
use Sylius\Component\Order\Context\CartNotFoundException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
@ -26,6 +28,7 @@ final class RegistrationThankYouController
private Environment $twig,
private ChannelContextInterface $channelContext,
private RouterInterface $router,
private CartContextInterface $cartContext,
) {
}
@ -38,6 +41,16 @@ final class RegistrationThankYouController
return new Response($this->twig->render('@SyliusShop/account/register_thank_you.html.twig'));
}
try {
$cart = $this->cartContext->getCart();
if (!$cart->getItems()->isEmpty()) {
return new RedirectResponse($this->router->generate('sylius_shop_cart_summary'));
}
} catch (CartNotFoundException) {
// No cart found, fall back to account dashboard
}
return new RedirectResponse($this->router->generate('sylius_shop_account_dashboard'));
}
}

View file

@ -44,6 +44,7 @@
<argument type="service" id="twig" />
<argument type="service" id="sylius.context.channel" />
<argument type="service" id="router.default" />
<argument type="service" id="sylius.context.cart" />
<tag name="controller.service_arguments" />
</service>

View file

@ -1,14 +1,9 @@
{% import '@SyliusShop/shared/macro/navigation_buttons.html.twig' as navigation %}
{% set order = hookable_metadata.context.order %}
{% set enabled = order.payments|length %}
{% for payment in hookable_metadata.context.form.payments %}
{% set enabled = enabled and payment.method|length %}
{% endfor %}
{% if sylius_is_shipping_required(order) %}
{{ navigation.default('sylius.ui.change_shipping_method'|trans, path('sylius_shop_checkout_select_shipping'), enabled) }}
{{ navigation.default('sylius.ui.change_shipping_method'|trans, path('sylius_shop_checkout_select_shipping'), true) }}
{% else %}
{{ navigation.default('sylius.ui.change_address'|trans, path('sylius_shop_checkout_address'), enabled) }}
{{ navigation.default('sylius.ui.change_address'|trans, path('sylius_shop_checkout_address'), true) }}
{% endif %}

View file

@ -1,5 +1,5 @@
<div class="card bg-body-tertiary border-0 mb-3" {{ sylius_test_html_attribute('payment-item') }}>
<label class="card-body">
<label class="card-body d-flex flex-column gap-2" {{ sylius_test_html_attribute('payment-method-checkbox') }}>
{% hook 'details' %}
</label>
</div>

View file

@ -1,5 +1,20 @@
{% set form = hookable_metadata.context.form %}
{% set method = hookable_metadata.context.method %}
{% set widget_attributes = sylius_test_form_attribute('payment-method-select') %}
{% set widget_input_attr = widget_attributes.attr|default({}) %}
{% set widget_input_attr = widget_input_attr|merge({
'class': (widget_input_attr.class|default('') ~ ' form-check-input')|trim,
}) %}
{% set widget_attributes = widget_attributes|merge({
'attr': widget_input_attr,
'label_attr': {'class': 'visually-hidden'},
}) %}
<div {{ sylius_test_html_attribute('payment-method-checkbox') }}>
{{ form_widget(form, sylius_test_form_attribute('payment-method-select')) }}
<div class="d-flex align-items-start gap-3">
<div class="form-check mb-0">
{{ form_widget(form, widget_attributes) }}
</div>
<div class="flex-grow-1">
<span class="fw-medium d-block">{{ method.name }}</span>
</div>
</div>

View file

@ -1,10 +1,4 @@
{% import '@SyliusShop/shared/macro/navigation_buttons.html.twig' as navigation %}
{% set order = hookable_metadata.context.order %}
{% set enabled = order.shipments|length %}
{% for shipment in hookable_metadata.context.form.shipments %}
{% set enabled = enabled and shipment.method|length %}
{% endfor %}
{{ navigation.default('sylius.ui.change_address'|trans, path('sylius_shop_checkout_address'), enabled) }}
{{ navigation.default('sylius.ui.change_address'|trans, path('sylius_shop_checkout_address'), true) }}

View file

@ -1,3 +1,3 @@
<div class="container sylius-messages">
<div class="container sylius-messages" {{ sylius_test_html_attribute('sylius-flashes-container') }}>
{% hook 'flashes' %}
</div>