├── Resources ├── Private │ ├── Fusion │ │ ├── Root.fusion │ │ ├── Component │ │ │ ├── Molecule │ │ │ │ ├── TabNavigation.fusion │ │ │ │ ├── Tab.fusion │ │ │ │ └── TabPane.fusion │ │ │ ├── Organism │ │ │ │ └── Tabs.fusion │ │ │ └── ABTestingContainer │ │ │ │ ├── TestingCaseRenderer.fusion │ │ │ │ └── BackendView.fusion │ │ └── NodeTypes │ │ │ └── ABTestingContainer.fusion │ ├── Templates │ │ └── Module │ │ │ └── AbTesting │ │ │ ├── FeatureModule │ │ │ ├── Index.html │ │ │ ├── ShowPages.html │ │ │ ├── DeleteDecision.html │ │ │ ├── EditDecision.html │ │ │ ├── AddDecisionToFeature.html │ │ │ ├── DeleteFeature.html │ │ │ ├── NewFeature.html │ │ │ ├── ShowFeature.html │ │ │ ├── EditFeature.html │ │ │ ├── ChooseDecider.html │ │ │ └── ListFeatures.html │ │ │ └── Index.html │ ├── Partials │ │ ├── DeciderInputSections │ │ │ ├── PercentageDecider.html │ │ │ └── PercentageAbcDecider.html │ │ ├── DecisionInputSection.html │ │ ├── DecisionInput.html │ │ ├── Module │ │ │ └── FeatureModule │ │ │ │ ├── PageList.html │ │ │ │ ├── DeciderSection.html │ │ │ │ └── DecisionList.html │ │ ├── PageList.html │ │ └── DeciderList.html │ └── Script │ │ └── AbTesting.js └── Public │ └── JavaScript │ └── AbTesting.js ├── Documentation ├── feature-list.jpg ├── ab-testing-container.jpg └── ab-testing-frontend.jpg ├── Configuration ├── Objects.yaml ├── NodeTypes.ABTestingContainer.yaml ├── NodeTypes.Content.NodeReference.yaml ├── NodeTypes.Mixins.yaml ├── Policy.yaml ├── Views.yaml └── Settings.yaml ├── Classes ├── Domain │ ├── Comparator │ │ ├── ComparatorInterface.php │ │ ├── FixedValueComparator.php │ │ ├── RandomComparator.php │ │ ├── HexToIntDowncast.php │ │ └── UserIdentifierComparator.php │ ├── Repository │ │ ├── DecisionRepository.php │ │ └── FeatureRepository.php │ ├── Decider │ │ ├── PercentageAbcDecider.php │ │ ├── DeciderInterface.php │ │ └── PercentageDecider.php │ ├── Session │ │ └── ABTestingSession.php │ ├── Dto │ │ └── DeciderObject.php │ ├── Model │ │ ├── Feature.php │ │ └── Decision.php │ ├── Factory │ │ └── DeciderFactory.php │ ├── DataSource │ │ ├── Tests.php │ │ └── Features.php │ ├── Service │ │ ├── FeatureService.php │ │ └── DecisionService.php │ └── Http │ │ └── Middleware │ │ └── AbTestingCookieMiddleware.php ├── Controller │ └── Module │ │ ├── AbTestingController.php │ │ └── AbTesting │ │ └── FeatureModuleController.php ├── ViewHelpers │ └── ClassNameViewHelper.php └── Eel │ ├── FeatureHelper.php │ └── DecisionHelper.php ├── composer.json ├── LICENSE ├── Migrations ├── Mysql │ ├── Version20211125113158.php │ ├── Version20190902152414.php │ ├── Version20190902140817.php │ ├── Version20190830132402.php │ └── Version20180703153541.php └── Postgresql │ └── Version20230301144616.php ├── README.md └── Tests └── Unit └── Domain └── Decider ├── AbstractPercentageDeciderTest.php └── PercentageDeciderTest.php /Resources/Private/Fusion/Root.fusion: -------------------------------------------------------------------------------- 1 | include: '**/*' 2 | -------------------------------------------------------------------------------- /Documentation/feature-list.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wysiwyg-software-design/Wysiwyg.ABTesting/HEAD/Documentation/feature-list.jpg -------------------------------------------------------------------------------- /Configuration/Objects.yaml: -------------------------------------------------------------------------------- 1 | Wysiwyg\ABTesting\Domain\Decider\PercentageDecider: 2 | properties: 3 | deciderName: 4 | value: 'Percentage' 5 | -------------------------------------------------------------------------------- /Documentation/ab-testing-container.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wysiwyg-software-design/Wysiwyg.ABTesting/HEAD/Documentation/ab-testing-container.jpg -------------------------------------------------------------------------------- /Documentation/ab-testing-frontend.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wysiwyg-software-design/Wysiwyg.ABTesting/HEAD/Documentation/ab-testing-frontend.jpg -------------------------------------------------------------------------------- /Classes/Domain/Comparator/ComparatorInterface.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/Index.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Classes/Controller/Module/AbTestingController.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Component/Molecule/TabNavigation.fusion: -------------------------------------------------------------------------------- 1 | prototype(Wysiwyg.AbTesting:Component.Molecule.TabNavigation) < prototype(Neos.Fusion:Component) { 2 | /** 3 | PROPS: 4 | - tabs 5 | */ 6 | tabs = '' 7 | 8 | renderer = afx` 9 | 12 | ` 13 | } 14 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Component/Molecule/Tab.fusion: -------------------------------------------------------------------------------- 1 | prototype(Wysiwyg.AbTesting:Component.Molecule.Tab) < prototype(Neos.Fusion:Component) { 2 | target = '' 3 | text = '' 4 | active = false 5 | 6 | renderer = afx` 7 |
  • 8 | {props.text} 9 |
  • 10 | ` 11 | } 12 | -------------------------------------------------------------------------------- /Resources/Private/Partials/DecisionInputSection.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 7 | 8 |
    9 |
    10 | -------------------------------------------------------------------------------- /Classes/Domain/Decider/PercentageAbcDecider.php: -------------------------------------------------------------------------------- 1 | 'a', 'b' => 'b', 'c' => 'c']; 8 | 9 | public function getTitle() 10 | { 11 | return 'PercentageAbcDecider'; 12 | } 13 | 14 | public function __toString() 15 | { 16 | return PercentageAbcDecider::class; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Configuration/NodeTypes.ABTestingContainer.yaml: -------------------------------------------------------------------------------- 1 | 'Wysiwyg.ABTesting:ABTestingContainer': 2 | superTypes: 3 | 'Neos.Neos:Content': true 4 | 'Wysiwyg.ABTesting:ABTestingMixin': true 5 | ui: 6 | label: 'A/B Testing Container' 7 | inlineEditable: true 8 | childNodes: 9 | itemsa: 10 | type: 'Neos.Neos:ContentCollection' 11 | itemsb: 12 | type: 'Neos.Neos:ContentCollection' 13 | itemsc: 14 | type: 'Neos.Neos:ContentCollection' 15 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Component/Organism/Tabs.fusion: -------------------------------------------------------------------------------- 1 | prototype(Wysiwyg.AbTesting:Component.Organism.Tabs) < prototype(Neos.Fusion:Component) { 2 | /** 3 | PROPS: 4 | - tabs 5 | */ 6 | tabNavigation = '' 7 | tabContent = '' 8 | 9 | renderer = afx` 10 |
    11 | {props.tabNavigation} 12 |
    13 | {props.tabContent} 14 |
    15 |
    16 | ` 17 | } 18 | -------------------------------------------------------------------------------- /Classes/Domain/Decider/DeciderInterface.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 |
    10 | 11 |
    12 | 15 |
    16 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Component/Molecule/TabPane.fusion: -------------------------------------------------------------------------------- 1 | prototype(Wysiwyg.AbTesting:Component.Molecule.TabPane) < prototype(Neos.Fusion:Component) { 2 | /** 3 | PROPS: 4 | - version 5 | - content 6 | - active 7 | */ 8 | id = '' 9 | content = '' 10 | active = false 11 | 12 | renderer = afx` 13 |
    14 | {props.content} 15 |
    16 | ` 17 | } 18 | -------------------------------------------------------------------------------- /Resources/Private/Partials/DeciderInputSections/PercentageAbcDecider.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wy/neos-abtesting", 3 | "description": "A/B Testing for Neos", 4 | "license": "MIT", 5 | "type": "neos-plugin", 6 | "require": { 7 | "php": "^8.1", 8 | "neos/neos": "^8.0" 9 | }, 10 | "autoload": { 11 | "psr-4": { 12 | "Wysiwyg\\ABTesting\\": "Classes/" 13 | } 14 | }, 15 | "archive": { 16 | "exclude": ["Documentation/"] 17 | }, 18 | "extra": { 19 | "neos": { 20 | "package-key": "Wysiwyg.ABTesting" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Classes/Domain/Comparator/FixedValueComparator.php: -------------------------------------------------------------------------------- 1 | comparisonValue = $comparisonValue; 17 | } 18 | 19 | public function getComparisonValue(): int 20 | { 21 | return $this->comparisonValue; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Classes/ViewHelpers/ClassNameViewHelper.php: -------------------------------------------------------------------------------- 1 | createQuery(); 20 | 21 | $flowQuery->matching($flowQuery->equals('active', 1)); 22 | 23 | return $flowQuery->execute()->toArray(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Configuration/NodeTypes.Mixins.yaml: -------------------------------------------------------------------------------- 1 | 'Wysiwyg.ABTesting:ABTestingMixin': 2 | abstract: TRUE 3 | superTypes: 4 | 'Wysiwyg.ABTesting:Mixins.FeatureProperty': true 5 | ui: 6 | inspector: 7 | groups: 8 | abTesting: 9 | label: 'A / B Testing' 10 | icon: 'icon-eye' 11 | 12 | 'Wysiwyg.ABTesting:Mixins.FeatureProperty': 13 | abstract: TRUE 14 | properties: 15 | abTest: 16 | type: string 17 | defaultValue: '' 18 | ui: 19 | label: 'A/B Test Feature' 20 | inspector: 21 | editor: 'Neos.Neos/Inspector/Editors/SelectBoxEditor' 22 | position: 10 23 | group: abTesting 24 | editorOptions: 25 | dataSourceIdentifier: 'wysiwyg-abtesting-tests' 26 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/DeleteDecision.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 | 6 | 7 | 8 |
    9 |

    Are you sure, you want to delete the decision?

    10 |
    11 | 15 |
    16 |
    17 | 18 | 19 | -------------------------------------------------------------------------------- /Classes/Domain/Comparator/RandomComparator.php: -------------------------------------------------------------------------------- 1 | comparisonValue !== null) { 20 | return $this->comparisonValue; 21 | } 22 | 23 | $randomBytes = random_bytes(3); 24 | $hexadecimalBytes = bin2hex($randomBytes); 25 | 26 | $this->comparisonValue = HexToIntDowncast::sixDigitHexToPercentageInteger($hexadecimalBytes); 27 | return $this->comparisonValue; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Classes/Domain/Comparator/HexToIntDowncast.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
    4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 15 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/NodeTypes/ABTestingContainer.fusion: -------------------------------------------------------------------------------- 1 | prototype(Wysiwyg.ABTesting:ABTestingContainer) < prototype(Neos.Neos:ContentComponent) { 2 | testingDecision = ${Wysiwyg.ABTesting.Decisions.getDecisionForFeatureByIdentifier(String.toString(q(node).property('abTest')), request.arguments.forceABVersion)} 3 | abTestFeature = ${Wysiwyg.ABTesting.Features.getFeatureById(q(node).property('abTest'))} 4 | 5 | renderer = afx` 6 |
    7 | 8 |
    9 | ` 10 | 11 | @cache { 12 | mode = 'uncached' 13 | 14 | context { 15 | 1 = 'node' 16 | 2 = 'documentNode' 17 | 3 = 'site' 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Resources/Public/JavaScript/AbTesting.js: -------------------------------------------------------------------------------- 1 | (()=>{var i={abTestingCookieString:"",cookieName:document.body.dataset.abTestingCookieName||"WYSIWYG_AB_TESTING",abTestingObject:{},init:function(){this.abTestingCookieString=this.getCookie(),this.abTestingObject=JSON.parse(this.abTestingCookieString||"{}")},getCookie:function(){let t=document.cookie.match(new RegExp("(^| )"+this.cookieName+"=([^;]+)"));if(t)return decodeURIComponent(t[2])},getTrackingStringsArrayForAllFeatures:function(){let t=[];for(let e in this.abTestingObject)t.push(this.getTrackingStringForFeature(e));return t},getTrackingStringForFeature:function(t){return t+"_"+this.getDecisionForFeature(t)},getDecisionsForAllFeatures:function(){return this.abTestingObject},getDecisionForFeature:function(t){if(t in this.abTestingObject)return this.abTestingObject[t]}};window.WY=window.WY||{};window.WY.AbTesting=i;window.addEventListener("load",function(){window.WY.AbTesting.init()});})(); 2 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/EditDecision.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 | 6 |

    7 | Edit {decision.decider.title} conditions 8 |

    9 |
    10 | 11 | 12 | 13 | 14 |
    15 | 16 |
    17 | 21 |
    22 |
    23 | 24 | 25 | -------------------------------------------------------------------------------- /Configuration/Policy.yaml: -------------------------------------------------------------------------------- 1 | privilegeTargets: 2 | 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': 3 | 'Wysiwyg.ABTesting:Module.AbTesting': 4 | matcher: 'method(Wysiwyg\ABTesting\Controller\Module\AbTestingController->(?(? 3 | 4 | 5 | Add Decision to Feature {feature.featureName} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 15 |
    16 | 17 | 21 |
    22 |
    23 | -------------------------------------------------------------------------------- /Resources/Private/Partials/Module/FeatureModule/PageList.html: -------------------------------------------------------------------------------- 1 |
    2 | Assigned Pages 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 27 |
    10 | {page.properties.title} 11 | 13 | 14 |
    21 | No pages assigned for this feature. 22 |
    28 |
    29 | -------------------------------------------------------------------------------- /Resources/Private/Partials/PageList.html: -------------------------------------------------------------------------------- 1 |
    2 | Assigned Pages 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 |
    Page
    15 | {page.properties.title} 16 | 18 | 19 |
    26 | No pages assigned for this test. 27 |
    33 |
    34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 wysiwyg software design GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Resources/Private/Fusion/Component/ABTestingContainer/TestingCaseRenderer.fusion: -------------------------------------------------------------------------------- 1 | prototype(Wysiwyg.ABTesting:Component.ABTestingContainer.TestingCaseRenderer) < prototype(Neos.Fusion:Component) { 2 | testingDecision = '' 3 | 4 | renderer = Neos.Fusion:Case { 5 | backendView { 6 | condition = ${node.context.inBackend == true} 7 | renderer = Wysiwyg.ABTesting:Component.ABTestingContainer.BackendView { 8 | nodeIdentifier = ${node.identifier} 9 | } 10 | } 11 | 12 | caseB { 13 | condition = ${props.testingDecision == 'b'} 14 | renderer = Neos.Neos:ContentCollection { 15 | nodePath = 'itemsb' 16 | } 17 | } 18 | 19 | caseC { 20 | condition = ${props.testingDecision == 'c'} 21 | renderer = Neos.Neos:ContentCollection { 22 | nodePath = 'itemsc' 23 | } 24 | } 25 | 26 | default { 27 | condition = ${true} 28 | renderer = Neos.Neos:ContentCollection { 29 | nodePath = 'itemsa' 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Configuration/Views.yaml: -------------------------------------------------------------------------------- 1 | - requestFilter: 'parentRequest.isPackage("Neos.Neos") && isPackage("Wysiwyg.ABTesting")' 2 | options: 3 | layoutRootPaths: 4 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Layouts' 5 | 'Neos.Media': 'resource://Neos.Media/Private/Layouts' 6 | 'Neos.Neos': 'resource://Neos.Neos/Private/Layouts' 7 | partialRootPaths: 8 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Partials' 9 | 'Neos.Media': 'resource://Neos.Media/Private/Partials' 10 | 'Neos.Neos': 'resource://Neos.Neos/Private/Partials' 11 | 12 | - requestFilter: 'parentRequest.isPackage("Neos.Neos") && isPackage("Wysiwyg.ABTesting") && (isController("Module\AbTesting\FeatureModule"))' 13 | options: 14 | layoutRootPaths: 15 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Layouts' 16 | 'Neos.Media': 'resource://Neos.Media/Private/Layouts' 17 | 'Neos.Neos': 'resource://Neos.Neos/Private/Layouts' 18 | partialRootPaths: 19 | 'Wysiwyg.ABTesting': 'resource://Wysiwyg.ABTesting/Private/Partials' 20 | 'Neos.Media': 'resource://Neos.Media/Private/Partials' 21 | 'Neos.Neos': 'resource://Neos.Neos/Private/Partials' 22 | -------------------------------------------------------------------------------- /Classes/Eel/FeatureHelper.php: -------------------------------------------------------------------------------- 1 | featureRepository->findByIdentifier($featureId); 29 | } 30 | 31 | return null; 32 | } 33 | 34 | /** 35 | * @param string $methodName 36 | * @return boolean 37 | */ 38 | public function allowsCallOfMethod($methodName) 39 | { 40 | switch ($methodName) { 41 | case 'getFeatureById': 42 | return true; 43 | } 44 | 45 | return false; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Classes/Domain/Session/ABTestingSession.php: -------------------------------------------------------------------------------- 1 | decisions[] = $decision; 26 | } 27 | 28 | /** 29 | * @return array 30 | */ 31 | public function getItems() 32 | { 33 | return $this->decisions; 34 | } 35 | 36 | /** 37 | * @Flow\Session(autoStart = true) 38 | * @param $feature 39 | * 40 | * @return string | null 41 | */ 42 | public function getDecisionForFeature($feature) 43 | { 44 | if (array_key_exists($feature, $this->decisions)) { 45 | return $this->decisions[$feature]; 46 | } 47 | 48 | return null; 49 | } 50 | 51 | /** 52 | * @Flow\Session(autoStart = true) 53 | * 54 | * @param $feature 55 | * @param $decision 56 | */ 57 | public function setDecisionForFeature($feature, $decision) 58 | { 59 | $this->decisions[$feature] = $decision; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20211125113158.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 26 | 27 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD defaultdecision VARCHAR(255) NOT NULL'); 28 | } 29 | 30 | /** 31 | * @param Schema $schema 32 | * 33 | * @return void 34 | * @throws \Doctrine\DBAL\Exception 35 | */ 36 | public function down(Schema $schema): void 37 | { 38 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 39 | 40 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP defaultdecision'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20190902152414.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 26 | 27 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature CHANGE defaultdecision defaultdecision VARCHAR(255) DEFAULT NULL'); 28 | } 29 | 30 | /** 31 | * @param Schema $schema 32 | * 33 | * @return void 34 | * @throws \Doctrine\DBAL\Exception 35 | */ 36 | public function down(Schema $schema): void 37 | { 38 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 39 | 40 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature CHANGE defaultdecision defaultdecision VARCHAR(255) NOT NULL'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20190902140817.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 27 | 28 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP priority, CHANGE decider deciderclassname VARCHAR(255) NOT NULL'); 29 | } 30 | 31 | /** 32 | * @param Schema $schema 33 | * 34 | * @return void 35 | * @throws \Doctrine\DBAL\Exception 36 | */ 37 | public function down(Schema $schema): void 38 | { 39 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 40 | 41 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD priority INT NOT NULL, CHANGE deciderclassname decider VARCHAR(255) NOT NULL'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Classes/Domain/Dto/DeciderObject.php: -------------------------------------------------------------------------------- 1 | deciderName; 30 | } 31 | 32 | /** 33 | * @param string $deciderName 34 | */ 35 | public function setDeciderName($deciderName) 36 | { 37 | $this->deciderName = $deciderName; 38 | } 39 | 40 | /** 41 | * @return string 42 | */ 43 | public function getDeciderClass() 44 | { 45 | return $this->deciderClass; 46 | } 47 | 48 | /** 49 | * @param string $deciderClass 50 | */ 51 | public function setDeciderClass($deciderClass) 52 | { 53 | $this->deciderClass = $deciderClass; 54 | } 55 | 56 | /** 57 | * @return Feature 58 | */ 59 | public function getFeature(): Feature 60 | { 61 | return $this->feature; 62 | } 63 | 64 | /** 65 | * @param Feature $feature 66 | */ 67 | public function setFeature(Feature $feature): void 68 | { 69 | $this->feature = $feature; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/DeleteFeature.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 | 6 | 7 | 8 |
    9 |

    Are you sure, you want to delete the A/B Test?

    10 |
    11 | 12 | 16 |
    17 |
    18 | 19 |
    20 |

    21 | A/B Test can't be deleted. 22 |

    23 |

    24 | Please remove the A/B Testing Feature on all pages in order to delete this A/B Test. 25 |

    26 |
    27 |
    28 | 29 |
    30 | 33 |
    34 |
    35 |
    36 | -------------------------------------------------------------------------------- /Classes/Domain/Model/Feature.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected $decisions; 29 | 30 | /** 31 | * @return string 32 | */ 33 | public function getFeatureName() 34 | { 35 | return $this->featureName; 36 | } 37 | 38 | /** 39 | * @param string $featureName 40 | */ 41 | public function setFeatureName($featureName) 42 | { 43 | $this->featureName = $featureName; 44 | } 45 | 46 | /** 47 | * @return bool 48 | */ 49 | public function isActive() 50 | { 51 | return $this->active; 52 | } 53 | 54 | /** 55 | * @param bool $active 56 | */ 57 | public function setActive($active) 58 | { 59 | $this->active = $active; 60 | } 61 | 62 | /** 63 | * @return ArrayCollection 64 | */ 65 | public function getDecisions() 66 | { 67 | return $this->decisions; 68 | } 69 | 70 | /** 71 | * @param ArrayCollection $decisions 72 | */ 73 | public function setDecisions($decisions) 74 | { 75 | $this->decisions = $decisions; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20190830132402.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 26 | 27 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP defaultdecision'); 28 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature ADD defaultdecision VARCHAR(255) NOT NULL'); 29 | } 30 | 31 | /** 32 | * @param Schema $schema 33 | * 34 | * @return void 35 | * @throws \Doctrine\DBAL\Exception 36 | */ 37 | public function down(Schema $schema): void 38 | { 39 | // this down() migration is autogenerated, please modify it to your needs 40 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 41 | 42 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD defaultdecision VARCHAR(255) NOT NULL'); 43 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_feature DROP defaultdecision'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Resources/Private/Partials/Module/FeatureModule/DeciderSection.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 |
    5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 |
    15 | 16 |
    17 | 18 |
    19 | 20 | 21 |
    22 |
    23 | 24 |
    25 | 26 |
    27 | 28 | 29 |
    30 |
    31 |
    32 | -------------------------------------------------------------------------------- /Classes/Domain/Decider/PercentageDecider.php: -------------------------------------------------------------------------------- 1 | 'a', 'b' => 'b']; 10 | 11 | /** 12 | * Returns a decision from a given array. 13 | * Decides by weight given from array values. 14 | * 15 | * Weighted Decision: a(60%) b (40%) 16 | * [ 17 | * 'a' => 60, 18 | * 'b' => 40 19 | * ] 20 | * 21 | * Also supports multiple decisions, for example c will be added: 22 | * Weighted Decision: a(40%) b (40%) c (20%) 23 | * 24 | * [ 25 | * 'a' => 40, 26 | * 'b' => 40, 27 | * 'c' => 20 28 | * ] 29 | * 30 | * @param array $decisions 31 | * @param ComparatorInterface $comparator 32 | * @return null|string 33 | */ 34 | public function decide(array $decisions, ComparatorInterface $comparator) 35 | { 36 | $comparisonValue = $comparator->getComparisonValue(); 37 | 38 | $tempDecision = 0; 39 | 40 | foreach ($decisions as $key => $decision) { 41 | $tempDecision += $decision; 42 | if ($tempDecision >= $comparisonValue) { 43 | return $key; 44 | } 45 | } 46 | 47 | return false; 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function getPossibleDecisions() 54 | { 55 | return $this->possibleDecisions; 56 | } 57 | 58 | public function getTitle() 59 | { 60 | return 'PercentageDecider'; 61 | } 62 | 63 | public function __toString() 64 | { 65 | return PercentageDecider::class; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Classes/Domain/Factory/DeciderFactory.php: -------------------------------------------------------------------------------- 1 | deciderSettings, function ($element) { 27 | return (array_key_exists('enabled', $element) && $element['enabled']); 28 | }); 29 | 30 | foreach ($enabledDeciders as $enabledDecider => $enabledValue) { 31 | if (class_exists('Wysiwyg\ABTesting\Domain\Decider\\' . $enabledDecider)) { 32 | $this->testDecider[] = 'Wysiwyg\ABTesting\Domain\Decider\\' . $enabledDecider; 33 | } 34 | } 35 | } 36 | 37 | /** 38 | * Gets an Instance of a Decider by Class name - since every Decider implements an 39 | * DeciderInterface, return type was set to DeciderInterface 40 | * 41 | * @param $className 42 | * @return DeciderInterface 43 | */ 44 | public function getTestDecider($className) 45 | { 46 | foreach ($this->testDecider as $decider) { 47 | if ($className === $decider) { 48 | return new $decider; 49 | } 50 | } 51 | 52 | return null; 53 | } 54 | 55 | public function getAllDecider() 56 | { 57 | return $this->testDecider; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/NewFeature.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 |

    Create new A/B Test

    6 |
    7 | 8 | 9 | 10 |
    11 |
    12 | Test Data 13 | 14 |
    15 | 16 |
    17 | 18 | 19 |
    20 |
    21 |
    22 |
    23 |
    24 | 28 |
    29 |
    30 | 31 |
    32 |
    33 | 37 |
    38 |
    39 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/ShowFeature.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 | 6 | A/B Test Feature Data 7 | 8 | 9 | 10 | 11 |
    12 |
    13 | 14 | 15 |
    16 | 17 |
    18 |
    19 |
    20 |
    21 | 25 |
    26 |
    27 |
    28 | 29 |
    30 |
    31 |
    32 | 33 |
    34 | 38 |
    39 | 40 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/EditFeature.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | {namespace wysiwyg=Wysiwyg\ABTesting\ViewHelpers} 3 | 4 | 5 | 6 | 7 | Edit A/B Test 8 | 9 | 10 | 11 | 12 |
    13 | 14 | 15 |
    16 | 20 |
    21 |
    22 | 23 | 24 |
    25 |
    26 | 27 |
    28 | 29 | 30 |
    31 |
    32 |
    33 |
    34 |
    35 | 40 |
    41 |
    42 |
    43 |
    44 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | Fusion: 3 | defaultContext: 4 | 'Wysiwyg.ABTesting.Decisions': 'Wysiwyg\ABTesting\Eel\DecisionHelper' 5 | 'Wysiwyg.ABTesting.Features': 'Wysiwyg\ABTesting\Eel\FeatureHelper' 6 | Neos: 7 | userInterface: 8 | translation: 9 | autoInclude: 10 | Wysiwyg.ABTesting: 11 | - Main 12 | 13 | fusion: 14 | autoInclude: 15 | 'Wysiwyg.ABTesting': TRUE 16 | 17 | modules: 18 | AbTesting: 19 | label: 'A/B Testing' 20 | description: 'A/B Testing Dashboard' 21 | controller: Wysiwyg\ABTesting\Controller\Module\AbTestingController 22 | icon: 'fas fa-briefcase' 23 | submodules: 24 | features: 25 | icon: 'fas fa-cog' 26 | label: 'A/B Tests' 27 | description: 'Configuration of A/B Tests' 28 | controller: Wysiwyg\ABTesting\Controller\Module\AbTesting\FeatureModuleController 29 | widgetTemplatePathAndFileName: 'resource://Neos.Neos/Private/Templates/Module/Widget.html' 30 | actions: 31 | newFeature: 32 | label: 'Create Feature' 33 | title: 'Create Feature' 34 | listFeatures: 35 | label: 'Feature List' 36 | title: 'Feature List' 37 | Flow: 38 | http: 39 | middlewares: 40 | abTestingCookieMiddleware: 41 | middleware: Wysiwyg\ABTesting\Domain\Http\Middleware\AbTestingCookieMiddleware 42 | position: 'after redirect' 43 | 44 | Wysiwyg: 45 | ABTesting: 46 | cookie: 47 | name: 'WYSIWYG_AB_TESTING' 48 | lifetime: '+2 years' 49 | deciders: 50 | PercentageDecider: 51 | enabled: true 52 | PercentageAbcDecider: 53 | enabled: true 54 | # configure the comparator you want to use 55 | # the \Wysiwyg\ABTesting\Domain\Comparator\FixedValueComparator is just for testing, it won't work here 56 | comparatorClassName: Wysiwyg\ABTesting\Domain\Comparator\UserIdentifierComparator 57 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Component/ABTestingContainer/BackendView.fusion: -------------------------------------------------------------------------------- 1 | prototype(Wysiwyg.ABTesting:Component.ABTestingContainer.BackendView) < prototype(Neos.Fusion:Component) { 2 | nodeIdentifier = '' 3 | 4 | renderer = Wysiwyg.AbTesting:Component.Organism.Tabs { 5 | tabNavigation = Wysiwyg.AbTesting:Component.Molecule.TabNavigation { 6 | tabs = Neos.Fusion:Array { 7 | tabA = Wysiwyg.AbTesting:Component.Molecule.Tab { 8 | target = ${'#ab-container-a-' + props.nodeIdentifier} 9 | text = 'items A' 10 | active = true 11 | } 12 | 13 | tabB = Wysiwyg.AbTesting:Component.Molecule.Tab { 14 | target = ${'#ab-container-b-' + props.nodeIdentifier} 15 | text = 'items B' 16 | } 17 | 18 | tabC = Wysiwyg.AbTesting:Component.Molecule.Tab { 19 | target = ${'#ab-container-c-' + props.nodeIdentifier} 20 | text = 'items C' 21 | } 22 | } 23 | } 24 | 25 | tabContent = Neos.Fusion:Array { 26 | tabPaneA = Wysiwyg.AbTesting:Component.Molecule.TabPane { 27 | content = Neos.Neos:ContentCollection { 28 | nodePath = 'itemsa' 29 | } 30 | id = ${'ab-container-a-' + props.nodeIdentifier} 31 | active = true 32 | } 33 | 34 | tabPaneB = Wysiwyg.AbTesting:Component.Molecule.TabPane { 35 | content = Neos.Neos:ContentCollection { 36 | nodePath = 'itemsb' 37 | } 38 | id = ${'ab-container-b-' + props.nodeIdentifier} 39 | } 40 | 41 | tabPaneC = Wysiwyg.AbTesting:Component.Molecule.TabPane { 42 | content = Neos.Neos:ContentCollection { 43 | nodePath = 'itemsc' 44 | } 45 | id = ${'ab-container-c-' + props.nodeIdentifier} 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Classes/Domain/DataSource/Tests.php: -------------------------------------------------------------------------------- 1 | getMappedFeaturesToSelectOptions(); 46 | } 47 | 48 | protected function getMappedFeaturesToSelectOptions() 49 | { 50 | $allFeatures = $this->featureRepository->findAll(); 51 | 52 | $mappedFeatures = []; 53 | 54 | /** 55 | * @var Feature $feature 56 | */ 57 | foreach ($allFeatures as $feature) { 58 | $mappedFeatures[] = [ 59 | 'label' => $feature->getFeatureName(), 60 | 'value' => $this->persistenceManager->getIdentifierByObject($feature), 61 | 'icon' => 'icon-cog' 62 | ]; 63 | } 64 | 65 | return $mappedFeatures; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/ChooseDecider.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 | Add Decider to A/B Test {feature.featureName} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
    15 |
    16 |
    17 | 18 |
    19 | 20 | 21 |
    22 |
    23 |
    24 |
    25 | 29 |
    30 |
    31 | 32 |

    No Decider to choose.

    33 |

    You can not add any decision. You may have set all possible deciders.

    34 | 37 |
    38 |
    39 |
    40 | -------------------------------------------------------------------------------- /Classes/Domain/DataSource/Features.php: -------------------------------------------------------------------------------- 1 | getMappedFeaturesToSelectOptions(); 46 | } 47 | 48 | /** 49 | * @return array 50 | */ 51 | protected function getMappedFeaturesToSelectOptions() 52 | { 53 | $allFeatures = $this->featureRepository->findAll(); 54 | $mappedFeatures = []; 55 | 56 | /** 57 | * @var Feature $feature 58 | */ 59 | foreach ($allFeatures as $feature) { 60 | $mappedFeatures[] = [ 61 | 'label' => $feature->getFeatureName(), 62 | 'value' => $this->persistenceManager->getIdentifierByObject($feature), 63 | 'icon' => 'icon-cog' 64 | ]; 65 | } 66 | 67 | return $mappedFeatures; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Classes/Domain/Comparator/UserIdentifierComparator.php: -------------------------------------------------------------------------------- 1 | comparisonValue !== null) { 19 | return $this->comparisonValue; 20 | } 21 | 22 | $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'no agent'; 23 | $acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'no accept language'; 24 | 25 | $identifierString = sprintf("%s°%s°%s", $userAgent, $acceptLanguage, $this->getClientIp()); 26 | $hash = md5($identifierString); 27 | $sixLastHexDigits = substr($hash, -6); 28 | 29 | $this->comparisonValue = HexToIntDowncast::sixDigitHexToPercentageInteger($sixLastHexDigits); 30 | return $this->comparisonValue; 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | private function getClientIp() 37 | { 38 | $ip = $_SERVER['REMOTE_ADDR']; 39 | if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && preg_match_all('#\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}#s', $_SERVER['HTTP_X_FORWARDED_FOR'], $matches)) { 40 | foreach ($matches[0] as $xip) { 41 | if (!preg_match('#^(10|172\.16|192\.168)\.#', $xip)) { 42 | $ip = $xip; 43 | break; 44 | } 45 | } 46 | } elseif (isset($_SERVER['HTTP_CLIENT_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CLIENT_IP'])) { 47 | $ip = $_SERVER['HTTP_CLIENT_IP']; 48 | } elseif (isset($_SERVER['HTTP_CF_CONNECTING_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CF_CONNECTING_IP'])) { 49 | $ip = $_SERVER['HTTP_CF_CONNECTING_IP']; 50 | } elseif (isset($_SERVER['HTTP_X_REAL_IP']) && preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_X_REAL_IP'])) { 51 | $ip = $_SERVER['HTTP_X_REAL_IP']; 52 | } 53 | 54 | return $ip; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Resources/Private/Partials/DeciderList.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 23 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
    DeciderDecisions
    16 | {singleDecision.decider.title} 17 | 19 | 20 | {decisionKey -> f:format.case(mode='upper')}: {decisionValue}   21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
    No decisions configured.
    43 |
    44 | Add Decider 45 |
    46 |
    47 | -------------------------------------------------------------------------------- /Resources/Private/Partials/Module/FeatureModule/DecisionList.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
    DeciderAB
    17 | {singleDecision.decider.deciderName} 18 | {decisionValue} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
    No decisions configured.
    42 |
    43 | 44 | Add Decision 45 | 46 |
    47 |
    -------------------------------------------------------------------------------- /Migrations/Postgresql/Version20230301144616.php: -------------------------------------------------------------------------------- 1 | abortIf( 18 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQL100Platform, 19 | "Migration can only be executed safely on 'PostgreSQL'." 20 | ); 21 | 22 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_decision (persistence_object_identifier VARCHAR(40) NOT NULL, feature VARCHAR(40) DEFAULT NULL, deciderclassname VARCHAR(255) NOT NULL, decision JSON NOT NULL, defaultdecision VARCHAR(255) NOT NULL, PRIMARY KEY(persistence_object_identifier))'); 23 | $this->addSql('CREATE INDEX IDX_8E96F03A1FD77566 ON wysiwyg_abtesting_domain_model_decision (feature)'); 24 | $this->addSql('COMMENT ON COLUMN wysiwyg_abtesting_domain_model_decision.decision IS \'(DC2Type:json_array)\''); 25 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_feature (persistence_object_identifier VARCHAR(40) NOT NULL, featurename VARCHAR(255) NOT NULL, active BOOLEAN NOT NULL, PRIMARY KEY(persistence_object_identifier))'); 26 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD CONSTRAINT FK_8E96F03A1FD77566 FOREIGN KEY (feature) REFERENCES wysiwyg_abtesting_domain_model_feature (persistence_object_identifier) NOT DEFERRABLE INITIALLY IMMEDIATE'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | $this->abortIf( 32 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQL100Platform, 33 | "Migration can only be executed safely on 'PostgreSQL'." 34 | ); 35 | 36 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP CONSTRAINT FK_8E96F03A1FD77566'); 37 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_decision'); 38 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_feature'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20180703153541.php: -------------------------------------------------------------------------------- 1 | abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 26 | 27 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_decision (persistence_object_identifier VARCHAR(40) NOT NULL, feature VARCHAR(40) DEFAULT NULL, decider VARCHAR(255) NOT NULL, decision LONGTEXT NOT NULL COMMENT \'(DC2Type:json_array)\', defaultdecision VARCHAR(255) NOT NULL, priority INT NOT NULL, INDEX IDX_8E96F03A1FD77566 (feature), PRIMARY KEY(persistence_object_identifier)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); 28 | $this->addSql('CREATE TABLE wysiwyg_abtesting_domain_model_feature (persistence_object_identifier VARCHAR(40) NOT NULL, featurename VARCHAR(255) NOT NULL, active TINYINT(1) NOT NULL, PRIMARY KEY(persistence_object_identifier)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); 29 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision ADD CONSTRAINT FK_8E96F03A1FD77566 FOREIGN KEY (feature) REFERENCES wysiwyg_abtesting_domain_model_feature (persistence_object_identifier)'); 30 | } 31 | 32 | /** 33 | * @param Schema $schema 34 | * 35 | * @return void 36 | * @throws \Doctrine\DBAL\Exception 37 | */ 38 | public function down(Schema $schema): void 39 | { 40 | $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on "mysql".'); 41 | 42 | $this->addSql('ALTER TABLE wysiwyg_abtesting_domain_model_decision DROP FOREIGN KEY FK_8E96F03A1FD77566'); 43 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_decision'); 44 | $this->addSql('DROP TABLE wysiwyg_abtesting_domain_model_feature'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Classes/Domain/Model/Decision.php: -------------------------------------------------------------------------------- 1 | deciderClassName; 43 | } 44 | 45 | /** 46 | * @param string $deciderClassName 47 | */ 48 | public function setDeciderClassName(string $deciderClassName) 49 | { 50 | $this->deciderClassName = $deciderClassName; 51 | } 52 | 53 | /** 54 | * @return Feature 55 | */ 56 | public function getFeature() 57 | { 58 | return $this->feature; 59 | } 60 | 61 | /** 62 | * @param Feature $feature 63 | */ 64 | public function setFeature($feature) 65 | { 66 | $this->feature = $feature; 67 | } 68 | 69 | /** 70 | * @return array 71 | */ 72 | public function getDecision() 73 | { 74 | return $this->decision; 75 | } 76 | 77 | /** 78 | * @param array $decision 79 | */ 80 | public function setDecision($decision) 81 | { 82 | $this->decision = $decision; 83 | } 84 | 85 | /** 86 | * @return string 87 | */ 88 | public function getDefaultDecision(): string 89 | { 90 | return $this->defaultDecision; 91 | } 92 | 93 | /** 94 | * @param string $defaultDecision 95 | */ 96 | public function setDefaultDecision(string $defaultDecision): void 97 | { 98 | $this->defaultDecision = $defaultDecision; 99 | } 100 | 101 | /** 102 | * @return DeciderInterface 103 | */ 104 | public function getDecider() 105 | { 106 | return new $this->deciderClassName; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Classes/Domain/Service/FeatureService.php: -------------------------------------------------------------------------------- 1 | contextFactory->create()]); 57 | $currentSiteNode = $flowQuery->get(0)->getCurrentSiteNode(); 58 | 59 | $q = new FlowQuery([$currentSiteNode]); 60 | 61 | $featureId = $this->persistenceManager->getIdentifierByObject($feature); 62 | $foundContainer = $q->find('[instanceof Wysiwyg.ABTesting:ABTestingContainer][abTest][abTest="' . $featureId . '"]')->get(); 63 | 64 | $pageNodes = []; 65 | 66 | /** 67 | * @var Node $container 68 | */ 69 | foreach ($foundContainer as $container) { 70 | $parent = $container->getParent()->getParent(); 71 | $pageNodes[] = $parent; 72 | } 73 | 74 | return $pageNodes; 75 | 76 | } 77 | 78 | /** 79 | * Wrapper method to get allActiveFeatures. 80 | * 81 | * @return array 82 | */ 83 | public function getAllActiveFeatures(): array 84 | { 85 | return $this->featureRepository->getAllActiveFeatures(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Resources/Private/Script/AbTesting.js: -------------------------------------------------------------------------------- 1 | const abTesting = { 2 | abTestingCookieString: "", 3 | cookieName: document.body.dataset.abTestingCookieName || "WYSIWYG_AB_TESTING", 4 | abTestingObject: {}, 5 | 6 | init: function () { 7 | this.abTestingCookieString = this.getCookie(); 8 | this.abTestingObject = JSON.parse(this.abTestingCookieString || "{}"); 9 | }, 10 | 11 | /** 12 | * returns the testing cookie value 13 | * 14 | * @returns {string} 15 | */ 16 | getCookie: function () { 17 | let match = document.cookie.match(new RegExp("(^| )" + this.cookieName + "=([^;]+)")); 18 | 19 | if (!match) { 20 | return undefined; 21 | } 22 | 23 | return decodeURIComponent(match[2]); 24 | }, 25 | 26 | /** 27 | * This function builds an array of TrackingStrings. 28 | * 29 | * Example: 30 | * [ 31 | * 'featureX_a', 32 | * 'featureY_b' 33 | * ] 34 | * 35 | * @returns {Array} 36 | */ 37 | getTrackingStringsArrayForAllFeatures: function () { 38 | let trackingStringsArray = []; 39 | 40 | for (let featureName in this.abTestingObject) { 41 | trackingStringsArray.push(this.getTrackingStringForFeature(featureName)); 42 | } 43 | 44 | return trackingStringsArray; 45 | }, 46 | 47 | /** 48 | * This functions concat a featureName with a decision separated by an underscore ('_') for a given feature by featureName. 49 | * 50 | * Example: 'feature_a' 51 | * 52 | * @param featureName 53 | * @returns {string} 54 | */ 55 | getTrackingStringForFeature: function (featureName) { 56 | return featureName + "_" + this.getDecisionForFeature(featureName); 57 | }, 58 | 59 | /** 60 | * This function returns an abTestingObject. 61 | * An Object has a featureName for every key and the value represents the decision for a feature. 62 | * 63 | * Example: 64 | * { 65 | * featureX: 'a', 66 | * featureY: 'b' 67 | * } 68 | * 69 | * @returns {WY.AbTesting.abTestingObject|{}} 70 | */ 71 | getDecisionsForAllFeatures: function () { 72 | return this.abTestingObject; 73 | }, 74 | 75 | /** 76 | * Searches the abTestObject for a property which matches the featureName and return its value. 77 | * 78 | * @param featureName 79 | * @returns {string} 80 | */ 81 | getDecisionForFeature: function (featureName) { 82 | if (featureName in this.abTestingObject) { 83 | return this.abTestingObject[featureName]; 84 | } 85 | }, 86 | }; 87 | 88 | window.WY = window.WY || {}; 89 | window.WY.AbTesting = abTesting; 90 | 91 | window.addEventListener('load', function () { 92 | window.WY.AbTesting.init(); 93 | }); 94 | -------------------------------------------------------------------------------- /Classes/Eel/DecisionHelper.php: -------------------------------------------------------------------------------- 1 | decisionService->getDecisionForFeature($feature); 40 | } 41 | 42 | /** 43 | * Returns a decision for a Feature by featureName. 44 | * 45 | * @param string $featureName 46 | * @param string $forcedDecision 47 | * @return string 48 | */ 49 | public function getDecisionForFeatureByName(string $featureName, $forcedDecision = null) 50 | { 51 | if ($forcedDecision) { 52 | return $forcedDecision; 53 | } 54 | 55 | $foundFeature = $this->featureRepository->findOneByFeatureName($featureName); 56 | return ($foundFeature instanceof Feature) ? $this->getDecisionForFeature($foundFeature, $forcedDecision) : ''; 57 | } 58 | /** 59 | * Returns a decision for a Feature by featureName. 60 | * 61 | * @param string $featurePersistentIdentifier 62 | * @param string $forcedDecision 63 | * @return string 64 | */ 65 | public function getDecisionForFeatureByIdentifier(string $featurePersistentIdentifier, $forcedDecision = null) 66 | { 67 | if ($forcedDecision) { 68 | return $forcedDecision; 69 | } 70 | 71 | $foundFeature = $this->featureRepository->findByIdentifier($featurePersistentIdentifier); 72 | return ($foundFeature instanceof Feature) ? $this->getDecisionForFeature($foundFeature, $forcedDecision) : ''; 73 | } 74 | 75 | /** 76 | * @return string[] 77 | */ 78 | public function getAllDecisions() 79 | { 80 | return $this->decisionService->decideForAllFeatures(); 81 | } 82 | 83 | /** 84 | * @param string $methodName 85 | * @return boolean 86 | */ 87 | public function allowsCallOfMethod($methodName) 88 | { 89 | switch ($methodName) { 90 | case 'getDecisionForFeature': 91 | case 'getAllDecisions': 92 | case 'getDecisionForFeatureByName': 93 | case 'getDecisionForFeatureByIdentifier': 94 | return true; 95 | } 96 | 97 | return false; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Resources/Private/Templates/Module/AbTesting/FeatureModule/ListFeatures.html: -------------------------------------------------------------------------------- 1 | {namespace neos=Neos\Neos\ViewHelpers} 2 | 3 | 4 | 5 | 6 |
    7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 28 | 55 | 56 | 57 |
    A/B TestStatus 
    {feature.featureName} 19 | 20 | 21 | Active 22 | 23 | 24 | Inactive 25 | 26 | 27 | 29 |
    30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
    54 |
    58 |
    59 | 62 |
    63 | -------------------------------------------------------------------------------- /Classes/Domain/Http/Middleware/AbTestingCookieMiddleware.php: -------------------------------------------------------------------------------- 1 | handle($request); 49 | 50 | $cookieParams = $request->getCookieParams(); 51 | 52 | if (!array_key_exists($this->cookieSettings['name'], $cookieParams)) { 53 | return $this->createCookieToResponse($response); 54 | } 55 | 56 | return $this->refreshResponseCookie($request, $response); 57 | } 58 | 59 | /** 60 | * Creates a new A/B Testing Cookie with decisions of all features. 61 | * 62 | * @param ResponseInterface $response 63 | * 64 | * @return ResponseInterface 65 | */ 66 | protected function createCookieToResponse(ResponseInterface $response): ResponseInterface 67 | { 68 | $abTestingCookie = new Cookie($this->cookieSettings['name'], null, strtotime($this->cookieSettings['lifetime']), null, null, '/', false, false); 69 | $decisionsAsJson = json_encode($this->decisionService->decideForAllFeatures()); 70 | $abTestingCookie->setValue($decisionsAsJson); 71 | 72 | return $response->withAddedHeader('Set-Cookie', (string)$abTestingCookie); 73 | } 74 | 75 | /** 76 | * Refreshes the A/B Testing Cookie, if necessary. 77 | * Checks for current decisions and add new decisions for features without a decision to the cookie. 78 | * 79 | * @param ServerRequestInterface $request 80 | * @param ResponseInterface $response 81 | * 82 | * @return ResponseInterface 83 | */ 84 | protected function refreshResponseCookie(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface 85 | { 86 | $cookieParams = $request->getCookieParams(); 87 | $abTestingCookie = $cookieParams[$this->cookieSettings['name']] ?? ''; 88 | $currentCookieValue = json_decode(urldecode(urldecode($abTestingCookie)), true); 89 | $activeFeatures = $this->featureService->getAllActiveFeatures(); 90 | 91 | if (is_array($currentCookieValue)) { 92 | /** @var Feature $activeFeature */ 93 | foreach ($activeFeatures as $activeFeature) { 94 | $featureName = str_replace(' ', '_', $activeFeature->getFeatureName()); 95 | 96 | if (!array_key_exists($featureName, $currentCookieValue)) { 97 | $currentCookieValue[$featureName] = $this->decisionService->getDecisionForFeature($activeFeature); 98 | } 99 | } 100 | } 101 | 102 | $abTestingCookie = new Cookie($this->cookieSettings['name'], null, strtotime($this->cookieSettings['lifetime']), null, null, '/', false, false); 103 | $decisionsAsJson = json_encode($currentCookieValue); 104 | $abTestingCookie->setValue($decisionsAsJson); 105 | 106 | return $response->withAddedHeader('Set-Cookie', (string)$abTestingCookie); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!CAUTION] 2 | > This repository is deprecated and will be removed in September 2025 3 | 4 | # wysiwyg\* - Neos A/B Testing 5 | 6 | ![Neos Package](https://img.shields.io/badge/Neos-Package-blue.svg "Neos Package") 7 | ![Neos Project](https://img.shields.io/badge/Neos-%20%3E=%208.0%20-blue.svg "Neos Project") 8 | ![PHP 7.1 and above](https://img.shields.io/badge/PHP-%20%3E=%208.1%20-blue.svg "PHP >= 8.1") 9 | 10 | This package provides a simple to use backend module and frontend container to run A/B tests in Neos. 11 | 12 | ## Installation 13 | 14 | Run these commands to install the package and update the database schema. 15 | 16 | ```bash 17 | composer require wy/neos-abtesting 18 | 19 | ./flow flow:doctrine:migrate 20 | ``` 21 | 22 | ## Usage 23 | 24 | ![ demo image](Documentation/ab-testing-container.jpg "Adding a A/B container in backend") 25 | 26 | ![ demo image](Documentation/ab-testing-frontend.jpg "Frontend displays one version") 27 | 28 | This package offers a node container for displaying two different nodes for two different decisions (A or B). 29 | 30 | You can add the A/B testing container to your constraints: `Wysiwyg.ABTesting:ABTestingContainer` 31 | 32 | The container has three contentCollections: 33 | 34 | - itemsa 35 | - itemsb 36 | - itemsc 37 | 38 | By default these collections accept all content nodes. 39 | This can be changed by overriding the respective node configuration in your own NodeTypes.yaml file. 40 | An editor has to put one or more nodes in each content collection. 41 | 42 | **IMPORTANT** 43 | Both versions will always be rendered in the Neos backend. 44 | Per default version A will be displayed in the frontend if no feature has been configured and selected. 45 | 46 | You can find an option group "A / B Testing" in each ABTestingContainer. 47 | This group provides a dropdown menu to choose which feature will be used for the container. 48 | 49 | #### Preview Different Versions 50 | 51 | Sometimes it is necessary to view a different version of the feature. 52 | For viewing a different version add the "forceABVersion" GET-parameter into the url and assign the desired version as the value. 53 | 54 | Example: 55 | Show Version A 56 | `https://example.com/greatFeature.html?forceABVersion=a` 57 | Show Version B 58 | `https://example.com/greatFeature.html?forceABVersion=b` 59 | Show Version C 60 | `https://example.com/greatFeature.html?forceABVersion=c` 61 | 62 | This parameter will not override the cookie value. 63 | 64 | ### Backend-Module Usage 65 | 66 | You will find a new "A/B Testing" menu item in the main menu of the Neos backend. 67 | The module "Features" will offer all necessary functions to manage A/B testing features. 68 | In the A/B testing dashboard you will find the following options: 69 | 70 | - "Create Feature": Add a new A/B test feature 71 | - "Feature List": Shows a list of all A/B test features 72 | 73 | **Feature List** 74 | ![ demo image](Documentation/feature-list.jpg "Neos Module which shows all features and options to change") 75 | 76 | ## Settings 77 | 78 | This package uses default values for creating a cookie. 79 | There are several settings which can be modified for your own implementation. 80 | 81 | ```yaml 82 | Wysiwyg: 83 | ABTesting: 84 | cookie: 85 | name: "WYSIWYG_AB_TESTING" 86 | lifetime: "+2 years" 87 | ``` 88 | 89 | Per default the cookie has a lifetime of 2 years. Please consider that [strtotime()](https://www.php.net/manual/de/function.strtotime.php) is used to evaluate the lifetime setting's value if you need to adjust it. 90 | 91 | ## Regarding Privacy (i.e. GDPR) 92 | 93 | Please note that all A/B testing decisions will be saved in a cookie that by default is named "WYSIWYG_AB_TESTING" and has a lifetime of 2 years. 94 | This cookie will be created whenever a user opens the webpage for the first time. 95 | It contains a raw JSON string which includes all names of the features and their decision (a or b). 96 | Whenever a user enters the page and already has the cookie, it will be made sure that all active features are saved with a decision. If there are new features they will be added to the cookie and a new JSON string will be saved with all decisions. 97 | 98 | ## Contributing 99 | 100 | Pull requests are welcome. For major changes please open an issue first to discuss what you would like to change. 101 | 102 | ## Planned Features 103 | 104 | We want to enhance the A/B testing with more solid features. 105 | 106 | - Decider-Chaining 107 | Right now it's only possible to add one decision to a feature. 108 | We want to make it possible to add a chaining of deciders for example DimensionDecision AND Percentage. 109 | 110 | ## Authors 111 | 112 | [Sven Wütherich](https://github.com/svwu) 113 | [Alexander Schulte](https://github.com/Alex-Schulte) 114 | [Eva-Maria Müller](https://github.com/emmue) 115 | [Marvin Kuhn](https://github.com/breadlesscode) 116 | 117 | ## License 118 | 119 | This package is released under the MIT License (MIT). Please see [License File](LICENSE) for more information. 120 | -------------------------------------------------------------------------------- /Classes/Domain/Service/DecisionService.php: -------------------------------------------------------------------------------- 1 | getFeatureName(); 58 | $decisionFromCookie = $this->getDecisionFromCookies($featureName); 59 | $decisionsForFeature = $this->decisionRepository->findByFeature($feature); 60 | 61 | if ($decisionFromCookie) { 62 | return $decisionFromCookie; 63 | } 64 | 65 | // We want to reuse the same comparator here 66 | $comparator = $this->getComparator(); 67 | $decision = ''; 68 | 69 | /** 70 | * @var Decision $singleDecision 71 | */ 72 | foreach ($decisionsForFeature as $singleDecision) { 73 | $decider = $singleDecision->getDecider(); 74 | $decision = $decider->decide($singleDecision->getDecision(), $comparator); 75 | 76 | if (!$decision) { 77 | return ''; 78 | } 79 | } 80 | 81 | return $decision; 82 | } 83 | 84 | /** 85 | * Returns all Decider Class Names without any Namespaces. 86 | * 87 | * @return array 88 | */ 89 | public function getAllDeciderObjects(): array 90 | { 91 | $featureFactory = new DeciderFactory(); 92 | 93 | $deciderObjects = []; 94 | 95 | foreach ($featureFactory->getAllDecider() as $deciderClassName) { 96 | $lastSlashPosition = strrpos($deciderClassName, '\\'); 97 | $deciderName = substr($deciderClassName, $lastSlashPosition + 1); 98 | 99 | $deciderObject = new DeciderObject(); 100 | $deciderObject->setDeciderClass($deciderClassName); 101 | $deciderObject->setDeciderName($deciderName); 102 | 103 | $deciderObjects[] = $deciderObject; 104 | } 105 | 106 | return $deciderObjects; 107 | } 108 | 109 | /** 110 | * Decides for all features which are configured in database. 111 | * 112 | * @return string[] 113 | */ 114 | public function decideForAllFeatures(): array 115 | { 116 | $features = $this->featureRepository->findAll(); 117 | 118 | $decisions = []; 119 | 120 | /** 121 | * @var Feature $feature 122 | */ 123 | foreach ($features as $feature) { 124 | if ($feature->isActive()) { 125 | 126 | $featureName = str_replace(' ', '_', $feature->getFeatureName()); 127 | 128 | $decisions[$featureName] = $this->getDecisionForFeature($feature); 129 | } 130 | } 131 | 132 | return $decisions; 133 | } 134 | 135 | /** 136 | * @param string $featureName 137 | * @return string 138 | */ 139 | public function getDecisionFromCookies(string $featureName): string 140 | { 141 | $cookieName = $this->cookieSettings['name'] ?? 'WYSIWYG_AB_TESTING'; 142 | 143 | if (array_key_exists($cookieName, $_COOKIE)) { 144 | $decisionsArray = json_decode(urldecode($_COOKIE[$cookieName]), true); 145 | 146 | if (is_array($decisionsArray)) { 147 | return array_key_exists($featureName, $decisionsArray) ? $decisionsArray[$featureName] : ''; 148 | } 149 | } 150 | 151 | return ''; 152 | } 153 | 154 | /** 155 | * Create the comparator to be used for decisions. Can be configured 156 | * TODO: Should probably be a factory that can be injected 157 | * 158 | * @return ComparatorInterface 159 | */ 160 | protected function getComparator(): ComparatorInterface 161 | { 162 | return new $this->configuredComparatorClass; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Classes/Controller/Module/AbTesting/FeatureModuleController.php: -------------------------------------------------------------------------------- 1 | redirect('listFeatures'); 47 | } 48 | 49 | public function newFeatureAction() 50 | { 51 | 52 | } 53 | 54 | /** 55 | * @param Feature $feature 56 | * 57 | * @throws \Neos\Flow\Mvc\Exception\StopActionException 58 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 59 | */ 60 | public function createFeatureAction(Feature $feature) 61 | { 62 | $existingFeature = $this->featureRepository->findOneByFeatureName($feature->getFeatureName()); 63 | 64 | if (is_null($existingFeature)) { 65 | $this->featureRepository->add($feature); 66 | } 67 | 68 | $this->redirect('listFeatures'); 69 | } 70 | 71 | public function listFeaturesAction() 72 | { 73 | $allFeatures = $this->featureRepository->findAll(); 74 | 75 | $this->view->assign('allFeatures', $allFeatures); 76 | } 77 | 78 | /** 79 | * @param Feature $feature 80 | * 81 | * @throws \Neos\Eel\Exception 82 | */ 83 | public function showFeatureAction(Feature $feature) 84 | { 85 | $decisions = $this->decisionRepository->findByFeature($feature); 86 | $pages = $this->featureService->getPagesWithFeature($feature); 87 | 88 | $this->view->assignMultiple([ 89 | 'decisions' => $decisions, 90 | 'feature' => $feature, 91 | 'pages' => $pages 92 | ]); 93 | } 94 | 95 | /** 96 | * @param Feature $feature 97 | * 98 | * @throws \Neos\Eel\Exception 99 | */ 100 | public function deleteFeatureAction(Feature $feature) 101 | { 102 | $pages = $this->featureService->getPagesWithFeature($feature); 103 | $deletable = count($pages) == 0; 104 | 105 | $this->view->assignMultiple([ 106 | 'feature' => $feature, 107 | 'deletable' => $deletable, 108 | 'pages' => $pages 109 | ]); 110 | } 111 | 112 | /** 113 | * @param Feature $feature 114 | * 115 | * @throws \Neos\Flow\Mvc\Exception\StopActionException 116 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 117 | */ 118 | public function deleteFeatureConfirmedAction(Feature $feature) 119 | { 120 | $this->featureRepository->remove($feature); 121 | $decisions = $this->decisionRepository->findByFeature($feature); 122 | 123 | foreach ($decisions as $decision) { 124 | $this->decisionRepository->remove($decision); 125 | } 126 | 127 | $this->redirect('index'); 128 | } 129 | 130 | /** 131 | * @param Feature $feature 132 | */ 133 | public function editFeatureAction(Feature $feature) 134 | { 135 | $decisions = $this->decisionRepository->findByFeature($feature); 136 | 137 | $this->view->assignMultiple([ 138 | 'decisions' => $decisions, 139 | 'feature' => $feature 140 | ]); 141 | } 142 | 143 | /** 144 | * @param Feature $feature 145 | * 146 | * @throws \Neos\Flow\Mvc\Exception\StopActionException 147 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 148 | */ 149 | public function updateFeatureAction(Feature $feature) 150 | { 151 | if ($feature) { 152 | $this->featureRepository->update($feature); 153 | } 154 | 155 | $this->redirect('listFeatures'); 156 | } 157 | 158 | /** 159 | * @param Feature $feature 160 | * 161 | * @throws \Neos\Flow\Mvc\Exception\StopActionException 162 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 163 | */ 164 | public function toggleActiveAction(Feature $feature) 165 | { 166 | $feature->setActive(!$feature->isActive()); 167 | 168 | $this->featureRepository->update($feature); 169 | $this->persistenceManager->persistAll(); 170 | 171 | $this->redirect('listFeatures'); 172 | } 173 | 174 | /** 175 | * @param Feature $feature 176 | */ 177 | public function showPagesAction(Feature $feature) 178 | { 179 | $pages = $this->featureService->getPagesWithFeature($feature); 180 | $this->view->assignMultiple([ 181 | 'feature' => $feature, 182 | 'pages' => $pages 183 | ]); 184 | } 185 | 186 | /** 187 | * @param Feature $feature 188 | */ 189 | public function chooseDeciderAction(Feature $feature) 190 | { 191 | $deciderObjectsRaw = $this->decisionService->getAllDeciderObjects(); 192 | 193 | $assignableDeciderObjects = []; 194 | $deciderToIgnore = []; 195 | 196 | /** @var Decision $decision */ 197 | foreach ($feature->getDecisions() as $decision) { 198 | $deciderToIgnore[] = $decision->getDecider(); 199 | } 200 | 201 | /** @var DeciderObject $deciderObject */ 202 | foreach ($deciderObjectsRaw as $deciderObject) { 203 | if (!in_array($deciderObject->getDeciderName(), $deciderToIgnore)) { 204 | $assignableDeciderObjects[] = $deciderObject; 205 | } 206 | } 207 | 208 | $this->view->assignMultiple([ 209 | 'feature' => $feature, 210 | 'deciderObjects' => $assignableDeciderObjects 211 | ]); 212 | } 213 | 214 | /** 215 | * @param DeciderObject $decider 216 | */ 217 | public function addDecisionToFeatureAction(DeciderObject $decider) 218 | { 219 | $deciderClass = $decider->getDeciderClass(); 220 | $deciderObject = new $deciderClass; 221 | 222 | $this->view->assignMultiple([ 223 | 'feature' => $decider->getFeature(), 224 | 'decider' => $deciderObject, 225 | 'deciderClass' => $deciderClass 226 | ]); 227 | } 228 | 229 | /** 230 | * @param Decision $decision 231 | * 232 | * @throws \Neos\Flow\Mvc\Exception\StopActionException 233 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 234 | */ 235 | public function saveDecisionToFeatureAction(Decision $decision) 236 | { 237 | $this->decisionRepository->add($decision); 238 | $this->addFlashMessage('Decider has been added.'); 239 | $this->redirect('listFeatures'); 240 | } 241 | 242 | /** 243 | * @param Decision $decision 244 | */ 245 | public function editDecisionAction(Decision $decision) 246 | { 247 | $this->view->assign('decision', $decision); 248 | } 249 | 250 | /** 251 | * @param Decision $decision 252 | * 253 | * @throws \Neos\Flow\Mvc\Exception\StopActionException 254 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 255 | */ 256 | public function updateDecisionAction(Decision $decision) 257 | { 258 | $total = 0; 259 | foreach ($decision->getDecision() as $version => $percentage) { 260 | $total += $percentage; 261 | } 262 | 263 | if ($total !== 100) { 264 | $this->addFlashMessage('Please configure 100% in total for all versions.', '', Error\Message::SEVERITY_ERROR); 265 | $this->redirect('editDecision', null, null, ['decision' => $decision]); 266 | } 267 | 268 | $this->addFlashMessage('A/B Test successfully configured.'); 269 | $this->decisionRepository->update($decision); 270 | $this->redirect('listFeatures'); 271 | } 272 | 273 | /** 274 | * @param Decision $decision 275 | */ 276 | public function deleteDecisionAction(Decision $decision) 277 | { 278 | $this->view->assign('decision', $decision); 279 | } 280 | 281 | /** 282 | * @param Decision $decision 283 | * 284 | * @throws \Neos\Flow\Mvc\Exception\StopActionException 285 | * @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException 286 | */ 287 | public function deleteDecisionConfirmedAction(Decision $decision) 288 | { 289 | $this->decisionRepository->remove($decision); 290 | $this->addFlashMessage('Decision has been deleted.', '', Error\Message::SEVERITY_NOTICE); 291 | $this->redirect('listFeatures'); 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /Tests/Unit/Domain/Decider/AbstractPercentageDeciderTest.php: -------------------------------------------------------------------------------- 1 | 100, 20 | 'b' => 0 21 | ] 22 | ]; 23 | } 24 | 25 | /** 26 | * DataProvider: A/B: 0 - 100 27 | * 28 | * @return array 29 | */ 30 | public function hundredPercentBProvider() 31 | { 32 | return [ 33 | [ 34 | 'a' => 0, 35 | 'b' => 100 36 | ] 37 | ]; 38 | } 39 | 40 | /** 41 | * DataProvider: A/B: 30 - 70 42 | * 43 | * @return array 44 | */ 45 | public function thirtyASeventyBProvider() 46 | { 47 | return [ 48 | [ 49 | 'a' => 30, 50 | 'b' => 70 51 | ] 52 | ]; 53 | } 54 | 55 | /** 56 | * DataProvider: A/B/C: 0 - 0 - 100 57 | * 58 | * @return array 59 | */ 60 | public function hundredPercentCProvider() 61 | { 62 | return [ 63 | [ 64 | 'a' => 0, 65 | 'b' => 0, 66 | 'c' => 100 67 | ] 68 | ]; 69 | } 70 | 71 | /** 72 | * DataProvider: A/B/C: 0 - 50 - 50 73 | * 74 | * @return array 75 | */ 76 | public function zeroAFiftyBFiftyCProvider() 77 | { 78 | return [ 79 | [ 80 | 'a' => 0, 81 | 'b' => 50, 82 | 'c' => 50 83 | ] 84 | ]; 85 | } 86 | 87 | /** 88 | * DataProvider: A/B/C: 50 - 0 - 50 89 | * 90 | * @return array 91 | */ 92 | public function fiftyAZeroBFiftyCProvider() 93 | { 94 | return [ 95 | [ 96 | 'a' => 50, 97 | 'b' => 0, 98 | 'c' => 50 99 | ] 100 | ]; 101 | } 102 | 103 | /** 104 | * DataProvider: A/B/C: 33 - 33 - 33 105 | * 106 | * @return array 107 | */ 108 | public function balancedAbcProvider() 109 | { 110 | return [ 111 | [ 112 | 'a' => 33, 113 | 'b' => 33, 114 | 'c' => 33 115 | ] 116 | ]; 117 | } 118 | 119 | /** 120 | * DataProvider, where a is never 0% 121 | * 122 | * Provided Test cases: 123 | * a < b, c = 0 124 | * a < b, a > c 125 | * a > b, a < c 126 | * a > b, a > c 127 | * a = b, c = 0 128 | * a > b, c = 0 129 | * a > c, b = 0 130 | * a < c, b = 0 131 | * a = c, b = 0 132 | * a = 100, b = 0, c = 0 133 | * 134 | * @return array 135 | */ 136 | public function unbalancedAbcProviderForA() 137 | { 138 | return [ 139 | // a < b, c = 0 140 | [ 141 | 'a' => 5, 142 | 'b' => 95, 143 | 'c' => 0 144 | ], 145 | // a < b, a > c 146 | [ 147 | 'a' => 20, 148 | 'b' => 70, 149 | 'c' => 10 150 | ], 151 | // a > b, a < c 152 | [ 153 | 'a' => 20, 154 | 'b' => 10, 155 | 'c' => 70 156 | ], 157 | // a > b, a > c 158 | [ 159 | 'a' => 50, 160 | 'b' => 10, 161 | 'c' => 40 162 | ], 163 | // a = b, c = 0 164 | [ 165 | 'a' => 50, 166 | 'b' => 50, 167 | 'c' => 0 168 | ], 169 | // a > b, c = 0 170 | [ 171 | 'a' => 70, 172 | 'b' => 30, 173 | 'c' => 0 174 | ], 175 | // a > c, b = 0 176 | [ 177 | 'a' => 70, 178 | 'b' => 0, 179 | 'c' => 30 180 | ], 181 | // a < c, b = 0 182 | [ 183 | 'a' => 30, 184 | 'b' => 0, 185 | 'c' => 70 186 | ], 187 | // a = c, b = 0 188 | [ 189 | 'a' => 50, 190 | 'b' => 0, 191 | 'c' => 50 192 | ], 193 | // a = 100, b = 0, c = 0 194 | [ 195 | 'a' => 100, 196 | 'b' => 0, 197 | 'c' => 0 198 | ] 199 | ]; 200 | } 201 | 202 | 203 | /** 204 | * DataProvider, where b is never 0% 205 | * 206 | * Provided Test cases: 207 | * b > a, b > c 208 | * b < a, b > c 209 | * b > a, b < c 210 | * b < a, b < c 211 | * b > a, c = 0 212 | * b < a, c = 0 213 | * b = a, c = 0 214 | * b = c, a = 0 215 | * b > c, a = 0 216 | * b < c, a = 0 217 | * b = 100, a = 0, c = 0 218 | * 219 | * @return array 220 | */ 221 | public function unbalancedAbcProviderForB() 222 | { 223 | return [ 224 | // b > a, b > c 225 | [ 226 | 'a' => 10, 227 | 'b' => 70, 228 | 'c' => 20 229 | ], 230 | // b < a, b > c 231 | [ 232 | 233 | 'a' => 10, 234 | 'b' => 50, 235 | 'c' => 40 236 | ], 237 | // b > a, b < c 238 | [ 239 | 'a' => 10, 240 | 'b' => 20, 241 | 'c' => 70 242 | ], 243 | // b < a, b < c 244 | [ 245 | 'a' => 20, 246 | 'b' => 30, 247 | 'c' => 50 248 | ], 249 | // b > a, c = 0 250 | [ 251 | 'a' => 30, 252 | 'b' => 70, 253 | 'c' => 0 254 | ], 255 | // b < a, c = 0 256 | [ 257 | 'a' => 70, 258 | 'b' => 30, 259 | 'c' => 0 260 | ], 261 | // b > c, a = 0 262 | [ 263 | 'a' => 0, 264 | 'b' => 60, 265 | 'c' => 40 266 | ], 267 | // b < c, a = 0 268 | [ 269 | 'a' => 0, 270 | 'b' => 40, 271 | 'c' => 60 272 | ], 273 | // b = a, c = 0 274 | [ 275 | 'a' => 50, 276 | 'b' => 50, 277 | 'c' => 0 278 | ], 279 | // b = c, a = 0 280 | [ 281 | 'a' => 0, 282 | 'b' => 50, 283 | 'c' => 50 284 | ], 285 | // b = 100, a = 0, c = 0 286 | [ 287 | 'a' => 0, 288 | 'b' => 100, 289 | 'c' => 0 290 | ] 291 | ]; 292 | } 293 | 294 | /** 295 | * DataProvider, where c is never 0% 296 | * 297 | * Provided data for Test cases: 298 | * c < a, b < a 299 | * c > a, b < a 300 | * c < a, b > a 301 | * c > a, b > a 302 | * c > a, b = 0 303 | * c < a, b = 0 304 | * c < b, a = 0 305 | * c > b, a = 0 306 | * c = a, b = 0 307 | * c = b, a = 0 308 | * c = 100, a = 0, b = 0 309 | * 310 | * @return array 311 | */ 312 | public function unbalancedAbcProviderForC() 313 | { 314 | return [ 315 | // c < a, b < a 316 | [ 317 | 'a' => 60, 318 | 'b' => 10, 319 | 'c' => 30 320 | ], 321 | // c > a, b < a 322 | [ 323 | 'a' => 30, 324 | 'b' => 10, 325 | 'c' => 60 326 | ], 327 | // c < a, b > a 328 | [ 329 | 'a' => 30, 330 | 'b' => 50, 331 | 'c' => 20 332 | ], 333 | // c > a, b > a 334 | [ 335 | 'a' => 10, 336 | 'b' => 70, 337 | 'c' => 20 338 | ], 339 | // c > a, b = 0 340 | [ 341 | 'a' => 20, 342 | 'b' => 0, 343 | 'c' => 80 344 | ], 345 | // c < a, b = 0 346 | [ 347 | 'a' => 30, 348 | 'b' => 0, 349 | 'c' => 70 350 | ], 351 | // c < b, a = 0 352 | [ 353 | 'a' => 0, 354 | 'b' => 70, 355 | 'c' => 30 356 | ], 357 | // c > b, a = 0 358 | [ 359 | 'a' => 0, 360 | 'b' => 30, 361 | 'c' => 70 362 | ], 363 | // c = a, b = 0 364 | [ 365 | 'a' => 50, 366 | 'b' => 0, 367 | 'c' => 50 368 | ], 369 | // c = b, a = 0 370 | [ 371 | 'a' => 0, 372 | 'b' => 50, 373 | 'c' => 50 374 | ], 375 | // c = 100, a = 0, b = 0 376 | [ 377 | 'a' => 0, 378 | 'b' => 0, 379 | 'c' => 100 380 | ] 381 | ]; 382 | } 383 | 384 | } 385 | -------------------------------------------------------------------------------- /Tests/Unit/Domain/Decider/PercentageDeciderTest.php: -------------------------------------------------------------------------------- 1 | decide($this->getProvidedData(), new RandomComparator()); 26 | 27 | Assert::assertSame('a', $decision); 28 | } 29 | 30 | /** 31 | * Scenario: A/B - 30 - 70 32 | * 33 | * Test if the decider decides for 'a', when 'a' has 100% configured. 34 | * This test will cover configured decisions for tests where only a and b are configured. 35 | * 36 | * @dataProvider thirtyASeventyBProvider 37 | * @test 38 | */ 39 | public function deciderDecidesForAOnThirtyPercent($a, $b) 40 | { 41 | $fixedValueComparator = new FixedValueComparator($a - 1); 42 | $percentageDecider = new PercentageDecider(); 43 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 44 | 45 | Assert::assertSame('a', $decision); 46 | } 47 | 48 | /** 49 | * Scenario: A/B - 0 - 100 50 | * 51 | * Test if the decider decides for 'b', when 'b' has 100% configured. 52 | * This test will cover configured decisions for tests where only a and b are configured. 53 | * 54 | * @dataProvider hundredPercentBProvider 55 | * @test 56 | */ 57 | public function deciderDecidesForBOnHundredPercent($a, $b) 58 | { 59 | $percentageDecider = new PercentageDecider(); 60 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator()); 61 | 62 | Assert::assertSame('b', $decision); 63 | } 64 | 65 | /** 66 | * Scenario: A/B/C 0 - 0 - 100 67 | * 68 | * Test if the decider decides for 'c', when 'c' has 100% configured. 69 | * This test will cover configured decisions for tests where a, b and c are configured. 70 | * 71 | * @dataProvider hundredPercentCProvider 72 | * @test 73 | */ 74 | public function deciderDecidesForCOnHundredPercent($a, $b, $c) 75 | { 76 | $percentageDecider = new PercentageDecider(); 77 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator()); 78 | 79 | Assert::assertSame('c', $decision); 80 | } 81 | 82 | /** 83 | * Scenario: A/B/C 0 - 50 - 50 84 | * 85 | * Test if the decider decides for 'b' or 'c', when 'a' has 0 percent configured. 86 | * 87 | * @dataProvider zeroAFiftyBFiftyCProvider 88 | * @test 89 | */ 90 | public function deciderAlwaysDecidesForBorC($a, $b, $c) 91 | { 92 | $percentageDecider = new PercentageDecider(); 93 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator()); 94 | 95 | Assert::assertTrue(in_array($decision, ['b', 'c'])); 96 | Assert::assertNotSame('a', $decision); 97 | } 98 | 99 | /** 100 | * Scenario: A/B/C 50 - 0 - 50 101 | * 102 | * Test if the decider decides for 'a' or 'c', when 'b' has 0 percent configured. 103 | * 104 | * @dataProvider fiftyAZeroBFiftyCProvider 105 | * @test 106 | */ 107 | public function deciderCanOnlyDecideForAorC($a, $b, $c) 108 | { 109 | $percentageDecider = new PercentageDecider(); 110 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator()); 111 | 112 | Assert::assertTrue(in_array($decision, ['a', 'c'])); 113 | Assert::assertNotSame('b', $decision); 114 | } 115 | 116 | /** 117 | * Scenario: A/B/C 33 - 33 - 33 118 | * 119 | * Test if the decider decides for 'a', 'b' or 'c'. 120 | * 121 | * @dataProvider balancedAbcProvider 122 | * @test 123 | */ 124 | public function deciderCanOnlyDecideForAorBorC($a, $b, $c) 125 | { 126 | $percentageDecider = new PercentageDecider(); 127 | $decision = $percentageDecider->decide($this->getProvidedData(), new RandomComparator()); 128 | 129 | Assert::assertTrue(in_array($decision, ['a', 'b', 'c'])); 130 | } 131 | 132 | /** 133 | * Scenario: A/B 30 - 70, randomNumber $a -1 134 | * 135 | * Test if the decider only chooses 'a', when the random number is in range of a. 136 | * 137 | * @dataProvider thirtyASeventyBProvider 138 | * @test 139 | */ 140 | public function deciderDecidesOnlyForAWhenRandomNumberIsLowerThanB($a, $b) 141 | { 142 | $fixedValueComparator = new FixedValueComparator($a - 1); 143 | $percentageDecider = new PercentageDecider(); 144 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 145 | Assert::assertSame('a', $decision); 146 | } 147 | 148 | /** 149 | * Scenario: A/B 30 - 70, randomNumber $a + 1 150 | * 151 | * Test if the decider only chooses 'b', when the random number is in range of b. 152 | * 153 | * @dataProvider thirtyASeventyBProvider 154 | * @test 155 | */ 156 | public function deciderDecidesOnlyForBWhenRandomNumberIsHigherThanB($a, $b) 157 | { 158 | $fixedValueComparator = new FixedValueComparator($a + 1); 159 | $percentageDecider = new PercentageDecider(); 160 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 161 | Assert::assertSame('b', $decision); 162 | } 163 | 164 | 165 | /** 166 | * Scenario: A/B/C 33 - 33 - 33, randomNumber $a + 1 167 | * 168 | * Test if the decider only chooses 'c', when the random number is in range of b. 169 | * 170 | * @dataProvider balancedAbcProvider 171 | * @test 172 | */ 173 | public function deciderDecidesOnlyForBWhenRandomNumberIsHigherThanAAndLowerThanC($a, $b, $c) 174 | { 175 | $fixedValueComparator = new FixedValueComparator($a + 1); 176 | $percentageDecider = new PercentageDecider(); 177 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 178 | Assert::assertSame('b', $decision); 179 | } 180 | 181 | /** 182 | * Scenario: A/B/C 33 - 33 - 33, randomNumber $a + $b - 1 183 | * 184 | * @dataProvider balancedAbcProvider 185 | * @test 186 | */ 187 | public function deciderDecidesOnlyForBWhenRandomNumberIsLowerThanC($a, $b, $c) 188 | { 189 | $fixedValueComparator = new FixedValueComparator($a + $b - 1); 190 | $percentageDecider = new PercentageDecider(); 191 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 192 | Assert::assertSame('b', $decision); 193 | } 194 | 195 | /** 196 | * Scenario: A/B/C 33 - 33 - 33, randomNumber $a +$b + 1 197 | * 198 | * Test if the decider only chooses 'c', when the random number is in range of c. 199 | * 200 | * @dataProvider balancedAbcProvider 201 | * @test 202 | */ 203 | public function deciderDecidesOnlyForCWhenRandomNumberIsHigherThanAAndB($a, $b, $c) 204 | { 205 | $fixedValueComparator = new FixedValueComparator($a + $b + 1); 206 | $percentageDecider = new PercentageDecider(); 207 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 208 | Assert::assertSame('c', $decision); 209 | } 210 | 211 | /** 212 | * Scenario: several unbalanced A/B/C, randomNumber $a - 1 213 | * 214 | * @dataProvider unbalancedAbcProviderForA 215 | * @test 216 | */ 217 | public function deciderDecidesOnlyForAWhenRandomNumberIsLowerThanA($a, $b, $c) 218 | { 219 | $fixedValueComparator = new FixedValueComparator($a - 1); 220 | $percentageDecider = new PercentageDecider(); 221 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 222 | Assert::assertSame('a', $decision); 223 | } 224 | 225 | /** 226 | * Scenario: several unbalanced A/B/C, randomNumber $a + 1 227 | * 228 | * @dataProvider unbalancedAbcProviderForB 229 | * @test 230 | */ 231 | public function deciderDecidesForBWhenRandomNumberIsHigherThanA($a, $b, $c) 232 | { 233 | $fixedValueComparator = new FixedValueComparator($a + 1); 234 | $percentageDecider = new PercentageDecider(); 235 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 236 | Assert::assertSame('b', $decision); 237 | } 238 | 239 | /** 240 | * Scenario: several unbalanced A/B/C, randomNumber $a + $b + 1 241 | * 242 | * @dataProvider unbalancedAbcProviderForC 243 | * @test 244 | */ 245 | public function deciderDecidesForCWhenRandomNumberIsHigherThanAndB($a, $b, $c) 246 | { 247 | $fixedValueComparator = new FixedValueComparator($a + $b + 1); 248 | $percentageDecider = new PercentageDecider(); 249 | $decision = $percentageDecider->decide($this->getProvidedData(), $fixedValueComparator); 250 | Assert::assertSame('c', $decision); 251 | } 252 | } 253 | --------------------------------------------------------------------------------