Compare commits

...

99 Commits
0.2.1 ... 2

Author SHA1 Message Date
Maxime Rainville
ee49a440fb
Merge pull request #86 from creative-commoners/pulls/2/dispatch-ci
MNT Use gha-dispatch-ci
2023-03-23 12:16:16 +13:00
Steve Boyd
cb34aa869f MNT Use gha-dispatch-ci 2023-03-21 12:19:24 +13:00
Steve Boyd
5c8070044c Merge branch '2.4' into 2 2023-02-02 16:20:55 +13:00
Maxime Rainville
8e3f71afb7
Merge pull request #85 from creative-commoners/pulls/2.4/null-state
FIX Handle invalid json data
2023-01-26 13:15:33 +13:00
Steve Boyd
fe5e87598f FIX Handle invalid json data 2023-01-24 14:38:01 +13:00
Sabina Talipova
ffe1bc8fa2
Merge pull request #83 from creative-commoners/pulls/2/stop-depr
API Stop using deprecated API
2022-11-11 13:08:01 +13:00
Steve Boyd
132c00f122 API Stop using deprecated API 2022-11-03 18:11:29 +13:00
Steve Boyd
9b91b2de98 Merge branch '2.3' into 2 2022-08-02 19:06:18 +12:00
Steve Boyd
ecac4295af Merge branch '2.2' into 2.3 2022-08-02 19:05:52 +12:00
Guy Sartorelli
c4786cd955
Merge pull request #77 from creative-commoners/pulls/2.2/standardise-modules
MNT Standardise modules
2022-08-02 14:43:58 +12:00
Steve Boyd
ffe829485e MNT Standardise modules 2022-08-01 15:39:57 +12:00
Steve Boyd
fc63ebe8a9 Merge branch '2.3' into 2 2022-07-25 11:46:50 +12:00
Steve Boyd
8535a680ca Merge branch '2.2' into 2.3 2022-07-25 11:46:22 +12:00
Guy Sartorelli
8db444605a
MNT Fix linting issues (#76) 2022-07-18 13:18:35 +12:00
Guy Sartorelli
cefce74559
Merge pull request #75 from creative-commoners/pulls/2.0/module-standards
MNT Use GitHub Actions CI
2022-07-18 10:24:31 +12:00
Steve Boyd
a1e8643ea7 MNT Use GitHub Actions CI 2022-07-18 09:55:08 +12:00
Guy Sartorelli
0f5cb30743
Merge pull request #73 from creative-commoners/pulls/2/php81
ENH PHP 8.1 compatibility
2022-04-22 16:16:50 +12:00
Steve Boyd
7733cc7c95 Merge branch '2.3' into 2 2022-04-13 18:14:53 +12:00
Steve Boyd
22b9ca23cb
Merge pull request #74 from creative-commoners/pulls/2.3/add-missing-email-config
FIX Add missing config to fix silverstipe/admin tests.
2022-04-13 18:13:46 +12:00
Steve Boyd
5e2ef7e52c ENH PHP 8.1 compatibility 2022-04-13 17:40:59 +12:00
Guy Sartorelli
44e5364ec2 FIX Add missing config to fix silverstipe/admin tests. 2022-04-13 17:11:57 +12:00
Ingo Schommer
e44774dbf0
Fixed composer require instruction 2020-07-27 17:30:49 +12:00
Serge Latyntsev
0c479ad2eb
Merge pull request #63 from blueo/pulls/db-connection-on-state-load
Connect to test database on session load
2019-05-10 14:15:03 +12:00
Guy Marriott
efb6777ee9
BUGFIX: updated route config for testsession endpoint (#66)
BUGFIX: updated route config for testsession endpoint
2019-05-08 16:27:55 +12:00
pjayme
81c2417414 updated route config for testsession endpoint 2019-05-08 12:05:28 +12:00
Bernard Hamlin
075d960e5d Connect to test database on session load 2019-03-13 09:17:12 +13:00
Serge Latyntsev
61d12ec08a
Merge pull request #61 from webbuilders-group/db-reconnect-fix
BUGFIX: Fixed issue where the incorrect database connection could be made when using a stubfile
2019-02-01 14:49:01 +13:00
UndefinedOffset
e957d1e0fd BUGFIX: Fixed issue where the incorrect database connection could be made when using a stubfile (fixes #60) 2019-01-24 15:22:29 -04:00
Serge Latyntsev
8827e97417
Merge pull request #59 from open-sausages/pulls/2.2/wait-for-pending-requests-for-real
Fix TestSessionState and TestSessionEnvironment
2019-01-10 16:24:15 +13:00
Serge Latyntcev
f54baefb5a Rename TestSessionState::microtime to millitime 2019-01-10 15:30:39 +13:00
Serge Latyntcev
32c8e6a3b1 Fix TestSessionState and TestSessionEnvironment
Fixing a bug that makes it only wait for pending requests, but
not for some time after the last response
2019-01-10 11:48:59 +13:00
Maxime Rainville
fc0f7baa11
Merge pull request #58 from open-sausages/pulls/2.2/pending-requests-awaited
ADD / TestSessionState initial implementation
2019-01-08 17:35:17 +13:00
Serge Latyntcev
0c078e5027 TestSessionState implementation refinement;
Move increment/decrement methods to TestSessionState class,
fix some documentation, fix some code style and readability issues
2019-01-08 16:47:37 +13:00
Serge Latyntcev
78dd43ed96 ADD / TestSessionState initial implementation
TestSessionState model initial implementation
TestSessionEnvironment to initialize the state for every scenario and provide API for the clients to use it
TestSessionHTTPMiddleware to keep the state fields up to date
2018-12-19 14:28:38 +13:00
Damian Mooyman
e6c9817328
Update 2 branch alias 2018-03-20 15:07:34 +13:00
Daniel Hensby
961069473c
Merge pull request #56 from open-sausages/pulls/2/fix-assets
BUG Prevent assets folder being destroyed on behat tests
2018-03-07 11:40:40 +00:00
Damian Mooyman
2e25beb703
BUG Prevent assets folder being destroyed on behat tests
ENHANCEMENT Shift into vendormodule
2018-03-07 14:34:56 +13:00
Damian Mooyman
2c277b53fb
Update branch alias for 2.x-dev to 2.1.x-dev 2017-11-28 13:20:08 +13:00
Damian Mooyman
5413f9182e
Merge branch '2.0' into 2 2017-11-28 13:19:29 +13:00
Damian Mooyman
3a65d766c7
Remove branch-alias for 2.0 branch 2017-11-28 13:19:02 +13:00
Damian Mooyman
03a1311bef Merge pull request #54 from open-sausages/pulls/2.0/i-am-getenv-us-of-your-progress
Fix update getenv call
2017-10-27 14:50:34 +13:00
Christopher Joe
a523d66402 Fix update getenv call 2017-10-27 14:37:12 +13:00
Loz Calver
b34a4f9081 Merge pull request #53 from dhensby/pulls/yml-syntax
FIX Quote yample starting with %
2017-07-27 10:59:07 +01:00
Daniel Hensby
b18ac73d81
FIX Quote yample starting with % 2017-07-27 10:47:35 +01:00
Chris Joe
2b64499e36 Merge pull request #52 from open-sausages/pulls/4.0/upgrader-tweaks
Upgrade code with upgrader
2017-07-03 16:10:18 +12:00
Damian Mooyman
0374e935e7
Upgrade code with upgrader 2017-07-03 15:09:05 +12:00
Damian Mooyman
62cd6303da Merge pull request #50 from open-sausages/pulls/4.0/update-middleware
API Replace request filter with middleware
2017-06-27 13:31:11 +12:00
Damian Mooyman
3e08c1aad0 API Replace request filter with middleware 2017-06-27 13:00:41 +12:00
Damian Mooyman
81130c7025 Merge pull request #51 from silverstripe/pulls/1/env-in-shined
Fix converted to getenv()
2017-06-27 12:58:16 +12:00
Christopher Joe
a5d1545347 Fix converted to getenv() 2017-06-27 12:22:58 +12:00
Ingo Schommer
787ba1cc67 Merge pull request #49 from open-sausages/pulls/4.0/app-object
[WIP] App object refactor
2017-06-22 23:01:41 +12:00
Damian Mooyman
2868e6bd3e Update references to deprecated global 2017-06-22 15:15:17 +12:00
Damian Mooyman
f5ef9f4fbf API Use new TempDatabase service 2017-06-21 21:04:10 +12:00
Damian Mooyman
1651d5695a
Update testsession for SapphireTest changes 2017-06-13 15:00:32 +12:00
Damian Mooyman
9ec863f917
Fix session accessors 2017-06-12 14:59:20 +12:00
Damian Mooyman
1ab843776d
App object refactor 2017-06-08 18:01:46 +12:00
Daniel Hensby
be78e58ff6 FIX Be less opinionated about test DB name 2017-05-13 10:23:45 +12:00
Damian Mooyman
beda9e158f API Namespace and update for ss 4.0 2017-04-26 15:59:54 +12:00
Damian Mooyman
04fe1e5ee8 Merge pull request #46 from open-sausages/pulls/4.0/6626-remove-jquery-datepicker
Fixed DatetimeField API usage
2017-04-04 10:58:40 +12:00
Ingo Schommer
7982a853e9 Fixed DatetimeField API usage
See https://github.com/silverstripe/silverstripe-framework/issues/6626
2017-04-03 20:20:07 +12:00
Damian Mooyman
8826a70e4d Merge pull request #44 from silverstripe/fix-composer
FIX: Allow pre-release versions of SS4.
2017-02-27 14:38:54 +13:00
Chris Joe
0f06483edd Merge pull request #45 from open-sausages/pulls/4.0/fix-header-blocks
BUG Fix use of duplicate yml header block
2017-02-27 13:30:09 +13:00
Damian Mooyman
7457549131 BUG Fix use of duplicate yml header block 2017-02-27 13:19:19 +13:00
Sam Minnee
74eed2a6d0 FIX: Allow pre-release versions of SS4.
Since SS4 has not yet been released, and this module is used to test
pre-release versions of SS4, we need to have @dev on the requirement
to make it as flexible as possible.

As a general rule, modules that plug *into* SS4 rather than making *use*
of it should have @dev on the end of their dependencies.
2017-02-27 09:08:43 +11:00
Ingo Schommer
9ca529492b Merge pull request #43 from open-sausages/pulls/4.0/i18n-locale
Update to use php-intl instead of zend_date
2017-02-16 10:57:06 +13:00
Damian Mooyman
6ad8d24946
Update to use php-intl instead of zend_date 2017-02-07 17:52:29 +13:00
Daniel Hensby
00367f210c Merge pull request #40 from open-sausages/pulls/upgrade-config
Update config names to match SS4 namespaces
2016-09-16 11:47:01 +01:00
Damian Mooyman
a9223ab1ba Merge pull request #41 from open-sausages/webpack
Webpack compat
2016-09-16 14:18:04 +12:00
Ingo Schommer
79552d2667 Use CDN jQuery for TestSessionController
We shouldn't hotlink to static asset files
in other modules since these paths aren't a "public api",
and tend to move around.
2016-09-15 15:16:37 +12:00
Ingo Schommer
a54ddb5513 Changed debug.css paths
See https://github.com/silverstripe/silverstripe-framework/pull/5918
2016-09-15 11:30:59 +12:00
Ingo Schommer
eb24406c5b Update config names to match SS4 namespaces 2016-09-15 11:25:44 +12:00
Daniel Hensby
81d4b73c2a Merge pull request #39 from sminnee/fix-alpha2
FIX: Fixes for final 4.0-alpha2 class names.
2016-09-12 00:48:22 +01:00
Sam Minnee
9b85c80178 FIX: Use composer-aware phpunit to run tests.
SilverStripe now requires the phpunit autoloader to work, which means
that a non-composer-installed copy of phpunit won’t work.
2016-09-12 11:43:12 +12:00
Sam Minnee
0662a9ad90 FIX: Fixes for final 4.0-alpha2 class names. 2016-09-10 11:19:56 +12:00
Ingo Schommer
7b918814ba Merge pull request #38 from open-sausages/pulls/4.0/namespace-everything
Upgrade for framework namespacing
2016-09-08 16:13:13 +12:00
Damian Mooyman
1bedade619 Upgrade for framework namespacing 2016-08-29 16:17:53 +12:00
Ingo Schommer
3b06f5f738 Merge pull request #36 from open-sausages/pulls/namespace-orm
Update for SilverStripe\ORM namespace
2016-07-01 15:08:17 +12:00
Damian Mooyman
cbcd808dde
Update for SilverStripe\ORM namespace
Lock master branch to 4.x compat due to incompatibliities
2016-06-27 15:08:08 +12:00
Daniel Hensby
e66772fdce Merge pull request #35 from helpfulrobot/add-standard-scrutinizer-config
Added standard Scrutinizer config
2016-03-22 11:24:59 +00:00
helpfulrobot
4e02738323 Added standard Scrutinizer config 2016-02-17 05:31:54 +13:00
Daniel Hensby
b91d12205c Merge pull request #34 from helpfulrobot/add-standard-code-of-conduct-file
Added standard code of conduct file
2016-02-16 09:42:10 +00:00
helpfulrobot
3feceb1629 Added standard code of conduct file 2016-02-16 11:43:36 +13:00
Daniel Hensby
ac8492b546 Merge pull request #30 from jeffreyguo/pulls/cleanup-temp-db
clean up team db after test session run
2016-01-21 22:35:21 +00:00
Daniel Hensby
c1a3d89e8f Merge pull request #32 from tractorcow/pulls/fix-tests
BUG Fix test dependencies for master branch
2016-01-18 16:06:06 +00:00
Damian Mooyman
dd5f97c49a BUG Fix test dependencies for master branch 2016-01-18 15:51:42 +13:00
Damian Mooyman
4e4e7f0cc4 Merge pull request #31 from helpfulrobot/add-standard-gitattributes-file
Added standard .gitattributes file
2016-01-18 15:48:37 +13:00
helpfulrobot
99b06efae1 Added standard .gitattributes file 2016-01-16 19:34:37 +13:00
Jeffrey Guo
2b21b068ce clean up team db after test session run 2016-01-12 14:56:31 +13:00
Damian Mooyman
5167be8d96 Merge pull request #29 from helpfulrobot/convert-to-psr-2
Converted to PSR-2
2015-12-18 10:05:50 +13:00
helpfulrobot
e40a0d0f1a Converted to PSR-2 2015-12-18 07:17:16 +13:00
Damian Mooyman
4015611673 Merge pull request #28 from helpfulrobot/add-standard-editorconfig-file
Added standard .editorconfig file
2015-12-17 13:30:18 +13:00
helpfulrobot
5a7bbfff5e Added standard .editorconfig file 2015-12-17 10:09:08 +13:00
Ingo Schommer
5b81862b7d Merge pull request #26 from IgorNadj/master
MINOR: readme typo
2015-10-19 15:15:57 +13:00
Igor Nadj
2545b3b8cd MINOR: readme typo 2015-10-19 15:04:14 +13:00
Ingo Schommer
3aa661ead8 Removed 5.3+master build config 2015-10-18 17:17:25 +13:00
Ingo Schommer
a5acde606f Updated CI dependencies
Test against PHP 5.6, only test PHP 5.3 against SS 3.2 (since SS 4.0 isn't PHP 5.3 compatible)
2015-10-18 14:13:51 +13:00
Ingo Schommer
afc0e7ea07 Documented database import params in README 2015-10-18 14:11:33 +13:00
Ingo Schommer
c5e9f33531 Merge pull request #24 from IgorNadj/patch-1
ENH: allow specify database by filename
2015-10-18 14:09:59 +13:00
Igor
5c08aa9248 ENH: allow specify database by filename 2015-10-16 11:13:00 +13:00
32 changed files with 1492 additions and 1065 deletions

20
.editorconfig Normal file
View File

@ -0,0 +1,20 @@
# For more information about the properties used in this file,
# please see the EditorConfig documentation:
# http://editorconfig.org
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{*.yml,*.json}]
indent_size = 2
[composer.json]
indent_size = 4
# The indent size used in the package.json file cannot be changed:
# https://github.com/npm/npm/pull/3180#issuecomment-16336516

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
/tests export-ignore
/.travis.yml export-ignore

11
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,11 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
ci:
name: CI
uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1

16
.github/workflows/dispatch-ci.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Dispatch CI
on:
# At 2:30 PM UTC, only on Monday and Tuesday
schedule:
- cron: '30 14 * * 1,2'
jobs:
dispatch-ci:
name: Dispatch CI
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Dispatch CI
uses: silverstripe/gha-dispatch-ci@v1

17
.github/workflows/keepalive.yml vendored Normal file
View File

@ -0,0 +1,17 @@
name: Keepalive
on:
workflow_dispatch:
# The 4th of every month at 10:50am UTC
schedule:
- cron: '50 10 4 * *'
jobs:
keepalive:
name: Keepalive
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Keepalive
uses: silverstripe/gha-keepalive@v1

View File

@ -1,30 +0,0 @@
# See https://github.com/silverstripe-labs/silverstripe-travis-support for setup details
language: php
sudo: false
php:
- 5.3
env:
matrix:
- DB=MYSQL CORE_RELEASE=master
matrix:
include:
- php: 5.3
env: DB=PGSQL CORE_RELEASE=master
- php: 5.4
env: DB=MYSQL CORE_RELEASE=master
- php: 5.5
env: DB=MYSQL CORE_RELEASE=master
before_script:
- phpenv rehash
- git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support
- php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss
- cd ~/builds/ss
script:
- phpunit testsession/tests/unit/

7
.upgrade.yml Normal file
View File

@ -0,0 +1,7 @@
mappings:
TestSessionController: SilverStripe\TestSession\TestSessionController
TestSessionEnvironment: SilverStripe\TestSession\TestSessionEnvironment
TestSessionRequestFilter: SilverStripe\TestSession\TestSessionHTTPMiddleware
TestSessionStubCodeWriter: SilverStripe\TestSession\TestSessionStubCodeWriter
excludedPaths:
- '*/testsession/_config/services.yml'

View File

@ -1,6 +1,6 @@
# Browser Test Session Module # Browser Test Session Module
[![Build Status](https://travis-ci.org/silverstripe-labs/silverstripe-testsession.svg)](https://travis-ci.org/silverstripe-labs/silverstripe-testsession) [![CI](https://github.com/silverstripe/silverstripe-testsession/actions/workflows/ci.yml/badge.svg)](https://github.com/silverstripe/silverstripe-testsession/actions/workflows/ci.yml)
## Overview ## Overview
@ -8,7 +8,7 @@
*It's completely possible to allow any user to become an admin, or do other nefarious things, if this is installed on a live site.* *It's completely possible to allow any user to become an admin, or do other nefarious things, if this is installed on a live site.*
This module starts a testing session in a browser, This module starts a testing session in a browser,
in order to test a SilverStripe application in a clean state. in order to test a Silverstripe application in a clean state.
Usually the session is started on a fresh database with only default records loaded. Usually the session is started on a fresh database with only default records loaded.
Further data can be loaded from YAML fixtures or database dumps. Further data can be loaded from YAML fixtures or database dumps.
@ -20,8 +20,13 @@ is a random token stored in the browser session, in order to make the
test session specific to the executing browser, and allow multiple test session specific to the executing browser, and allow multiple
people using their own test session in the same webroot. people using their own test session in the same webroot.
The module also keeps some metadata about the session state in the database,
so that it may be available for the clients as well.
E.g. the silverstripe-behat-extension may use it through this module APIs,
allowing us to introduce some grey-box testing techniques.
The module also serves as an initializer for the The module also serves as an initializer for the
[SilverStripe Behat Extension](https://github.com/silverstripe-labs/silverstripe-behat-extension/). [Silverstripe Behat Extension](https://github.com/silverstripe-labs/silverstripe-behat-extension/).
It is required for Behat because the Behat CLI test runner needs to persist It is required for Behat because the Behat CLI test runner needs to persist
test configuration just for the tested browser connection, test configuration just for the tested browser connection,
available on arbitary URL endpoints. For example, available on arbitary URL endpoints. For example,
@ -30,7 +35,7 @@ into a temporary database table for inspection by the CLI-based process.
## Setup ## Setup
Simply require the module in a SilverStripe webroot (3.0 or newer): Simply require the module in a Silverstripe webroot (3.0 or newer):
composer require --dev silverstripe/behat-extension composer require --dev silverstripe/behat-extension
@ -58,7 +63,8 @@ on "dev/testsession/start":
(see [fixture format docs](http://doc.silverstripe.org/en/developer_guides/testing/fixtures/)). (see [fixture format docs](http://doc.silverstripe.org/en/developer_guides/testing/fixtures/)).
The path should be relative to the webroot. The path should be relative to the webroot.
* `createDatabase`: Create a temporary database. * `createDatabase`: Create a temporary database.
* `createDatabaseTemplate`: Path to a database dump to load into a newly created temporary database. * `importDatabasePath`: Absolute path to a database dump to load into a newly created temporary database.
* `importDatabaseFilename`: File name for a database dump to load, relative to `TestSessionController.database_templates_path`
* `requireDefaultRecords`: Include default records as defined on the model classes (in PHP) * `requireDefaultRecords`: Include default records as defined on the model classes (in PHP)
* `database`: Set an alternative database name in the current * `database`: Set an alternative database name in the current
browser session as a cookie. Does not actually create the database, browser session as a cookie. Does not actually create the database,

View File

@ -1,24 +1,27 @@
<?php <?php
use SilverStripe\ORM\DB;
use SilverStripe\TestSession\TestSessionEnvironment;
// Determine whether there is a testsession currently running, and if so - setup the persistent details for it. // Determine whether there is a testsession currently running, and if so - setup the persistent details for it.
Injector::inst()->get('TestSessionEnvironment')->loadFromFile(); TestSessionEnvironment::singleton()->loadFromFile();
/** /**
* This closure will run every time a Resque_Event is forked (just before it is forked, so it applies to the parent * This closure will run every time a Resque_Event is forked (just before it is forked, so it applies to the parent
* and child process). * and child process).
*/ */
if(class_exists('Resque_Event') && class_exists('SSResqueRun')) { if (class_exists('Resque_Event') && class_exists('SSResqueRun')) {
Resque_Event::listen('beforeFork', function($data) { Resque_Event::listen('beforeFork', function ($data) {
global $databaseConfig; $databaseConfig = DB::getConfig();
// Reconnect to the database - this may connect to the old DB first, but is required because these processes // Reconnect to the database - this may connect to the old DB first, but is required because these processes
// are long-lived, and MySQL connections often get closed in between worker runs. We need to connect before // are long-lived, and MySQL connections often get closed in between worker runs. We need to connect before
// calling {@link TestSessionEnvironment::loadFromFile()}. // calling {@link TestSessionEnvironment::loadFromFile()}.
DB::connect($databaseConfig); DB::connect($databaseConfig);
$testEnv = Injector::inst()->get('TestSessionEnvironment'); $testEnv = TestSessionEnvironment::singleton();
if($testEnv->isRunningTests()) { if ($testEnv->isRunningTests()) {
$testEnv->loadFromFile(); $testEnv->loadFromFile();
} else { } else {
$testEnv->endTestSession(); $testEnv->endTestSession();

View File

@ -1,8 +0,0 @@
---
Name: requestprocessors
---
Injector:
RequestProcessor:
properties:
filters:
- '%$TestSessionRequestFilter'

12
_config/processors.yml Normal file
View File

@ -0,0 +1,12 @@
---
Name: testsessionprocessors
After:
- requestprocessors
Before:
- coresecurity
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Control\Director:
properties:
Middlewares:
TestSessionHTTPMiddleware: '%$SilverStripe\TestSession\TestSessionHTTPMiddleware'

View File

@ -1,6 +1,7 @@
--- ---
Name: testsessionroutes Name: testsessionroutes
--- ---
Director: SilverStripe\Dev\DevelopmentAdmin:
rules: registered_controllers:
'dev/testsession': 'TestSessionController' testsession:
controller: SilverStripe\TestSession\TestSessionController

8
_config/services.yml Normal file
View File

@ -0,0 +1,8 @@
---
Name: testsessionservices
---
SilverStripe\Core\Injector\Injector:
SilverStripe\TestSession\TestSessionEnvironment:
class: SilverStripe\TestSession\TestSessionEnvironment
# shorthand alias for FQN
TestSessionEnvironment: '%$SilverStripe\TestSession\TestSessionEnvironment'

1
code-of-conduct.md Normal file
View File

@ -0,0 +1 @@
When having discussions about this module in issues or pull request please adhere to the [SilverStripe Community Code of Conduct](https://docs.silverstripe.org/en/contributing/code_of_conduct).

View File

@ -1,380 +0,0 @@
<?php
/**
* Requires PHP's mycrypt extension in order to set the database name as an encrypted cookie.
*/
class TestSessionController extends Controller {
private static $allowed_actions = array(
'index',
'start',
'set',
'end',
'clear',
'browsersessionstate',
'StartForm',
'ProgressForm',
);
private static $alternative_database_name = -1;
/**
* @var String Absolute path to a folder containing *.sql dumps.
*/
private static $database_templates_path;
/**
* @var TestSessionEnvironment
*/
protected $environment;
public function __construct() {
parent::__construct();
$this->environment = Injector::inst()->get('TestSessionEnvironment');
}
public function init() {
parent::init();
$this->extend('init');
$canAccess = (
!Director::isLive()
&& (Director::isDev() || Director::isTest() || Director::is_cli() || Permission::check("ADMIN"))
);
if(!$canAccess) return Security::permissionFailure($this);
Requirements::javascript('framework/thirdparty/jquery/jquery.js');
Requirements::javascript('testsession/javascript/testsession.js');
}
public function Link($action = null) {
return Controller::join_links(Director::baseUrl(), 'dev/testsession', $action);
}
public function index() {
if($this->environment->isRunningTests()) {
return $this->renderWith('TestSession_inprogress');
} else {
return $this->renderWith('TestSession_start');
}
}
/**
* Start a test session. If you wish to extend how the test session is started (and add additional test state),
* then take a look at {@link TestSessionEnvironment::startTestSession()} and
* {@link TestSessionEnvironment::applyState()} to see the extension points.
*/
public function start() {
$params = $this->request->requestVars();
if(!empty($params['globalTestSession'])) {
$id = null;
} else {
$generator = Injector::inst()->get('RandomGenerator');
$id = substr($generator->randomToken(), 0, 10);
Session::set('TestSessionId', $id);
}
// Convert datetime from form object into a single string
$params = $this->fixDatetimeFormField($params);
// Remove unnecessary items of form-specific data from being saved in the test session
$params = array_diff_key(
$params,
array(
'action_set' => true,
'action_start' => true,
'SecurityID' => true,
'url' => true,
'flush' => true,
)
);
$this->environment->startTestSession($params, $id);
// Optionally import database
if(!empty($params['importDatabasePath'])) {
$this->environment->importDatabase(
$params['importDatabasePath'],
!empty($params['requireDefaultRecords']) ? $params['requireDefaultRecords'] : false
);
} else if(!empty($params['requireDefaultRecords']) && $params['requireDefaultRecords']) {
$this->environment->requireDefaultRecords();
}
// Fixtures
$fixtureFile = (!empty($params['fixture'])) ? $params['fixture'] : null;
if($fixtureFile) {
$this->environment->loadFixtureIntoDb($fixtureFile);
}
return $this->renderWith('TestSession_inprogress');
}
/**
* Set $_SESSION state for the current browser session.
*/
public function browsersessionstate($request) {
if(!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$newSessionStates = array_diff_key($request->getVars(), array('url' => true));
if(!$newSessionStates) {
throw new LogicException('No query parameters detected');
}
$sessionStates = (array)Session::get('_TestSessionController.BrowserSessionState');
foreach($newSessionStates as $k => $v) {
Session::set($k, $v);
}
// Track which state we're setting so we can unset later in end()
Session::set('_TestSessionController.BrowserSessionState', array_merge($sessionStates, $newSessionStates));
}
public function StartForm() {
$databaseTemplates = $this->getDatabaseTemplates();
$fields = new FieldList(
new CheckboxField('createDatabase', 'Create temporary database?', 1)
);
if($databaseTemplates) {
$fields->push(
$dropdown = new DropdownField('importDatabasePath', false)
);
$dropdown->setSource($databaseTemplates)
->setEmptyString('Empty database');
}
$fields->push(new CheckboxField('requireDefaultRecords', 'Create default data?'));
if(Director::isDev()) {
$fields->push(
CheckboxField::create('globalTestSession', 'Use global test session?')
->setDescription('Caution: Will apply to all users across browsers')
);
}
$fields->merge($this->getBaseFields());
$form = new Form(
$this,
'StartForm',
$fields,
new FieldList(
new FormAction('start', 'Start Session')
)
);
$this->extend('updateStartForm', $form);
return $form;
}
/**
* Shows state which is allowed to be modified while a test session is in progress.
*/
public function ProgressForm() {
$fields = $this->getBaseFields();
$form = new Form(
$this,
'ProgressForm',
$fields,
new FieldList(
new FormAction('set', 'Set testing state')
)
);
$form->setFormAction($this->Link('set'));
$this->extend('updateProgressForm', $form);
return $form;
}
protected function getBaseFields() {
$testState = $this->environment->getState();
$fields = new FieldList(
$textfield = new TextField('fixture', 'Fixture YAML file path'),
$datetimeField = new DatetimeField('datetime', 'Custom date'),
new HiddenField('flush', null, 1)
);
$textfield->setAttribute('placeholder', 'Example: framework/tests/security/MemberTest.yml');
$datetimeField->getDateField()
->setConfig('dateformat', 'yyyy-MM-dd')
->setConfig('showcalendar', true)
->setAttribute('placeholder', 'Date (yyyy-MM-dd)');
$datetimeField->getTimeField()
->setConfig('timeformat', 'HH:mm:ss')
->setAttribute('placeholder', 'Time (HH:mm:ss)');
$datetimeField->setValue((isset($testState->datetime) ? $testState->datetime : null));
$this->extend('updateBaseFields', $fields);
return $fields;
}
public function DatabaseName() {
$db = DB::get_conn();
return $db->getSelectedDatabase();
}
/**
* Updates an in-progress {@link TestSessionEnvironment} object with new details. This could be loading in new
* fixtures, setting the mocked date to another value etc.
*
* @return HTMLText Rendered Template
* @throws LogicException
*/
public function set() {
if(!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$params = $this->request->requestVars();
// Convert datetime from form object into a single string
$params = $this->fixDatetimeFormField($params);
// Remove unnecessary items of form-specific data from being saved in the test session
$params = array_diff_key(
$params,
array(
'action_set' => true,
'action_start' => true,
'SecurityID' => true,
'url' => true,
'flush' => true,
)
);
$this->environment->updateTestSession($params);
return $this->renderWith('TestSession_inprogress');
}
public function clear() {
if(!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$this->extend('onBeforeClear');
if(SapphireTest::using_temp_db()) {
SapphireTest::empty_temp_db();
}
if(isset($_SESSION['_testsession_codeblocks'])) {
unset($_SESSION['_testsession_codeblocks']);
}
$this->extend('onAfterClear');
return "Cleared database and test state";
}
/**
* As with {@link self::start()}, if you want to extend the functionality of this, then look at
* {@link TestSessionEnvironent::endTestSession()} as the extension points have moved to there now that the logic
* is there.
*/
public function end() {
if(!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$this->environment->endTestSession();
Session::clear('TestSessionId');
// Clear out all PHP session states which have been set previously
if($sessionStates = Session::get('_TestSessionController.BrowserSessionState')) {
foreach($sessionStates as $k => $v) {
Session::clear($k);
}
Session::clear('_TestSessionController');
}
return $this->renderWith('TestSession_end');
}
/**
* @return boolean
*/
public function isTesting() {
return SapphireTest::using_temp_db();
}
public function setState($data) {
Deprecation::notice('3.1', 'TestSessionController::setState() is no longer used, please use '
. 'TestSessionEnvironment instead.');
}
/**
* @return ArrayList
*/
public function getState() {
$stateObj = $this->environment->getState();
$state = array();
// Convert the stdObject of state into ArrayData
foreach($stateObj as $k => $v) {
$state[] = new ArrayData(array(
'Name' => $k,
'Value' => var_export($v, true)
));
}
return new ArrayList($state);
}
/**
* Get all *.sql database files located in a specific path,
* keyed by their file name.
*
* @param String $path Absolute folder path
* @return array
*/
protected function getDatabaseTemplates($path = null) {
$templates = array();
if(!$path) {
$path = $this->config()->database_templates_path;
}
// TODO Remove once we can set BASE_PATH through the config layer
if($path && !Director::is_absolute($path)) {
$path = BASE_PATH . '/' . $path;
}
if($path && file_exists($path)) {
$it = new FilesystemIterator($path);
foreach($it as $fileinfo) {
if($fileinfo->getExtension() != 'sql') continue;
$templates[$fileinfo->getRealPath()] = $fileinfo->getFilename();
}
}
return $templates;
}
/**
* @param $params array The form fields as passed through from ->start() or ->set()
* @return array The form fields, after fixing the datetime field if necessary
*/
private function fixDatetimeFormField($params) {
if(isset($params['datetime']) && is_array($params['datetime']) && !empty($params['datetime']['date'])) {
// Convert DatetimeField format from array into string
$datetime = $params['datetime']['date'];
$datetime .= ' ';
$datetime .= (@$params['datetime']['time']) ? $params['datetime']['time'] : '00:00:00';
$params['datetime'] = $datetime;
} else if(isset($params['datetime']) && empty($params['datetime']['date'])) {
unset($params['datetime']); // No datetime, so remove the param entirely
}
return $params;
}
}

View File

@ -1,413 +0,0 @@
<?php
/**
* Responsible for starting and finalizing test sessions.
* Since these session span across multiple requests, session information is persisted
* in a file. This file is stored in the webroot by default, and the test session
* is considered "in progress" as long as this file exists.
*
* This allows for cross-request, cross-client sharing of the same testsession,
* for example: Behat CLI starts a testsession, then opens a web browser which
* makes a separate request picking up the same testsession.
*
* An environment can have an optional identifier ({@link id}), which allows
* multiple environments to exist at the same time in the same webroot.
* This enables parallel testing with (mostly) isolated state.
*
* For a valid test session to exist, this needs to contain at least:
* - database: The alternate database name that is being used for this test session (e.g. ss_tmpdb_1234567)
* It can optionally contain other details that should be passed through many separate requests:
* - datetime: Mocked SS_DateTime ({@see TestSessionRequestFilter})
* - mailer: Mocked Email sender ({@see TestSessionRequestFilter})
* - stubfile: Path to PHP stub file for setup ({@see TestSessionRequestFilter})
* Extensions of TestSessionEnvironment can add extra fields in here to be saved and restored on each request.
*
* See {@link $state} for default information stored in the test session.
*/
class TestSessionEnvironment extends Object {
/**
* @var int Optional identifier for the session.
*/
protected $id;
/**
* @var string The original database name, before we overrode it with our tmpdb.
*
* Used in {@link self::resetDatabaseName()} when we want to restore the normal DB connection.
*/
private $oldDatabaseName;
/**
* @config
* @var string Path (from web-root) to the test state file that indicates a testsession is in progress.
* Defaults to value stored in testsession/_config/_config.yml
*/
private static $test_state_file = 'TESTS_RUNNING.json';
/**
* @config
* @var [type]
*/
private static $test_state_id_file = 'TESTS_RUNNING-%s.json';
public function __construct($id = null) {
parent::__construct();
if($id) {
$this->id = $id;
} else {
Session::start();
// $_SESSION != Session::get() in some execution paths, suspect Controller->pushCurrent()
// as part of the issue, easiest resolution is to use session directly for now
$this->id = (isset($_SESSION['TestSessionId'])) ? $_SESSION['TestSessionId'] : null;
}
}
/**
* @return String Absolute path to the file persisting our state.
*/
public function getFilePath() {
if($this->id) {
$path = Director::getAbsFile(sprintf($this->config()->test_state_id_file, $this->id));
} else {
$path = Director::getAbsFile($this->config()->test_state_file);
}
return $path;
}
/**
* Tests for the existence of the file specified by $this->test_state_file
*/
public function isRunningTests() {
return(file_exists($this->getFilePath()));
}
/**
* @param String $id
*/
public function setId($id) {
$this->id = $id;
}
/**
* @return String
*/
public function getId() {
return $this->id;
}
/**
* Creates a temp database, sets up any extra requirements, and writes the state file. The database will be
* connected to as part of {@link self::applyState()}, so if you're continuing script execution after calling this
* method, be aware that the database will be different - so various things may break (e.g. administrator logins
* using the SS_DEFAULT_USERNAME / SS_DEFAULT_PASSWORD constants).
*
* If something isn't explicitly handled here, and needs special handling, then it should be taken care of by an
* extension to TestSessionEnvironment. You can either extend onBeforeStartTestSession() or
* onAfterStartTestSession(). Alternatively, for more fine-grained control, you can also extend
* onBeforeApplyState() and onAfterApplyState(). See the {@link self::applyState()} method for more.
*
* @param array $state An array of test state options to write.
*/
public function startTestSession($state = null, $id = null) {
if(!$state) $state = array();
$this->removeStateFile();
$this->id = $id;
// Assumes state will be modified by reference
$this->extend('onBeforeStartTestSession', $state);
// Convert to JSON and back so we can share the applyState() code between this and ->loadFromFile()
$json = json_encode($state, JSON_FORCE_OBJECT);
$state = json_decode($json);
$this->applyState($state);
$this->extend('onAfterStartTestSession');
}
public function updateTestSession($state) {
$this->extend('onBeforeUpdateTestSession', $state);
// Convert to JSON and back so we can share the appleState() code between this and ->loadFromFile()
$json = json_encode($state, JSON_FORCE_OBJECT);
$state = json_decode($json);
$this->applyState($state);
$this->extend('onAfterUpdateTestSession');
}
/**
* Assumes the database has already been created in startTestSession(), as this method can be called from
* _config.php where we don't yet have a DB connection.
*
* Persists the state to the filesystem.
*
* You can extend this by creating an Extension object and implementing either onBeforeApplyState() or
* onAfterApplyState() to add your own test state handling in.
*
* @throws LogicException
* @throws InvalidArgumentException
*/
public function applyState($state) {
$this->extend('onBeforeApplyState', $state);
$database = (isset($state->database)) ? $state->database : null;
// back up source
global $databaseConfig;
$this->oldDatabaseName = $databaseConfig['database'];
// Load existing state from $this->state into $state, if there is any
$oldState = $this->getState();
if($oldState) {
foreach($oldState as $k => $v) {
if(!isset($state->$k)) {
$state->$k = $v; // Don't overwrite stuff in $state, as that's the new state
}
}
}
// ensure we have a connection to the database
if(isset($state->database) && $state->database) {
if(!DB::get_conn()) {
// No connection, so try and connect to tmpdb if it exists
if(isset($state->database)) {
$this->oldDatabaseName = $databaseConfig['database'];
$databaseConfig['database'] = $state->database;
}
// Connect to database
DB::connect($databaseConfig);
} else {
// We've already connected to the database, do a fast check to see what database we're currently using
$db = DB::get_conn()->getSelectedDatabase();
if(isset($state->database) && $db != $state->database) {
$this->oldDatabaseName = $databaseConfig['database'];
$databaseConfig['database'] = $state->database;
DB::connect($databaseConfig);
}
}
}
// Database
if(!$this->isRunningTests()) {
$dbName = (isset($state->database)) ? $state->database : null;
if($dbName) {
$dbExists = DB::get_conn()->databaseExists($dbName);
} else {
$dbExists = false;
}
if(!$dbExists) {
// Create a new one with a randomized name
$dbName = SapphireTest::create_temp_db();
$state->database = $dbName; // In case it's changed by the call to SapphireTest::create_temp_db();
// Set existing one, assumes it already has been created
$prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_';
$pattern = strtolower(sprintf('#^%stmpdb\d{7}#', $prefix));
if(!preg_match($pattern, $dbName)) {
throw new InvalidArgumentException("Invalid database name format");
}
$this->oldDatabaseName = $databaseConfig['database'];
$databaseConfig['database'] = $dbName; // Instead of calling DB::set_alternative_db_name();
// Connect to the new database, overwriting the old DB connection (if any)
DB::connect($databaseConfig);
}
}
// Mailer
$mailer = (isset($state->mailer)) ? $state->mailer : null;
if($mailer) {
if(!class_exists($mailer) || !is_subclass_of($mailer, 'Mailer')) {
throw new InvalidArgumentException(sprintf(
'Class "%s" is not a valid class, or subclass of Mailer',
$mailer
));
}
}
// Date and time
if(isset($state->datetime)) {
require_once 'Zend/Date.php';
// Convert DatetimeField format
if(!Zend_Date::isDate($state->datetime, 'yyyy-MM-dd HH:mm:ss')) {
throw new LogicException(sprintf(
'Invalid date format "%s", use yyyy-MM-dd HH:mm:ss',
$state->datetime
));
}
}
$this->saveState($state);
$this->extend('onAfterApplyState');
}
/**
* Import the database
*
* @param String $path Absolute path to a SQL dump (include DROP TABLE commands)
* @return void
*/
public function importDatabase($path, $requireDefaultRecords = false) {
$sql = file_get_contents($path);
// Split into individual query commands, removing comments
$sqlCmds = array_filter(
preg_split('/;\n/',
preg_replace(array('/^$\n/m', '/^(\/|#).*$\n/m'), '', $sql)
)
);
// Execute each query
foreach($sqlCmds as $sqlCmd) {
DB::query($sqlCmd);
}
// In case the dump involved CREATE TABLE commands, we need to ensure the schema is still up to date
$dbAdmin = new DatabaseAdmin();
Versioned::set_reading_mode('');
$dbAdmin->doBuild(true, $requireDefaultRecords);
}
/**
* Build the database with default records, see {@link DataObject->requireDefaultRecords()}.
*/
public function requireDefaultRecords() {
$dbAdmin = new DatabaseAdmin();
Versioned::set_reading_mode('');
$dbAdmin->doBuild(true, true);
}
/**
* Sliented as if the file already exists by another process, we don't want
* to modify.
*/
public function saveState($state) {
if (defined('JSON_PRETTY_PRINT')) {
$content = json_encode($state, JSON_PRETTY_PRINT);
} else {
$content = json_encode($state);
}
$old = umask(0);
file_put_contents($this->getFilePath(), $content, LOCK_EX);
umask($old);
}
public function loadFromFile() {
if($this->isRunningTests()) {
try {
$contents = file_get_contents($this->getFilePath());
$json = json_decode($contents);
$this->applyState($json);
} catch(Exception $e) {
throw new \Exception("A test session appears to be in progress, but we can't retrieve the details. "
. "Try removing the " . $this->getFilePath() . " file. Inner "
. "error: " . $e->getMessage());
}
}
}
private function removeStateFile() {
$file = $this->getFilePath();
if(file_exists($file)) {
if(!unlink($file)) {
throw new \Exception('Unable to remove the testsession state file, please remove it manually. File '
. 'path: ' . $file);
}
}
}
/**
* Cleans up the test session state by restoring the normal database connect (for the rest of this request, if any)
* and removes the {@link self::$test_state_file} so that future requests don't use this test state.
*
* Can be extended by implementing either onBeforeEndTestSession() or onAfterEndTestSession().
*
* This should implement itself cleanly in case it is called twice (e.g. don't throw errors when the state file
* doesn't exist anymore because it's already been cleaned up etc.) This is because during behat test runs where
* a queueing system (e.g. silverstripe-resque) is used, the behat module may call this first, and then the forked
* worker will call it as well - but there is only one state file that is created.
*/
public function endTestSession() {
$this->extend('onBeforeEndTestSession');
if(SapphireTest::using_temp_db()) {
$this->resetDatabaseName();
SapphireTest::set_is_running_test(false);
}
$this->removeStateFile();
$this->extend('onAfterEndTestSession');
}
/**
* Loads a YAML fixture into the database as part of the {@link TestSessionController}.
*
* @param string $fixtureFile The .yml file to load
* @return FixtureFactory The loaded fixture
* @throws LogicException
*/
public function loadFixtureIntoDb($fixtureFile) {
$realFile = realpath(BASE_PATH.'/'.$fixtureFile);
$baseDir = realpath(Director::baseFolder());
if(!$realFile || !file_exists($realFile)) {
throw new LogicException("Fixture file doesn't exist");
} else if(substr($realFile,0,strlen($baseDir)) != $baseDir) {
throw new LogicException("Fixture file must be inside $baseDir");
} else if(substr($realFile,-4) != '.yml') {
throw new LogicException("Fixture file must be a .yml file");
} else if(!preg_match('/^([^\/.][^\/]+)\/tests\//', $fixtureFile)) {
throw new LogicException("Fixture file must be inside the tests subfolder of one of your modules.");
}
$factory = Injector::inst()->create('FixtureFactory');
$fixture = Injector::inst()->create('YamlFixture', $fixtureFile);
$fixture->writeInto($factory);
$state = $this->getState();
$state->fixtures[] = $fixtureFile;
$this->applyState($state);
return $fixture;
}
/**
* Reset the database connection to use the original database. Called by {@link self::endTestSession()}.
*/
public function resetDatabaseName() {
if($this->oldDatabaseName) {
global $databaseConfig;
$databaseConfig['database'] = $this->oldDatabaseName;
$conn = DB::get_conn();
if($conn) {
$conn->selectDatabase($this->oldDatabaseName, false, false);
}
}
}
/**
* @return stdClass Data as taken from the JSON object in {@link self::loadFromFile()}
*/
public function getState() {
$path = Director::getAbsFile($this->getFilePath());
return (file_exists($path)) ? json_decode(file_get_contents($path)) : new stdClass;
}
}

View File

@ -1,57 +0,0 @@
<?php
/**
* Sets state previously initialized through {@link TestSessionController}.
*/
class TestSessionRequestFilter implements RequestFilter {
/**
* @var TestSessionEnvironment
*/
protected $testSessionEnvironment;
public function __construct() {
$this->testSessionEnvironment = Injector::inst()->get('TestSessionEnvironment');
}
public function preRequest(SS_HTTPRequest $request, Session $session, DataModel $model) {
if(!$this->testSessionEnvironment->isRunningTests()) return;
$testState = $this->testSessionEnvironment->getState();
// Date and time
if(isset($testState->datetime)) {
SS_Datetime::set_mock_now($testState->datetime);
}
// Register mailer
if(isset($testState->mailer)) {
$mailer = $testState->mailer;
Email::set_mailer(new $mailer());
Config::inst()->update("Email","send_all_emails_to", null);
}
// Allows inclusion of a PHP file, usually with procedural commands
// to set up required test state. The file can be generated
// through {@link TestSessionStubCodeWriter}, and the session state
// set through {@link TestSessionController->set()} and the
// 'testsession.stubfile' state parameter.
if(isset($testState->stubfile)) {
$file = $testState->stubfile;
if(!Director::isLive() && $file && file_exists($file)) {
// Connect to the database so the included code can interact with it
global $databaseConfig;
if ($databaseConfig) DB::connect($databaseConfig);
include_once($file);
}
}
}
public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) {
if(!$this->testSessionEnvironment->isRunningTests()) return;
// Store PHP session
$state = $this->testSessionEnvironment->getState();
$state->session = Session::get_all();
$this->testSessionEnvironment->applyState($state);
}
}

View File

@ -1,73 +0,0 @@
<?php
/**
* Writes PHP to a file which can be included in SilverStripe runs on existence.
* The generated file is included in page execution through {@link TestSessionRequestFilter}.
*/
class TestSessionStubCodeWriter {
/**
* @var boolean Add debug statements to the generated PHP about
* the generator's origin code location.
*/
protected $debug = false;
/**
* @var String Absolute path to a PHP file, essentially the "name" of the stub.
*/
protected $filePath;
public function __construct($filePath = null) {
$this->filePath = $filePath ? $filePath : BASE_PATH . '/testSessionStubCode.php';
}
/**
* Writes arbitrary PHP code to {@link $filePath} for later inclusion.
* Creates the file if it doesn't exist.
* Adds debug information about the origin of this code if {@link $debug} is set.
*
* @param String $php Block of PHP code (without preceding <?php)
* @param boolean $eval Sanity check on code.
*/
public function write($php, $eval = true) {
$trace = $this->debug ? debug_backtrace() : null;
$path = $this->getFilePath();
$header = '';
// Create file incl. header if it doesn't exist
if(!file_exists($this->getFilePath())) {
touch($this->getFilePath());
if($this->debug) {
$header .= "<?php\n// Generated by " . $trace[1]['class'] . " on " . date('Y-m-d H:i:s') . "\n\n";
} else {
$header .= "<?php\n";
}
}
// Add content
if($this->debug) {
$header .= "// Added by " . $trace[1]['class'] . '::' . $trace[1]['function'] . "\n";
}
file_put_contents($path, $header . $php . "\n", FILE_APPEND);
}
public function reset() {
if(file_exists($this->getFilePath())) {
unlink($this->getFilePath());
}
}
public function getFilePath() {
return $this->filePath;
}
public function getDebug() {
return $this->debug;
}
public function setDebug($debug) {
$this->debug = $debug;
return $this;
}
}

View File

@ -1,10 +1,13 @@
{ {
"name": "silverstripe/testsession", "name": "silverstripe/testsession",
"type": "silverstripe-module", "type": "silverstripe-vendormodule",
"description": "Support module for browser-based test sessions, e.g. for Behat behaviour testing", "description": "Support module for browser-based test sessions, e.g. for Behat behaviour testing",
"homepage": "http://silverstripe.org", "homepage": "http://silverstripe.org",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"keywords": ["silverstripe", "testing"], "keywords": [
"silverstripe",
"testing"
],
"authors": [ "authors": [
{ {
"name": "SilverStripe", "name": "SilverStripe",
@ -12,9 +15,26 @@
} }
], ],
"require": { "require": {
"php": ">=5.3.2",
"composer/installers": "*", "composer/installers": "*",
"silverstripe/framework": ">=3.2.0" "silverstripe/framework": "^4@dev",
"silverstripe/vendor-plugin": "^1.3"
},
"require-dev": {
"squizlabs/php_codesniffer": "^3.5"
},
"extra": {
"expose": [
"client"
]
},
"scripts": {
"lint": "phpcs -s src/ tests/"
},
"autoload": {
"psr-4": {
"SilverStripe\\TestSession\\": "src/",
"SilverStripe\\TestSession\\Tests\\": "tests/"
}
}, },
"minimum-stability": "dev" "minimum-stability": "dev"
} }

12
phpcs.xml.dist Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="SilverStripe">
<description>CodeSniffer ruleset for SilverStripe coding conventions.</description>
<file>src</file>
<!-- base rules are PSR-2 -->
<rule ref="PSR2" >
<!-- Current exclusions -->
<exclude name="PSR1.Methods.CamelCapsMethodName" />
</rule>
</ruleset>

View File

@ -0,0 +1,433 @@
<?php
namespace SilverStripe\TestSession;
use FilesystemIterator;
use LogicException;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Forms\CheckboxField;
use SilverStripe\Forms\DatetimeField;
use SilverStripe\Forms\DropdownField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\TextField;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\Connect\TempDatabase;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\Security\Permission;
use SilverStripe\Security\RandomGenerator;
use SilverStripe\Security\Security;
use SilverStripe\View\ArrayData;
use SilverStripe\View\Requirements;
/**
* Requires PHP's mycrypt extension in order to set the database name as an encrypted cookie.
*/
class TestSessionController extends Controller
{
private static $url_segment = 'dev/testsession';
private static $allowed_actions = array(
'index',
'start',
'set',
'end',
'clear',
'browsersessionstate',
'StartForm',
'ProgressForm',
);
private static $alternative_database_name = -1;
/**
* @var String Absolute path to a folder containing *.sql dumps.
*/
private static $database_templates_path;
/**
* @var TestSessionEnvironment
*/
protected $environment;
public function __construct()
{
parent::__construct();
$this->environment = TestSessionEnvironment::singleton();
}
public function init()
{
parent::init();
$this->extend('init');
$canAccess = (
!Director::isLive()
&& (Director::isDev() || Director::isTest() || Director::is_cli() || Permission::check("ADMIN"))
);
if (!$canAccess) {
Security::permissionFailure($this);
return;
}
Requirements::javascript('//code.jquery.com/jquery-1.7.2.min.js');
Requirements::javascript('silverstripe/testsession:client/js/testsession.js');
}
public function index()
{
if ($this->environment->isRunningTests()) {
return $this->renderWith('TestSession_inprogress');
} else {
return $this->renderWith('TestSession_start');
}
}
/**
* Start a test session. If you wish to extend how the test session is started (and add additional test state),
* then take a look at {@link TestSessionEnvironment::startTestSession()} and
* {@link TestSessionEnvironment::applyState()} to see the extension points.
*/
public function start()
{
$params = $this->getRequest()->requestVars();
if (!empty($params['globalTestSession'])) {
$id = null;
} else {
$generator = Injector::inst()->get(RandomGenerator::class);
$id = substr($generator->randomToken() ?? '', 0, 10);
$this->getRequest()->getSession()->set('TestSessionId', $id);
}
// Convert datetime from form object into a single string
$params = $this->fixDatetimeFormField($params);
// Remove unnecessary items of form-specific data from being saved in the test session
$params = array_diff_key(
$params ?? [],
array(
'action_set' => true,
'action_start' => true,
'SecurityID' => true,
'url' => true,
'flush' => true,
)
);
$this->environment->startTestSession($params, $id);
// Optionally import database
if (!empty($params['importDatabasePath']) || !empty($params['importDatabaseFilename'])) {
$absPath = '';
// by path
if (!empty($params['importDatabasePath'])) {
$absPath = $params['importDatabasePath'];
// by filename
} elseif (!empty($params['importDatabaseFilename'])) {
foreach ($this->getDatabaseTemplates() as $tAbsPath => $tFilename) {
if ($tFilename === $params['importDatabaseFilename']) {
$absPath = $tAbsPath;
break;
}
}
}
$this->environment->importDatabase(
$absPath,
!empty($params['requireDefaultRecords']) ? $params['requireDefaultRecords'] : false
);
} elseif (!empty($params['requireDefaultRecords']) && $params['requireDefaultRecords']) {
$this->environment->requireDefaultRecords();
}
// Fixtures
$fixtureFile = (!empty($params['fixture'])) ? $params['fixture'] : null;
if ($fixtureFile) {
$this->environment->loadFixtureIntoDb($fixtureFile);
}
return $this->renderWith('TestSession_inprogress');
}
/**
* Set $_SESSION state for the current browser session.
*
* @param HTTPRequest $request
*/
public function browsersessionstate($request)
{
if (!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$newSessionStates = array_diff_key($request->getVars() ?? [], array('url' => true));
if (!$newSessionStates) {
throw new LogicException('No query parameters detected');
}
$session = $this->getRequest()->getSession();
$sessionStates = (array)$session->get('_TestSessionController.BrowserSessionState');
foreach ($newSessionStates as $k => $v) {
$session->set($k, $v);
}
// Track which state we're setting so we can unset later in end()
$session->set('_TestSessionController.BrowserSessionState', array_merge($sessionStates, $newSessionStates));
}
public function StartForm()
{
$databaseTemplates = $this->getDatabaseTemplates();
$fields = new FieldList(
new CheckboxField('createDatabase', 'Create temporary database?', 1)
);
if ($databaseTemplates) {
$fields->push(
$dropdown = new DropdownField('importDatabasePath', false)
);
$dropdown->setSource($databaseTemplates)
->setEmptyString('Empty database');
}
$fields->push(new CheckboxField('requireDefaultRecords', 'Create default data?'));
if (Director::isDev()) {
$fields->push(
CheckboxField::create('globalTestSession', 'Use global test session?')
->setDescription('Caution: Will apply to all users across browsers')
);
}
$fields->merge($this->getBaseFields());
$form = new Form(
$this,
'StartForm',
$fields,
new FieldList(
new FormAction('start', 'Start Session')
)
);
$this->extend('updateStartForm', $form);
return $form;
}
/**
* Shows state which is allowed to be modified while a test session is in progress.
*/
public function ProgressForm()
{
$fields = $this->getBaseFields();
$form = new Form(
$this,
'ProgressForm',
$fields,
new FieldList(
new FormAction('set', 'Set testing state')
)
);
$form->setFormAction($this->Link('set'));
$this->extend('updateProgressForm', $form);
return $form;
}
protected function getBaseFields()
{
$testState = $this->environment->getState();
$fields = new FieldList(
$textfield = new TextField('fixture', 'Fixture YAML file path'),
$datetimeField = new DatetimeField('datetime', 'Custom date'),
new HiddenField('flush', null, 1)
);
$textfield->setAttribute('placeholder', 'Example: framework/tests/security/MemberTest.yml');
$datetimeField->setValue((isset($testState->datetime) ? $testState->datetime : null));
$this->extend('updateBaseFields', $fields);
return $fields;
}
public function DatabaseName()
{
$db = DB::get_conn();
return $db->getSelectedDatabase();
}
/**
* Updates an in-progress {@link TestSessionEnvironment} object with new details. This could be loading in new
* fixtures, setting the mocked date to another value etc.
*
* @return DBHTMLText Rendered Template
* @throws LogicException
*/
public function set()
{
if (!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$params = $this->request->requestVars();
// Convert datetime from form object into a single string
$params = $this->fixDatetimeFormField($params);
// Remove unnecessary items of form-specific data from being saved in the test session
$params = array_diff_key(
$params ?? [],
array(
'action_set' => true,
'action_start' => true,
'SecurityID' => true,
'url' => true,
'flush' => true,
)
);
$this->environment->updateTestSession($params);
return $this->renderWith('TestSession_inprogress');
}
public function clear()
{
if (!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$this->extend('onBeforeClear');
$tempDB = new TempDatabase();
if ($tempDB->isUsed()) {
$tempDB->clearAllData();
}
if (isset($_SESSION['_testsession_codeblocks'])) {
unset($_SESSION['_testsession_codeblocks']);
}
$this->extend('onAfterClear');
return "Cleared database and test state";
}
/**
* As with {@link self::start()}, if you want to extend the functionality of this, then look at
* {@link TestSessionEnvironent::endTestSession()} as the extension points have moved to there now that the logic
* is there.
*/
public function end()
{
if (!$this->environment->isRunningTests()) {
throw new LogicException("No test session in progress.");
}
$this->environment->endTestSession();
$session = Controller::curr()->getRequest()->getSession();
$session->clear('TestSessionId');
// Clear out all PHP session states which have been set previously
if ($sessionStates = $session->get('_TestSessionController.BrowserSessionState')) {
foreach ($sessionStates as $k => $v) {
$session->clear($k);
}
$session->clear('_TestSessionController');
}
return $this->renderWith('TestSession_end');
}
/**
* @return boolean
*/
public function isTesting()
{
$tempDB = new TempDatabase();
return $tempDB->isUsed();
}
/**
* @return ArrayList
*/
public function getState()
{
$stateObj = $this->environment->getState();
$state = array();
// Convert the stdObject of state into ArrayData
foreach ($stateObj as $k => $v) {
$state[] = new ArrayData(array(
'Name' => $k,
'Value' => var_export($v, true)
));
}
return new ArrayList($state);
}
/**
* Get all *.sql database files located in a specific path,
* keyed by their file name.
*
* @param String $path Absolute folder path
* @return array
*/
protected function getDatabaseTemplates($path = null)
{
$templates = array();
if (!$path) {
$path = $this->config()->get('database_templates_path');
}
// TODO Remove once we can set BASE_PATH through the config layer
if ($path && !Director::is_absolute($path)) {
$path = BASE_PATH . '/' . $path;
}
if ($path && file_exists($path ?? '')) {
$it = new FilesystemIterator($path);
foreach ($it as $fileinfo) {
if ($fileinfo->getExtension() != 'sql') {
continue;
}
$templates[$fileinfo->getRealPath()] = $fileinfo->getFilename();
}
}
return $templates;
}
/**
* @param $params array The form fields as passed through from ->start() or ->set()
* @return array The form fields, after fixing the datetime field if necessary
*/
private function fixDatetimeFormField($params)
{
if (isset($params['datetime']) && is_array($params['datetime']) && !empty($params['datetime']['date'])) {
// Convert DatetimeField format from array into string
$datetime = $params['datetime']['date'];
$datetime .= ' ';
$datetime .= (@$params['datetime']['time']) ? $params['datetime']['time'] : '00:00:00';
$params['datetime'] = $datetime;
} elseif (isset($params['datetime']) && empty($params['datetime']['date'])) {
unset($params['datetime']); // No datetime, so remove the param entirely
}
return $params;
}
}

View File

@ -0,0 +1,613 @@
<?php
namespace SilverStripe\TestSession;
use DirectoryIterator;
use Exception;
use InvalidArgumentException;
use LogicException;
use SilverStripe\Assets\Filesystem;
use SilverStripe\Core\Environment;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\FixtureFactory;
use SilverStripe\Dev\YamlFixture;
use SilverStripe\ORM\Connect\TempDatabase;
use SilverStripe\ORM\DatabaseAdmin;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\Versioned\Versioned;
use stdClass;
/**
* Responsible for starting and finalizing test sessions.
* Since these session span across multiple requests, session information is persisted
* in a file. This file is stored in the webroot by default, and the test session
* is considered "in progress" as long as this file exists.
*
* This allows for cross-request, cross-client sharing of the same testsession,
* for example: Behat CLI starts a testsession, then opens a web browser which
* makes a separate request picking up the same testsession.
*
* An environment can have an optional identifier ({@link id}), which allows
* multiple environments to exist at the same time in the same webroot.
* This enables parallel testing with (mostly) isolated state.
*
* For a valid test session to exist, this needs to contain at least:
* - database: The alternate database name that is being used for this test session (e.g. ss_tmpdb_1234567)
* It can optionally contain other details that should be passed through many separate requests:
* - datetime: Mocked SS_DateTime ({@see TestSessionRequestFilter})
* - mailer: Mocked Email sender ({@see TestSessionRequestFilter})
* - stubfile: Path to PHP stub file for setup ({@see TestSessionRequestFilter})
* Extensions of TestSessionEnvironment can add extra fields in here to be saved and restored on each request.
*
* See {@link $state} for default information stored in the test session.
*/
class TestSessionEnvironment
{
use Injectable;
use Configurable;
use Extensible;
/**
* @var int Optional identifier for the session.
*/
protected $id;
/**
* @var string The original database name, before we overrode it with our tmpdb.
*
* Used in {@link self::resetDatabaseName()} when we want to restore the normal DB connection.
*/
private $oldDatabaseName;
/**
* @config
* @var string Path (from web-root) to the test state file that indicates a testsession is in progress.
* Defaults to value stored in testsession/_config/_config.yml
*/
private static $test_state_file = 'TESTS_RUNNING.json';
/**
* @config
* @var [type]
*/
private static $test_state_id_file = 'TESTS_RUNNING-%s.json';
public function __construct($id = null)
{
if ($id) {
$this->id = $id;
}
}
public function init(HTTPRequest $request)
{
if (!$this->id) {
$request->getSession()->init($request);
// $_SESSION != Session::get() in some execution paths, suspect Controller->pushCurrent()
// as part of the issue, easiest resolution is to use session directly for now
$this->id = $request->getSession()->get('TestSessionId');
}
}
/**
* @return string Absolute path to the file persisting our state.
*/
public function getFilePath()
{
if ($this->id) {
$path = Director::getAbsFile(sprintf($this->config()->get('test_state_id_file') ?? '', $this->id));
} else {
$path = Director::getAbsFile($this->config()->get('test_state_file'));
}
return $path;
}
/**
* Tests for the existence of the file specified by $this->test_state_file
*/
public function isRunningTests()
{
return (file_exists($this->getFilePath() ?? ''));
}
/**
* @param String $id
*/
public function setId($id)
{
$this->id = $id;
}
/**
* @return String
*/
public function getId()
{
return $this->id;
}
/**
* Creates a temp database, sets up any extra requirements, and writes the state file. The database will be
* connected to as part of {@link self::applyState()}, so if you're continuing script execution after calling this
* method, be aware that the database will be different - so various things may break (e.g. administrator logins
* using the SS_DEFAULT_USERNAME / SS_DEFAULT_PASSWORD constants).
*
* If something isn't explicitly handled here, and needs special handling, then it should be taken care of by an
* extension to TestSessionEnvironment. You can either extend onBeforeStartTestSession() or
* onAfterStartTestSession(). Alternatively, for more fine-grained control, you can also extend
* onBeforeApplyState() and onAfterApplyState(). See the {@link self::applyState()} method for more.
*
* @param array $state An array of test state options to write.
* @param mixed $id
*/
public function startTestSession($state = null, $id = null)
{
if (!$state) {
$state = array();
}
$this->removeStateFile();
$this->id = $id;
// Assumes state will be modified by reference
$this->extend('onBeforeStartTestSession', $state);
// Convert to JSON and back so we can share the applyState() code between this and ->loadFromFile()
$json = json_encode($state, JSON_FORCE_OBJECT);
$state = json_decode($json ?? '');
$this->applyState($state);
// Back up /assets folder
$this->backupAssets();
$this->extend('onAfterStartTestSession');
}
public function updateTestSession($state)
{
$this->extend('onBeforeUpdateTestSession', $state);
// Convert to JSON and back so we can share the appleState() code between this and ->loadFromFile()
$json = json_encode($state, JSON_FORCE_OBJECT);
$state = json_decode($json ?? '');
$this->applyState($state);
$this->extend('onAfterUpdateTestSession');
}
/**
* Backup all assets from /assets to /assets_backup.
* Note: Only does file move, no files ever duplicated / deleted
*/
protected function backupAssets()
{
// Ensure files backed up to assets dir
$backupFolder = $this->getAssetsBackupfolder();
if (!is_dir($backupFolder ?? '')) {
Filesystem::makeFolder($backupFolder);
}
$this->moveRecursive(ASSETS_PATH, $backupFolder, ['.htaccess', 'web.config', '.protected']);
}
/**
* Restore all assets to /assets folder.
* Note: Only does file move, no files ever duplicated / deleted
*/
public function restoreAssets()
{
// Ensure files backed up to assets dir
$backupFolder = $this->getAssetsBackupfolder();
if (is_dir($backupFolder ?? '')) {
// Move all files
Filesystem::makeFolder(ASSETS_PATH);
$this->moveRecursive($backupFolder, ASSETS_PATH);
Filesystem::removeFolder($backupFolder);
}
}
/**
* Recursively move files from one directory to another
*
* @param string $src Source of files being moved
* @param string $dest Destination of files being moved
* @param array $ignore List of files to not move
*/
protected function moveRecursive($src, $dest, $ignore = [])
{
// If source is not a directory stop processing
if (!is_dir($src ?? '')) {
return;
}
// If the destination directory does not exist create it
if (!is_dir($dest ?? '') && !mkdir($dest ?? '')) {
// If the destination directory could not be created stop processing
return;
}
// Open the source directory to read in files
$iterator = new DirectoryIterator($src);
foreach ($iterator as $file) {
if ($file->isFile()) {
if (!in_array($file->getFilename(), $ignore ?? [])) {
rename($file->getRealPath() ?? '', $dest . DIRECTORY_SEPARATOR . $file->getFilename());
}
} elseif (!$file->isDot() && $file->isDir()) {
// If a dir is ignored, still move children but don't remove self
$this->moveRecursive($file->getRealPath(), $dest . DIRECTORY_SEPARATOR . $file);
if (!in_array($file->getFilename(), $ignore ?? [])) {
Filesystem::removeFolder($file->getRealPath());
}
}
}
}
/**
* Assumes the database has already been created in startTestSession(), as this method can be called from
* _config.php where we don't yet have a DB connection.
*
* Persists the state to the filesystem.
*
* You can extend this by creating an Extension object and implementing either onBeforeApplyState() or
* onAfterApplyState() to add your own test state handling in.
*
* @param mixed $state
* @throws LogicException
* @throws InvalidArgumentException
*/
public function applyState($state)
{
$this->extend('onBeforeApplyState', $state);
// back up source
$databaseConfig = DB::getConfig();
$this->oldDatabaseName = $databaseConfig['database'];
// Load existing state from $this->state into $state, if there is any
$oldState = $this->getState();
if ($oldState) {
foreach ($oldState as $k => $v) {
if (!isset($state->$k)) {
$state->$k = $v; // Don't overwrite stuff in $state, as that's the new state
}
}
}
// ensure we have a connection to the database
$this->connectToDatabase($state);
// Database
if (!$this->isRunningTests()) {
$dbName = (isset($state->database)) ? $state->database : null;
if ($dbName) {
$dbExists = DB::get_conn()->databaseExists($dbName);
} else {
$dbExists = false;
}
if (!$dbExists) {
// Create a new one with a randomized name
$tempDB = new TempDatabase();
$dbName = $tempDB->build();
$state->database = $dbName; // In case it's changed by the call to SapphireTest::create_temp_db();
// Set existing one, assumes it already has been created
$prefix = Environment::getEnv('SS_DATABASE_PREFIX') ?: 'ss_';
$pattern = strtolower(sprintf('#^%stmpdb.*#', preg_quote($prefix ?? '', '#')));
if (!preg_match($pattern ?? '', $dbName ?? '')) {
throw new InvalidArgumentException("Invalid database name format");
}
$this->oldDatabaseName = $databaseConfig['database'];
$databaseConfig['database'] = $dbName; // Instead of calling DB::set_alternative_db_name();
// Connect to the new database, overwriting the old DB connection (if any)
DB::connect($databaseConfig);
}
TestSessionState::create()->write(); // initialize the session state
}
// Mailer
$mailer = (isset($state->mailer)) ? $state->mailer : null;
if ($mailer) {
if (!class_exists($mailer ?? '') || !is_subclass_of($mailer, 'SilverStripe\\Control\\Email\\Mailer')) {
throw new InvalidArgumentException(sprintf(
'Class "%s" is not a valid class, or subclass of Mailer',
$mailer
));
}
}
// Date and time
if (isset($state->datetime)) {
$formatter = DBDatetime::singleton()->getFormatter();
$formatter->setPattern(DBDatetime::ISO_DATETIME);
// Convert DatetimeField format
if ($formatter->parse($state->datetime) === false) {
throw new LogicException(sprintf(
'Invalid date format "%s", use yyyy-MM-dd HH:mm:ss',
$state->datetime
));
}
}
$this->saveState($state);
$this->extend('onAfterApplyState');
}
/**
* Import the database
*
* @param String $path Absolute path to a SQL dump (include DROP TABLE commands)
* @param bool $requireDefaultRecords
*/
public function importDatabase($path, $requireDefaultRecords = false)
{
$sql = file_get_contents($path ?? '');
// Split into individual query commands, removing comments
$sqlCmds = array_filter(preg_split(
'/;\n/',
preg_replace(array('/^$\n/m', '/^(\/|#).*$\n/m'), '', $sql ?? '') ?? ''
) ?? []);
// Execute each query
foreach ($sqlCmds as $sqlCmd) {
DB::query($sqlCmd);
}
// In case the dump involved CREATE TABLE commands, we need to ensure the schema is still up to date
$dbAdmin = new DatabaseAdmin();
Versioned::set_reading_mode('');
$dbAdmin->doBuild(true, $requireDefaultRecords);
}
/**
* Build the database with default records, see {@link DataObject->requireDefaultRecords()}.
*/
public function requireDefaultRecords()
{
$dbAdmin = new DatabaseAdmin();
Versioned::set_reading_mode('');
$dbAdmin->doBuild(true, true);
}
/**
* Sliented as if the file already exists by another process, we don't want
* to modify.
*
* @param mixed $state
*/
public function saveState($state)
{
if (defined('JSON_PRETTY_PRINT')) {
$content = json_encode($state, JSON_PRETTY_PRINT);
} else {
$content = json_encode($state);
}
$old = umask(0);
file_put_contents($this->getFilePath() ?? '', $content, LOCK_EX);
umask($old);
}
public function loadFromFile()
{
if ($this->isRunningTests()) {
try {
$contents = file_get_contents($this->getFilePath() ?? '');
$json = json_decode($contents ?? '');
$this->applyState($json);
} catch (Exception $e) {
throw new Exception(
"A test session appears to be in progress, but we can't retrieve the details.\n"
. "Try removing the " . $this->getFilePath() . " file.\n"
. "Inner error: " . $e->getMessage() . "\n"
. "Stacktrace: " . $e->getTraceAsString()
);
}
}
}
private function removeStateFile()
{
$file = $this->getFilePath();
if (file_exists($file ?? '')) {
if (!unlink($file ?? '')) {
throw new \Exception('Unable to remove the testsession state file, please remove it manually. File '
. 'path: ' . $file);
}
}
}
/**
* Cleans up the test session state by restoring the normal database connect (for the rest of this request, if any)
* and removes the {@link self::$test_state_file} so that future requests don't use this test state.
*
* Can be extended by implementing either onBeforeEndTestSession() or onAfterEndTestSession().
*
* This should implement itself cleanly in case it is called twice (e.g. don't throw errors when the state file
* doesn't exist anymore because it's already been cleaned up etc.) This is because during behat test runs where
* a queueing system (e.g. silverstripe-resque) is used, the behat module may call this first, and then the forked
* worker will call it as well - but there is only one state file that is created.
*/
public function endTestSession()
{
$this->extend('onBeforeEndTestSession');
// Restore assets
$this->restoreAssets();
// Reset DB
$tempDB = new TempDatabase();
if ($tempDB->isUsed()) {
$state = $this->getState();
$dbConn = DB::get_schema();
$dbExists = $dbConn->databaseExists($state->database);
if ($dbExists) {
// Clean up temp database
$dbConn->dropDatabase($state->database);
file_put_contents('php://stdout', "Deleted temp database: $state->database" . PHP_EOL);
}
// End test session mode
$this->resetDatabaseName();
}
$this->removeStateFile();
$this->extend('onAfterEndTestSession');
}
/**
* Loads a YAML fixture into the database as part of the {@link TestSessionController}.
*
* @param string $fixtureFile The .yml file to load
* @return FixtureFactory The loaded fixture
* @throws LogicException
*/
public function loadFixtureIntoDb($fixtureFile)
{
$realFile = realpath(BASE_PATH . '/' . $fixtureFile);
$baseDir = realpath(Director::baseFolder() ?? '');
if (!$realFile || !file_exists($realFile ?? '')) {
throw new LogicException("Fixture file doesn't exist");
} elseif (substr($realFile ?? '', 0, strlen($baseDir ?? '')) != $baseDir) {
throw new LogicException("Fixture file must be inside $baseDir");
} elseif (substr($realFile ?? '', -4) != '.yml') {
throw new LogicException("Fixture file must be a .yml file");
} elseif (!preg_match('/^([^\/.][^\/]+)\/tests\//', $fixtureFile ?? '')) {
throw new LogicException("Fixture file must be inside the tests subfolder of one of your modules.");
}
$factory = Injector::inst()->create(FixtureFactory::class);
$fixture = Injector::inst()->create(YamlFixture::class, $fixtureFile);
$fixture->writeInto($factory);
$state = $this->getState();
$state->fixtures[] = $fixtureFile;
$this->applyState($state);
return $fixture;
}
/**
* Reset the database connection to use the original database. Called by {@link self::endTestSession()}.
*/
public function resetDatabaseName()
{
if ($this->oldDatabaseName) {
$databaseConfig = DB::getConfig();
$databaseConfig['database'] = $this->oldDatabaseName;
DB::setConfig($databaseConfig);
$conn = DB::get_conn();
if ($conn) {
$conn->selectDatabase($this->oldDatabaseName, false, false);
}
}
}
/**
* @return stdClass Data as taken from the JSON object in {@link self::loadFromFile()}
*/
public function getState()
{
$path = Director::getAbsFile($this->getFilePath());
if (file_exists($path ?? '')) {
return json_decode(file_get_contents($path)) ?: new stdClass;
}
return new stdClass;
}
/**
* Path where assets should be backed up during testing
*
* @return string
*/
protected function getAssetsBackupfolder()
{
return PUBLIC_PATH . DIRECTORY_SEPARATOR . 'assets_backup';
}
/**
* Ensure that there is a connection to the database
*
* @param mixed $state
*/
public function connectToDatabase($state = null)
{
if ($state == null) {
$state = $this->getState();
}
$databaseConfig = DB::getConfig();
if (isset($state->database) && $state->database) {
if (!DB::get_conn()) {
// No connection, so try and connect to tmpdb if it exists
if (isset($state->database)) {
$this->oldDatabaseName = $databaseConfig['database'];
$databaseConfig['database'] = $state->database;
}
// Connect to database
DB::connect($databaseConfig);
} else {
// We've already connected to the database, do a fast check to see what database we're currently using
$db = DB::get_conn()->getSelectedDatabase();
if (isset($state->database) && $db != $state->database) {
$this->oldDatabaseName = $databaseConfig['database'];
$databaseConfig['database'] = $state->database;
DB::connect($databaseConfig);
}
}
}
}
/**
* Wait for pending requests
*
* @param int $await Time to wait (in ms) after the last response (to allow the browser react)
* @param int $timeout For how long (in ms) do we wait before giving up
*
* @return bool Whether there are no more pending requests
*/
public function waitForPendingRequests($await = 700, $timeout = 10000)
{
$timeout = TestSessionState::millitime() + $timeout;
$interval = max(300, $await);
do {
$now = TestSessionState::millitime();
if ($timeout < $now) {
return false;
}
$model = TestSessionState::get()->byID(1);
$pendingRequests = $model->PendingRequests > 0;
$lastRequestAwait = ($model->LastResponseTimestamp + $await) > $now;
$pending = $pendingRequests || $lastRequestAwait;
} while ($pending && (usleep($interval * 1000) || true));
return true;
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace SilverStripe\TestSession;
use SilverStripe\Control\Director;
use SilverStripe\Control\Email\Email;
use SilverStripe\Control\Email\Mailer;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Middleware\HTTPMiddleware;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\FieldType\DBDatetime;
/**
* Sets state previously initialized through {@link TestSessionController}.
*/
class TestSessionHTTPMiddleware implements HTTPMiddleware
{
/**
* @var TestSessionEnvironment
*/
protected $testSessionEnvironment;
public function __construct()
{
$this->testSessionEnvironment = TestSessionEnvironment::singleton();
}
public function process(HTTPRequest $request, callable $delegate)
{
// Init environment
$this->testSessionEnvironment->init($request);
// If not running tests, just pass through
$isRunningTests = $this->testSessionEnvironment->isRunningTests();
if (!$isRunningTests) {
return $delegate($request);
}
// Load test state
$this->loadTestState($request);
TestSessionState::incrementState();
// Call with safe teardown
try {
return $delegate($request);
} finally {
$this->restoreTestState($request);
TestSessionState::decrementState();
}
}
/**
* Load test state from environment into "real" environment
*
* @param HTTPRequest $request
*/
protected function loadTestState(HTTPRequest $request)
{
$testState = $this->testSessionEnvironment->getState();
// Date and time
if (isset($testState->datetime)) {
DBDatetime::set_mock_now($testState->datetime);
}
// Register mailer
if (isset($testState->mailer)) {
$mailer = $testState->mailer;
Injector::inst()->registerService(new $mailer(), Mailer::class);
Email::config()->set("send_all_emails_to", null);
Email::config()->set('admin_email', 'no-reply@example.com');
}
// Connect to the test session database
$this->testSessionEnvironment->connectToDatabase();
// Allows inclusion of a PHP file, usually with procedural commands
// to set up required test state. The file can be generated
// through {@link TestSessionStubCodeWriter}, and the session state
// set through {@link TestSessionController->set()} and the
// 'testsession.stubfile' state parameter.
if (isset($testState->stubfile)) {
$file = $testState->stubfile;
if (!Director::isLive() && $file && file_exists($file ?? '')) {
include_once($file);
}
}
}
protected function restoreTestState(HTTPRequest $request)
{
// Store PHP session
$state = $this->testSessionEnvironment->getState();
$state->session = $request->getSession()->getAll();
$this->testSessionEnvironment->applyState($state);
}
}

70
src/TestSessionState.php Normal file
View File

@ -0,0 +1,70 @@
<?php
namespace SilverStripe\TestSession;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\Queries\SQLUpdate;
/**
* The session state keeps some metadata about the current test session.
* This may allow the client (Behat) to get some insight into the
* server side affairs (e.g. if the server is handling some number requests at the moment).
*
* The client side (Behat) must not use this class straightforwardly, but rather
* rely on the API of {@see TestSessionEnvironment} or {@see TestSessionController}.
*
* @property int PendingRequests keeps information about how many requests are in progress
* @property float LastResponseTimestamp microtime of the last response made by the server
*/
class TestSessionState extends DataObject
{
private static $table_name = 'TestSessionState';
private static $db = [
'PendingRequests' => 'Int',
'LastResponseTimestamp' => 'Decimal(14, 0)'
];
/**
* Increments TestSessionState.PendingRequests number by 1
* to indicate we have one more request in progress
*/
public static function incrementState()
{
$schema = DataObject::getSchema();
$update = SQLUpdate::create(sprintf('"%s"', $schema->tableName(self::class)))
->addWhere(['ID' => 1])
->assignSQL('"PendingRequests"', '"PendingRequests" + 1');
$update->execute();
}
/**
* Decrements TestSessionState.PendingRequests number by 1
* to indicate we have one more request in progress.
* Also updates TestSessionState.LastResponseTimestamp
* to the current timestamp.
*/
public static function decrementState()
{
$schema = DataObject::getSchema();
$update = SQLUpdate::create(sprintf('"%s"', $schema->tableName(self::class)))
->addWhere(['ID' => 1])
->assignSQL('"PendingRequests"', '"PendingRequests" - 1')
->assign('"LastResponseTimestamp"', self::millitime());
$update->execute();
}
/**
* Returns unix timestamp in milliseconds
*
* @return float milliseconds since 1970
*/
public static function millitime()
{
return round(microtime(true) * 1000);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace SilverStripe\TestSession;
/**
* Writes PHP to a file which can be included in SilverStripe runs on existence.
* The generated file is included in page execution through {@link TestSessionRequestFilter}.
*/
class TestSessionStubCodeWriter
{
/**
* @var boolean Add debug statements to the generated PHP about
* the generator's origin code location.
*/
protected $debug = false;
/**
* @var String Absolute path to a PHP file, essentially the "name" of the stub.
*/
protected $filePath;
public function __construct($filePath = null)
{
$this->filePath = $filePath ? $filePath : BASE_PATH . '/testSessionStubCode.php';
}
/**
* Writes arbitrary PHP code to {@link $filePath} for later inclusion.
* Creates the file if it doesn't exist.
* Adds debug information about the origin of this code if {@link $debug} is set.
*
* @param String $php Block of PHP code (without preceding <?php)
* @param boolean $eval Sanity check on code.
*/
public function write($php, $eval = true)
{
$trace = $this->debug ? debug_backtrace() : null;
$path = $this->getFilePath();
$header = '';
// Create file incl. header if it doesn't exist
if (!file_exists($this->getFilePath() ?? '')) {
touch($this->getFilePath() ?? '');
if ($this->debug) {
$header .= "<?php\n// Generated by " . $trace[1]['class'] . " on " . date('Y-m-d H:i:s') . "\n\n";
} else {
$header .= "<?php\n";
}
}
// Add content
if ($this->debug) {
$header .= "// Added by " . $trace[1]['class'] . '::' . $trace[1]['function'] . "\n";
}
file_put_contents($path ?? '', $header . $php . "\n", FILE_APPEND);
}
public function reset()
{
if (file_exists($this->getFilePath() ?? '')) {
unlink($this->getFilePath() ?? '');
}
}
public function getFilePath()
{
return $this->filePath;
}
public function getDebug()
{
return $this->debug;
}
public function setDebug($debug)
{
$this->debug = $debug;
return $this;
}
}

View File

@ -1,8 +1,8 @@
<% if State %> <% if $State %>
<p> <p>
<a href="#" onclick="document.getElementById('state').style.display = 'block'; return false;">Show testing state</a> <a href="#" onclick="document.getElementById('state').style.display = 'block'; return false;">Show testing state</a>
<ul id="state" style="display: none;"> <ul id="state" style="display: none;">
<% loop State %> <% loop $State %>
<li><strong>$Name:</strong> $Value</li> <li><strong>$Name:</strong> $Value</li>
<% end_loop %> <% end_loop %>
</ul> </ul>

View File

@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<% base_tag %> <% base_tag %>
$MetaTags $MetaTags
<% require css('framework/css/debug.css') %> <% require css('silverstripe/framework:client/styles/debug.css') %>
<% require css('testsession/css/styles.css') %> <% require css('silverstripe/testsession:client/styles/styles.css') %>
</head> </head>
<body> <body>
<div class="info"> <div class="info">

View File

@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<% base_tag %> <% base_tag %>
$MetaTags $MetaTags
<% require css('framework/css/debug.css') %> <% require css('silverstripe/framework:client/styles/debug.css') %>
<% require css('testsession/css/styles.css') %> <% require css('silverstripe/testsession:client/styles/styles.css') %>
</head> </head>
<body> <body>
<div class="info"> <div class="info">

View File

@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<% base_tag %> <% base_tag %>
$MetaTags $MetaTags
<% require css('framework/css/debug.css') %> <% require css('silverstripe/framework:client/styles/debug.css') %>
<% require css('testsession/css/styles.css') %> <% require css('silverstripe/testsession:client/styles/styles.css') %>
</head> </head>
<body> <body>
<div class="info"> <div class="info">

View File

@ -1,43 +0,0 @@
<?php
class TestSessionStubCodeWriterTest extends SapphireTest {
public function tearDown() {
parent::tearDown();
$file = TEMP_FOLDER . '/TestSessionStubCodeWriterTest-file.php';
if(file_exists($file)) unlink($file);
}
public function testWritesHeaderOnNewFile() {
$file = TEMP_FOLDER . '/TestSessionStubCodeWriterTest-file.php';
$writer = new TestSessionStubCodeWriter($file);
$writer->write('foo();', false);
$this->assertFileExists($file);
$this->assertEquals(
file_get_contents($writer->getFilePath()),
"<?php\nfoo();\n"
);
}
public function testWritesWithAppendOnExistingFile() {
$file = TEMP_FOLDER . '/TestSessionStubCodeWriterTest-file.php';
$writer = new TestSessionStubCodeWriter($file);
$writer->write('foo();', false);
$writer->write('bar();', false);
$this->assertFileExists($file);
$this->assertEquals(
file_get_contents($writer->getFilePath()),
"<?php\nfoo();\nbar();\n"
);
}
public function testReset() {
$file = TEMP_FOLDER . '/TestSessionStubCodeWriterTest-file.php';
$writer = new TestSessionStubCodeWriter($file);
$writer->write('foo();', false);
$this->assertFileExists($file);
$writer->reset();
$this->assertFileNotExists($file);
}
}