Merge test

This commit is contained in:
An Phan 2016-11-17 16:45:51 +08:00
commit 4ca08c903d
No known key found for this signature in database
GPG key ID: 05536BB4BCDC02A2
36 changed files with 1504 additions and 48 deletions

2
.gitignore vendored
View file

@ -66,3 +66,5 @@ Temporary Items
### Sass ###
.sass-cache/
*.css.map
/database/e2e.sqlite

View file

@ -5,6 +5,7 @@ namespace App\Http;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\GetUserFromToken;
use App\Http\Middleware\ObjectStorageAuthenticate;
use App\Http\Middleware\UseDifferentConfigIfE2E;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
@ -20,6 +21,7 @@ class Kernel extends HttpKernel
*/
protected $middleware = [
CheckForMaintenanceMode::class,
UseDifferentConfigIfE2E::class,
];
/**

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
/**
* Check if the app is running in an E2E session and use the proper data settings.
*/
class UseDifferentConfigIfE2E
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if ($_SERVER['SERVER_PORT'] === '8081') {
config(['database.default' => 'sqlite-e2e']);
}
return $next($request);
}
}

View file

@ -11,7 +11,8 @@
"barryvdh/laravel-ide-helper": "^2.1",
"guzzlehttp/guzzle": "^6.1",
"tymon/jwt-auth": "^0.5.6",
"aws/aws-sdk-php-laravel": "^3.1"
"aws/aws-sdk-php-laravel": "^3.1",
"facebook/webdriver": "^1.2"
},
"require-dev": {
"fzaninotto/faker": "~1.4",
@ -31,7 +32,8 @@
},
"autoload-dev": {
"classmap": [
"tests/TestCase.php"
"tests/TestCase.php",
"tests/E2E"
]
},
"scripts": {

51
composer.lock generated
View file

@ -4,8 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "34d0eb369179b7e8718962fb530f83f0",
"content-hash": "9c68d683b99bec01c5a9f55577399ae8",
"hash": "9216fb1e8ef42422a945b1ebc83e902a",
"content-hash": "9d176a92a6e8bf22d69a770843e8e013",
"packages": [
{
"name": "aws/aws-sdk-php",
@ -412,6 +412,53 @@
],
"time": "2015-11-06 14:35:42"
},
{
"name": "facebook/webdriver",
"version": "1.2.0",
"source": {
"type": "git",
"url": "https://github.com/facebook/php-webdriver.git",
"reference": "af21de3ae5306a8ca0bcc02a19735dadc43e83f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/facebook/php-webdriver/zipball/af21de3ae5306a8ca0bcc02a19735dadc43e83f3",
"reference": "af21de3ae5306a8ca0bcc02a19735dadc43e83f3",
"shasum": ""
},
"require": {
"ext-curl": "*",
"php": "^5.5 || ~7.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^1.11",
"php-mock/php-mock-phpunit": "^1.1",
"phpunit/phpunit": "4.6.* || ~5.0",
"squizlabs/php_codesniffer": "^2.6"
},
"suggest": {
"phpdocumentor/phpdocumentor": "2.*"
},
"type": "library",
"autoload": {
"psr-4": {
"Facebook\\WebDriver\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "A PHP client for WebDriver",
"homepage": "https://github.com/facebook/php-webdriver",
"keywords": [
"facebook",
"php",
"selenium",
"webdriver"
],
"time": "2016-10-14 15:16:51"
},
{
"name": "guzzlehttp/guzzle",
"version": "6.2.1",

View file

@ -52,6 +52,12 @@ return [
'prefix' => '',
],
'sqlite-e2e' => [
'driver' => 'sqlite',
'database' => __DIR__.'/../database/e2e.sqlite',
'prefix' => '',
],
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', 'localhost'),

View file

@ -26,16 +26,16 @@ $factory->define(App\Models\Artist::class, function ($faker) {
$factory->define(App\Models\Album::class, function ($faker) {
return [
'name' => $faker->sentence,
'name' => ucwords($faker->words(random_int(2, 5), true)),
'cover' => md5(uniqid()).'.jpg',
];
});
$factory->define(App\Models\Song::class, function ($faker) {
return [
'title' => $faker->sentence,
'title' => ucwords($faker->words(random_int(2, 5), true)),
'length' => $faker->randomFloat(2, 10, 500),
'track' => $faker->randomNumber(),
'track' => random_int(1, 20),
'lyrics' => $faker->paragraph(),
'path' => '/tmp/'.uniqid().'.mp3',
'mtime' => time(),

View file

@ -0,0 +1,25 @@
<?php
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use Illuminate\Database\Seeder;
class E2EDataSeeder extends Seeder
{
private $sampleSongFullPath;
public function run()
{
$this->sampleSongFullPath = realpath(__DIR__.'/../../tests/songs/full.mp3');
factory(Artist::class, 20)->create()->each(function(Artist $artist) {
factory(Album::class, 5)->create([
'artist_id' => $artist->id,
])->each(function (Album $album) {
factory(Song::class, 10)->create([
'album_id' => $album->id,
]);
});
});
}
}

17
index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>Koel</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="public/css/vendors.css">
<link rel="stylesheet" href="public/css/app.css">
</head>
<body>
<div id="app"></div>
<script src="public/js/vendors.js"></script>
<script src="public/js/main.js"></script>
</body>
</html>

View file

@ -13,7 +13,7 @@
"type": "git",
"url": "https://github.com/phanan/koel"
},
"devDependencies": {
"dependencies": {
"babel-plugin-lodash": "^2.2.1",
"babel-plugin-transform-runtime": "^6.3.13",
"babel-polyfill": "^6.9.1",
@ -22,8 +22,6 @@
"babel-register": "^6.3.13",
"babel-runtime": "^6.0.0",
"blueimp-md5": "^2.3.0",
"browserify-hmr": "^0.3.1",
"chai": "^3.4.1",
"cross-env": "^1.0.7",
"font-awesome": "^4.5.0",
"gulp": "^3.9.0",
@ -34,7 +32,6 @@
"laravel-elixir": "^5.0.0",
"local-storage": "^1.4.2",
"lodash": "^4.6.1",
"mocha": "^2.3.4",
"node-sass": "^3.4.2",
"nprogress": "^0.2.0",
"plyr": "1.5.x",
@ -49,8 +46,23 @@
"vueify-insert-css": "^1.0.0",
"youtube-player": "^3.0.4"
},
"devDependencies": {
"body-parser": "^1.15.2",
"browserify-hmr": "^0.3.1",
"chai": "^3.4.1",
"chalk": "^1.1.3",
"express": "^4.14.0",
"faker": "^3.1.0",
"http-server": "^0.9.0",
"jsdom": "^9.2.1",
"json-server": "^0.8.14",
"mocha": "^2.3.4",
"nightmare": "^2.5.2",
"sinon": "^1.17.2"
},
"scripts": {
"postinstall": "cross-env NODE_ENV=production && gulp --production",
"test": "mocha --compilers js:babel-register --require resources/assets/js/tests/helper.js resources/assets/js/tests/**/*Test.js"
"test": "mocha --compilers js:babel-register --require resources/assets/js/tests/helper.js resources/assets/js/tests/**/*Test.js",
"e2e": "echo 'Remember to run `php artisan serve --port=8081`' && phpunit tests/e2e -c phpunit.e2e.xml"
}
}

37
phpunit.e2e.xml Normal file
View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="bootstrap/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="E2E Test Suite">
<directory>./tests/e2e</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">app/</directory>
</whitelist>
</filter>
<php>
<env name="APP_ENV" value="e2e"/>
<env name="APP_KEY" value="16efa6c23c2e8c705826b0e66778fbe7"/>
<env name="JWT_SECRET" value="ki8jSvMf5wFrlSRBAWcGbmAzBUJfc8p8"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="sqlite-e2e"/>
<env name="LASTFM_API_KEY" value="foo"/>
<env name="LASTFM_API_SECRET" value="bar"/>
<env name="YOUTUBE_API_KEY" value="foo"/>
<env name="ADMIN_EMAIL" value="koel@example.com"/>
<env name="ADMIN_NAME" value="Koel Admin"/>
<env name="ADMIN_PASSWORD" value="SoSecureK0el"/>
<env name="BROADCAST_DRIVER" value="log"/>
</php>
</phpunit>

View file

@ -11,6 +11,7 @@
<testsuites>
<testsuite name="Application Test Suite">
<directory>./tests/</directory>
<exclude>./tests/e2e</exclude>
</testsuite>
</testsuites>
<filter>

12
resources/assets/js/.idea/js.iml generated Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="ECMAScript 6" />
</component>
</project>

16
resources/assets/js/.idea/misc.xml generated Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectLevelVcsManager" settingsEditedManually="false">
<OptionsSetting value="true" id="Add" />
<OptionsSetting value="true" id="Remove" />
<OptionsSetting value="true" id="Checkout" />
<OptionsSetting value="true" id="Update" />
<OptionsSetting value="true" id="Status" />
<OptionsSetting value="true" id="Edit" />
<ConfirmationsSetting value="0" id="Add" />
<ConfirmationsSetting value="0" id="Remove" />
</component>
</project>

8
resources/assets/js/.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/js.iml" filepath="$PROJECT_DIR$/.idea/js.iml" />
</modules>
</component>
</project>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="Babel" />
</project>

338
resources/assets/js/.idea/workspace.xml generated Normal file
View file

@ -0,0 +1,338 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ChangeListManager">
<list default="true" id="ab3db53b-a246-4a74-8766-88c1db2358b0" name="Default" comment="" />
<ignored path="js.iws" />
<ignored path=".idea/workspace.xml" />
<ignored path="$PROJECT_DIR$/.tmp/" />
<ignored path="$PROJECT_DIR$/temp/" />
<ignored path="$PROJECT_DIR$/tmp/" />
<option name="EXCLUDED_CONVERTED_TO_IGNORED" value="true" />
<option name="TRACKING_ENABLED" value="true" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="CreatePatchCommitExecutor">
<option name="PATCH_PATH" value="" />
</component>
<component name="ExecutionTargetManager" SELECTED_TARGET="default_target" />
<component name="FavoritesManager">
<favorites_list name="js" />
</component>
<component name="FileEditorManager">
<leaf SIDE_TABS_SIZE_LIMIT_KEY="300">
<file leaf-file-name="login-form.vue" pinned="false" current-in-tab="false">
<entry file="file://$PROJECT_DIR$/components/auth/login-form.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="465">
<caret line="32" column="25" selection-start-line="32" selection-start-column="25" selection-end-line="32" selection-end-column="25" />
<folding />
</state>
</provider>
</entry>
</file>
<file leaf-file-name="index.vue" pinned="false" current-in-tab="false">
<entry file="file://$PROJECT_DIR$/components/main-wrapper/index.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="75">
<caret line="5" column="8" selection-start-line="5" selection-start-column="8" selection-end-line="5" selection-end-column="8" />
<folding />
</state>
</provider>
</entry>
</file>
<file leaf-file-name="edit-songs-form.vue" pinned="false" current-in-tab="false">
<entry file="file://$PROJECT_DIR$/components/modals/edit-songs-form.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="-3720">
<caret line="13" column="15" selection-start-line="13" selection-start-column="15" selection-end-line="13" selection-end-column="15" />
<folding />
</state>
</provider>
</entry>
</file>
<file leaf-file-name="album-item.vue" pinned="false" current-in-tab="true">
<entry file="file://$PROJECT_DIR$/components/shared/album-item.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="451">
<caret line="106" column="43" selection-start-line="106" selection-start-column="43" selection-end-line="106" selection-end-column="43" />
<folding />
</state>
</provider>
</entry>
</file>
<file leaf-file-name="main.js" pinned="false" current-in-tab="false">
<entry file="file://$PROJECT_DIR$/main.js">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="180">
<caret line="15" column="16" selection-start-line="15" selection-start-column="16" selection-end-line="15" selection-end-column="16" />
<folding>
<element signature="e#0#22#0" expanded="true" />
</folding>
</state>
</provider>
</entry>
</file>
</leaf>
</component>
<component name="JsBuildToolGruntFileManager" detection-done="true" sorting="DEFINITION_ORDER" />
<component name="JsBuildToolPackageJson" detection-done="true" sorting="DEFINITION_ORDER" />
<component name="JsGulpfileManager">
<detection-done>true</detection-done>
<sorting>DEFINITION_ORDER</sorting>
</component>
<component name="ProjectFrameBounds">
<option name="x" value="20" />
<option name="y" value="43" />
<option name="width" value="1400" />
<option name="height" value="833" />
</component>
<component name="ProjectLevelVcsManager" settingsEditedManually="false">
<OptionsSetting value="true" id="Add" />
<OptionsSetting value="true" id="Remove" />
<OptionsSetting value="true" id="Checkout" />
<OptionsSetting value="true" id="Update" />
<OptionsSetting value="true" id="Status" />
<OptionsSetting value="true" id="Edit" />
<ConfirmationsSetting value="0" id="Add" />
<ConfirmationsSetting value="0" id="Remove" />
</component>
<component name="ProjectView">
<navigator currentView="ProjectPane" proportions="" version="1">
<flattenPackages />
<showMembers />
<showModules />
<showLibraryContents />
<hideEmptyPackages />
<abbreviatePackageNames />
<autoscrollToSource />
<autoscrollFromSource />
<sortByType />
<manualOrder />
<foldersAlwaysOnTop value="true" />
</navigator>
<panes>
<pane id="Scratches" />
<pane id="Scope" />
<pane id="ProjectPane">
<subPane>
<PATH>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.ProjectViewProjectNode" />
</PATH_ELEMENT>
</PATH>
<PATH>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.ProjectViewProjectNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
</PATH>
<PATH>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.ProjectViewProjectNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="components" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
</PATH>
<PATH>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.ProjectViewProjectNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="components" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="shared" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
</PATH>
<PATH>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.ProjectViewProjectNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="components" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="modals" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
</PATH>
<PATH>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.ProjectViewProjectNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="components" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="main-wrapper" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
</PATH>
<PATH>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.ProjectViewProjectNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="js" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="components" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
<PATH_ELEMENT>
<option name="myItemId" value="auth" />
<option name="myItemType" value="com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode" />
</PATH_ELEMENT>
</PATH>
</subPane>
</pane>
</panes>
</component>
<component name="PropertiesComponent">
<property name="last_opened_file_path" value="$PROJECT_DIR$" />
<property name="WebServerToolWindowFactoryState" value="false" />
<property name="HbShouldOpenHtmlAsHb" value="" />
<property name="nodejs_interpreter_path" value="/usr/local/bin/node" />
</component>
<component name="RunManager">
<configuration default="true" type="NodeJSConfigurationType" factoryName="Node.js" path-to-node="project" working-dir="">
<method />
</configuration>
</component>
<component name="ShelveChangesManager" show_recycled="false">
<option name="remove_strategy" value="false" />
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="ab3db53b-a246-4a74-8766-88c1db2358b0" name="Default" comment="" />
<created>1479096421308</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1479096421308</updated>
<workItem from="1479096423357" duration="76000" />
<workItem from="1479096510774" duration="15000" />
<workItem from="1479096542485" duration="78000" />
</task>
<servers />
</component>
<component name="TimeTrackingManager">
<option name="totallyTimeSpent" value="169000" />
</component>
<component name="ToolWindowManager">
<frame x="20" y="43" width="1400" height="833" extended-state="0" />
<editor active="false" />
<layout>
<window_info id="Project" active="false" anchor="left" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="true" show_stripe_button="true" weight="0.17571428" sideWeight="0.5" order="0" side_tool="false" content_ui="combo" />
<window_info id="TODO" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="6" side_tool="false" content_ui="tabs" />
<window_info id="Event Log" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="7" side_tool="true" content_ui="tabs" />
<window_info id="Version Control" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="7" side_tool="false" content_ui="tabs" />
<window_info id="Structure" active="false" anchor="left" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.25" sideWeight="0.5" order="1" side_tool="false" content_ui="tabs" />
<window_info id="Terminal" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="7" side_tool="false" content_ui="tabs" />
<window_info id="Favorites" active="false" anchor="left" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="2" side_tool="true" content_ui="tabs" />
<window_info id="Cvs" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.25" sideWeight="0.5" order="4" side_tool="false" content_ui="tabs" />
<window_info id="Message" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="0" side_tool="false" content_ui="tabs" />
<window_info id="Commander" active="false" anchor="right" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.4" sideWeight="0.5" order="0" side_tool="false" content_ui="tabs" />
<window_info id="Inspection" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.4" sideWeight="0.5" order="5" side_tool="false" content_ui="tabs" />
<window_info id="Run" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="2" side_tool="false" content_ui="tabs" />
<window_info id="Hierarchy" active="false" anchor="right" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.25" sideWeight="0.5" order="2" side_tool="false" content_ui="combo" />
<window_info id="Find" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.33" sideWeight="0.5" order="1" side_tool="false" content_ui="tabs" />
<window_info id="Ant Build" active="false" anchor="right" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.25" sideWeight="0.5" order="1" side_tool="false" content_ui="tabs" />
<window_info id="Debug" active="false" anchor="bottom" auto_hide="false" internal_type="DOCKED" type="DOCKED" visible="false" show_stripe_button="true" weight="0.4" sideWeight="0.5" order="3" side_tool="false" content_ui="tabs" />
</layout>
</component>
<component name="Vcs.Log.UiProperties">
<option name="RECENTLY_FILTERED_USER_GROUPS">
<collection />
</option>
<option name="RECENTLY_FILTERED_BRANCH_GROUPS">
<collection />
</option>
</component>
<component name="VcsContentAnnotationSettings">
<option name="myLimit" value="2678400000" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager />
<watches-manager />
</component>
<component name="editorHistoryManager">
<entry file="file://$PROJECT_DIR$/components/auth/login-form.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="465">
<caret line="32" column="25" selection-start-line="32" selection-start-column="25" selection-end-line="32" selection-end-column="25" />
<folding />
</state>
</provider>
</entry>
<entry file="file://$PROJECT_DIR$/main.js">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="180">
<caret line="15" column="16" selection-start-line="15" selection-start-column="16" selection-end-line="15" selection-end-column="16" />
<folding>
<element signature="e#0#22#0" expanded="true" />
</folding>
</state>
</provider>
</entry>
<entry file="file://$PROJECT_DIR$/components/main-wrapper/index.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="75">
<caret line="5" column="8" selection-start-line="5" selection-start-column="8" selection-end-line="5" selection-end-column="8" />
<folding />
</state>
</provider>
</entry>
<entry file="file://$PROJECT_DIR$/components/modals/edit-songs-form.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="-3720">
<caret line="13" column="15" selection-start-line="13" selection-start-column="15" selection-end-line="13" selection-end-column="15" />
<folding />
</state>
</provider>
</entry>
<entry file="file://$PROJECT_DIR$/components/shared/album-item.vue">
<provider selected="true" editor-type-id="text-editor">
<state relative-caret-position="451">
<caret line="106" column="43" selection-start-line="106" selection-start-column="43" selection-end-line="106" selection-end-column="43" />
<folding />
</state>
</provider>
</entry>
</component>
</project>

View file

@ -1,5 +1,5 @@
<template>
<div id="youtube-wrapper">
<div id="youtube-extra-wrapper">
<template v-if="videos && videos.length">
<a class="video" v-for="video in videos" href @click.prevent="playYouTube(video.id.videoId)">
<div class="thumb">
@ -58,7 +58,7 @@ export default {
</script>
<style lang="sass" scoped>
#youtube-wrapper {
#youtube-extra-wrapper {
overflow-x: hidden;
.video {

View file

@ -1,5 +1,5 @@
<template>
<section id="youTubePlayer">
<section id="youtubeWrapper">
<h1 class="heading"><span>YouTube Video</span></h1>
<div id="player">
<p class="none">Your YouTube video will be played here.<br/>

View file

@ -28,7 +28,7 @@
<div v-show="currentView === 'details'">
<div class="form-row" v-if="editSingle">
<label>Title</label>
<input type="text" v-model="formData.title">
<input name="title" type="text" v-model="formData.title">
</div>
<div class="form-row">
<label>Artist</label>
@ -55,7 +55,7 @@
</div>
<div class="form-row" v-show="editSingle">
<label>Track</label>
<input type="number" min="0" v-model="formData.track">
<input name="track" type="number" min="0" v-model="formData.track">
</div>
</div>
<div v-show="currentView === 'lyrics' && editSingle">

View file

@ -3,14 +3,14 @@
class="song-item"
draggable="true"
:data-song-id="song.id"
@click="$parent.rowClick(song.id, $event)"
@click="clicked($event)"
@dblclick.prevent="playRightAwayyyyyyy"
@dragstart="$parent.dragStart(song.id, $event)"
@dragleave="$parent.removeDroppableState($event)"
@dragover.prevent="$parent.allowDrop(song.id, $event)"
@drop.stop.prevent="$parent.handleDrop(song.id, $event)"
@contextmenu.prevent="$parent.openContextMenu(song.id, $event)"
:class="{ selected: selected, playing: song.playbackState === 'playing' || song.playbackState === 'paused' }"
:class="{ selected: selected, playing: playing }"
>
<td class="track-number">{{ song.track || '' }}</td>
<td class="title">{{ song.title }}</td>
@ -37,6 +37,12 @@ export default {
};
},
computed: {
playing() {
return this.song.playbackState === 'playing' || this.song.playbackState === 'paused';
},
},
methods: {
/**
* Play the song right away.
@ -66,26 +72,24 @@ export default {
}
},
clicked($e) {
this.$emit('itemClicked', this.song.id, $e);
},
select() {
this.selected = true;
},
deselect() {
this.selected = false;
},
/**
* Toggle the "selected" state of the current component.
*/
toggleSelectedState() {
this.selected = !this.selected;
},
/**
* Select the current component (apply a CSS class on its DOM).
*/
select() {
this.selected = true;
},
/**
* Deselect the current component.
*/
deselect() {
this.selected = false;
},
},
};
</script>

View file

@ -35,7 +35,12 @@
</thead>
<tbody>
<tr is="song-item" v-for="item in displayedItems" :song="item" ref="rows" :key="item.id"/>
<tr is="song-item"
v-for="item in displayedItems"
@itemClicked="itemClicked"
:song="item"
:key="item.id"
ref="rows"/>
</tbody>
</table>
@ -69,7 +74,6 @@ export default {
q: '', // The filter query
sortKey: '',
order: 1,
componentCache: {},
sortingByAlbum: false,
sortingByArtist: false,
selectedSongs: [],
@ -221,12 +225,7 @@ export default {
* @return {Object} The Vue compoenent
*/
getComponentBySongId(id) {
// A Vue component can be removed (as a result of filter for example), so we check for its $el as well.
if (!this.componentCache[id] || !this.componentCache[id].$el) {
this.componentCache[id] = find(this.$refs.rows, { song: { id } });
}
return this.componentCache[id];
return find(this.$refs.rows, { song: { id } });
},
/**
@ -269,7 +268,7 @@ export default {
* @param {String} songId
* @param {Object} e
*/
rowClick(songId, e) {
itemClicked(songId, e) {
const row = this.getComponentBySongId(songId);
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
@ -342,8 +341,11 @@ export default {
this.gatherSelected();
}
console.log('selected songs before drop:', this.selectedSongs);
this.$nextTick(() => {
const songIds = map(this.selectedSongs, 'id');
console.log('dragging', songIds);
e.dataTransfer.setData('application/x-koel.text+plain', songIds);
e.dataTransfer.effectAllowed = 'move';
@ -377,6 +379,7 @@ export default {
* @param {Object} e
*/
handleDrop(songId, e) {
console.log('dropping into', songId);
if (this.type !== 'queue') {
return this.removeDroppableState(e) && false;
}
@ -386,6 +389,7 @@ export default {
}
const songs = this.selectedSongs;
console.log('selected songs after drop:', songs);
if (!songs.length) {
return this.removeDroppableState(e) && false;
@ -458,12 +462,6 @@ export default {
*/
'main-content-view:load': () => this.clearSelection(),
/**
* Listens to the 'song:selection-changed' dispatched from a child song-item
* to collect the selected songs.
*/
'song:selection-changed': () => this.gatherSelected(),
/**
* Listen to 'song:selection-clear' (often broadcasted from the direct parent)
* to clear the selected songs.

View file

@ -0,0 +1,148 @@
'use strict'
const fs = require('fs')
const path = require('path')
const faker = require('faker')
const md5 = require('blueimp-md5')
const _ = require('lodash')
const jsonServer = require('json-server')
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
const rootDir = path.resolve(__dirname, '../../../../../')
let data = null
const getData = () => {
if (!data) {
data = {
allowDownload: faker.random.boolean(),
artists: [],
albums: [],
cdnUrl: 'http://localhost:8080/',
currentUser: {},
interactions: [],
latestVersion: 'v2.2.1',
playlists: [],
settings: {
media_path: '/tmp/fake/path'
},
useLastfm: faker.random.boolean(),
users: []
}
const allSongs = []
// create 100 songs in 10 albums by 5 artists
for (let i = 1; i < 6; ++i) {
const artist = {
id: i,
name: faker.name.findName(),
image: faker.image.imageUrl(),
albums: []
}
for (let j = 1; j < 3; ++j) {
const album = {
id: i * 10 + j,
artist_id: artist.id,
cover: faker.image.imageUrl(),
is_compilation: false, // let's keep it simple for now
name: faker.lorem.sentence(3),
songs: []
}
for (let k = 1; k < 11; ++k) {
const song = {
id: md5(faker.random.uuid()),
album_id: album.id,
contributing_artist_id: null,
title: faker.lorem.sentence(5),
length: (Math.random()*1000).toFixed(2),
track: faker.random.number(15),
lyrics: faker.lorem.paragraphs(3),
path: '/tmp/fake/file.mp3'
}
album.songs.push(song)
allSongs.push(song)
}
artist.albums.push(album)
}
data.artists.push(artist)
}
// create 50 interactions
_.sampleSize(allSongs, 50).forEach(song => {
data.interactions.push({
song_id: song.id,
like: faker.random.boolean(),
play_count: faker.random.number(100)
})
})
// create 3 playlists, each contains a random of 0-20 songs
for (let i = 1; i < 4; ++i) {
data.playlists.push({
id: i,
name: faker.lorem.sentence(3),
songs: _.map(_.sampleSize(allSongs, faker.random.number(20)), 'id')
})
}
// create 3 users and make the first one the current and admin
for (let i = 1; i < 4; ++i) {
data.users.push({
id: i,
name: faker.name.findName(),
email: faker.internet.email(),
is_admin: i === 1
})
}
data.currentUser = _.clone(data.users[0])
}
return data
}
fs.createReadStream(__dirname + '/app.html')
.pipe(fs.createWriteStream(rootDir + '/index.html'))
app.use(express.static(rootDir))
app.use(bodyParser.json())
// Mock all basic routes
app.get('/api/data', (req, res) => {
res.json(getData())
}).post('/api/me', (req, res, next) => {
if (req.body.email === 'admin@koel.dev' && req.body.password === 'koel') {
res.json({ token: 'koelToken' })
} else {
res.status(401).json({ error: 'invalid_credentials' })
}
}).delete('/api/me', (req, res, next) => {
next()
}).put('/api/me', (req, res, next) => {
next()
})
module.exports = {
server: null,
start() {
this.server = app.listen(3000, function () {
console.log('API server started at port 3000')
})
},
stop() {
this.server.close()
},
restart() {
this.stop()
this.start()
},
data() {
return getData()
}
}

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>Koel</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="public/css/vendors.css">
<link rel="stylesheet" href="public/css/app.css">
</head>
<body>
<div id="app"></div>
<script src="public/js/vendors.js"></script>
<script src="public/js/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,49 @@
'use strict'
const fs = require('fs')
const chalk = require('chalk')
const path = require('path')
require('chai').should()
const express = require('express')
const app = express()
console.log(chalk.white.bgMagenta('Remember to run gulp before e2e!'))
app.use('/public', express.static(path.resolve(__dirname, '../../../../public')))
app.get('/', (req, res) => {
res.sendFile(path.resolve(__dirname, 'app.html'))
})
const appServer = app.listen(8080, function () {
console.log('App server started at 8080')
})
let apiServer = require('./apiServer')
const Nightmare = require('nightmare')
const nightmare = Nightmare({ show: true })
apiServer.start()
describe('test elements rendered', function () {
afterEach(() => {
nightmare.end()
})
after(function() {
apiServer.stop()
apiServer = null
})
it ('should display the login form', done => {
nightmare
.goto('http://localhost:8080')
.wait('#main')
.evaluate(() => {
return document.querySelector('.login-wrapper')
})
.then(result => {
result.should.not.be.null
done()
})
})
})

View file

@ -0,0 +1,25 @@
<?php
namespace E2E;
use Facebook\WebDriver\WebDriverBy;
class AlbumsScreenTest extends TestCase
{
public function testAlbumsScreen()
{
$this->loginAndWait()->goto('albums');
static::assertNotEmpty($this->els('#albumsWrapper .albums article.item'));
$firstAlbum = $this->el('#albumsWrapper .albums article.item:nth-child(1)');
static::assertNotEmpty($firstAlbum->findElement(WebDriverBy::cssSelector('.info .name'))->getText());
static::assertNotEmpty($firstAlbum->findElement(WebDriverBy::cssSelector('.info .artist'))->getText());
static::assertContains('10 songs', $firstAlbum->findElement(WebDriverBy::cssSelector('.meta'))->getText());
// test the view modes
$this->click('#albumsWrapper > h1.heading > span.view-modes > a:nth-child(2)');
static::assertCount(1, $this->els('#albumsWrapper > div.albums.as-list'));
$this->click('#albumsWrapper > h1.heading > span.view-modes > a:nth-child(1)');
static::assertCount(1, $this->els('#albumsWrapper > div.albums.as-thumbnails'));
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace E2E;
abstract class AllSongsScreenTest extends TestCase
{
// All we need to test should have been cover in SongListTest class.
}

View file

@ -0,0 +1,24 @@
<?php
namespace E2E;
use Facebook\WebDriver\WebDriverBy;
class ArtistsScreenTest extends TestCase
{
public function testArtistsScreen()
{
$this->loginAndWait()->goto('artists');
static::assertNotEmpty($this->els('#artistsWrapper .artists article.item'));
$firstArtist = $this->el('#artistsWrapper .artists article.item:nth-child(1)');
static::assertNotEmpty($firstArtist->findElement(WebDriverBy::cssSelector('.info .name'))->getText());
static::assertContains('5 albums • 50 songs', $firstArtist->findElement(WebDriverBy::cssSelector('.meta'))->getText());
// test the view modes
$this->click('#artistsWrapper > h1.heading > span.view-modes > a:nth-child(2)');
static::assertCount(1, $this->els('#artistsWrapper > div.artists.as-list'));
$this->click('#artistsWrapper > h1.heading > span.view-modes > a:nth-child(1)');
static::assertCount(1, $this->els('#artistsWrapper > div.artists.as-thumbnails'));
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace E2E;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverExpectedCondition;
class KoelTest extends TestCase
{
public function testDefaults()
{
static::assertContains('Koel', $this->driver->getTitle());
$formSelector = '#app > div.login-wrapper > form';
// Our login form should be there.
static::assertCount(1, $this->els($formSelector));
// We submit rubbish and expect an error class on the form.
$this->login('foo@bar.com', 'ThisIsWongOnSoManyLevels');
$this->waitUntil(WebDriverExpectedCondition::presenceOfElementLocated(
WebDriverBy::cssSelector("$formSelector.error")
));
// Now we submit good stuff and make sure we're in.
$this->login();
$this->waitUntil(WebDriverExpectedCondition::textToBePresentInElement(
WebDriverBy::cssSelector('#userBadge > a.view-profile.control > span'), 'Koel Admin'
));
// Default URL must be Home
static::assertEquals($this->url.'/#!/home', $this->driver->getCurrentURL());
// While we're at this, test logging out as well.
$this->click('#userBadge > a.logout');
$this->waitUntil(WebDriverExpectedCondition::visibilityOfElementLocated(
WebDriverBy::cssSelector($formSelector)
));
}
}

View file

@ -0,0 +1,45 @@
<?php
/**
* Created by PhpStorm.
* User: an
* Date: 15/11/16
* Time: 4:14 PM
*/
namespace E2E;
use Facebook\WebDriver\Remote\RemoteWebElement;
class HomeScreenTest extends TestCase
{
public function testHomeScreen()
{
$this->loginAndWait();
$this->click('#sidebar a.home');
// We must see some greetings
static::assertTrue($this->el('#homeWrapper > h1')->isDisplayed());
// 6 recently added albums
static::assertCount(6, $this->els('#homeWrapper section.recently-added article'));
// 10 recently added songs
static::assertCount(10, $this->els('#homeWrapper .recently-added-song-list .song-item-home'));
// Shuffle must work for latest albums
$this->click('#homeWrapper section.recently-added article:nth-child(1) span.right a:nth-child(1)');
static::assertCount(10, $this->els('#queueWrapper .song-list-wrap tr.song-item'));
$this->goto('home');
// Simulate a "double click to play" action
/** @var $clickedSong RemoteWebElement */
$clickedSong = $this->el('#homeWrapper section.recently-added > div > div:nth-child(2) li:nth-child(1) .details');
$this->doubleClick($clickedSong);
// The song must appear at the top of "Recently played" section
/** @var $mostRecentSong RemoteWebElement */
$mostRecentSong = $this->el('#homeWrapper .recently-added-song-list .song-item-home:nth-child(1) .details');
static::assertEquals($mostRecentSong->getText(), $clickedSong->getText());
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace E2E;
class QueueScreenTest extends TestCase
{
public function test()
{
$this->loginAndWait();
$this->goto('queue');
static::assertContains('Current Queue', $this->el('#queueWrapper > h1 > span')->getText());
// As the queue is currently empty, the "Shuffling all song" link should be there
$this->click('#queueWrapper a.start');
$this->waitUntil(function () {
return count($this->els('#queueWrapper .song-item'));
});
// Clear the queue
$this->click('#queueWrapper .buttons button.btn.btn-red');
static::assertEmpty($this->els('#queueWrapper tr.song-item'));
}
}

68
tests/e2e/SideBarTest.php Normal file
View file

@ -0,0 +1,68 @@
<?php
namespace E2E;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverElement;
use Facebook\WebDriver\WebDriverExpectedCondition;
class SideBarTest extends TestCase
{
public function testSideBar()
{
$this->loginAndWait();
// All basic navigation
foreach (['home', 'queue', 'songs', 'albums', 'artists', 'youtube', 'settings', 'users'] as $screen) {
$this->goto($screen);
$this->waitUntil(function () use ($screen) {
return $this->driver->getCurrentURL() === $this->url.'/#!/'.$screen;
});
}
// Add a playlist
$this->click('#playlists > h1 > i.create');
$this->waitUntil(WebDriverExpectedCondition::visibilityOfElementLocated(
WebDriverBy::cssSelector('#playlists > form.create')
));
$this->typeIn('#playlists > form > input[type="text"]', 'Bar');
$this->enter();
/** @var WebDriverElement $mostRecentPlaylist */
$mostRecentPlaylist = null;
$this->waitUntil(function () use (&$mostRecentPlaylist) {
/** @var WebDriverElement $mostRecentPlaylist */
$list = $this->els('#playlists .playlist');
$mostRecentPlaylist = end($list);
return $mostRecentPlaylist->getText() === 'Bar';
});
// Double click to edit/rename a playlist
$this->doubleClick($mostRecentPlaylist);
$this->waitUntil(function () use (&$mostRecentPlaylist) {
return count($mostRecentPlaylist->findElements(WebDriverBy::cssSelector('input[type="text"]')));
});
$this->typeIn(
$mostRecentPlaylist->findElement(WebDriverBy::cssSelector('input[type="text"]')),
'Baz'
);
$this->enter();
$this->waitUntil(function () {
$list = $this->els('#playlists .playlist');
$mostRecentPlaylist = end($list);
return $mostRecentPlaylist->getText() === 'Baz';
});
// Edit with an empty name shouldn't do anything.
$this->doubleClick($mostRecentPlaylist);
$mostRecentPlaylist->findElement(WebDriverBy::cssSelector('input[type="text"]'))->clear();
$this->enter();
$this->waitUntil(function () {
$list = $this->els('#playlists .playlist');
$mostRecentPlaylist = end($list);
return $mostRecentPlaylist->getText() === 'Baz';
});
}
}

195
tests/e2e/SongListTest.php Normal file
View file

@ -0,0 +1,195 @@
<?php
namespace E2E;
use Facebook\WebDriver\Interactions\WebDriverActions;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverElement;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Facebook\WebDriver\WebDriverKeys;
class SongListTest extends TestCase
{
public function testSelection()
{
$this->loginAndWait()->repopulateList();
// Single song selection
static::assertContains(
'selected',
$this->click('#queueWrapper tr.song-item:nth-child(1)')->getAttribute('class')
);
// shift+click
(new WebDriverActions($this->driver))
->keyDown(null, WebDriverKeys::SHIFT)
->click($this->el('#queueWrapper tr.song-item:nth-child(5)'))
->keyUp(null, WebDriverKeys::SHIFT)
->perform();
// should have 5 selected rows
static::assertCount(5, $this->els('#queueWrapper tr.song-item.selected'));
// Cmd+Click
(new WebDriverActions($this->driver))
->keyDown(null, WebDriverKeys::COMMAND)
->click($this->el('#queueWrapper tr.song-item:nth-child(2)'))
->click($this->el('#queueWrapper tr.song-item:nth-child(3)'))
->keyUp(null, WebDriverKeys::COMMAND)
->perform();
// should have only 3 selected rows remaining
static::assertCount(3, $this->els('#queueWrapper tr.song-item.selected'));
// 2nd and 3rd rows must not be selected
static::assertNotContains(
'selected',
$this->el('#queueWrapper tr.song-item:nth-child(2)')->getAttribute('class')
);
static::assertNotContains(
'selected',
$this->el('#queueWrapper tr.song-item:nth-child(3)')->getAttribute('class')
);
// Delete key should remove selected songs
$this->press(WebDriverKeys::DELETE);
$this->waitUntil(function () {
return count($this->els('#queueWrapper tr.song-item.selected')) === 0
&& count($this->els('#queueWrapper tr.song-item')) === 7;
});
// Ctrl+A/Cmd+A should select all songs
$this->selectAll();
static::assertCount(7, $this->els('#queueWrapper tr.song-item.selected'));
}
public function testActionButtons()
{
$this->loginAndWait()->repopulateList();
// Since no songs are selected, the shuffle button must read "(Shuffle) All"
static::assertContains('ALL', $this->el('#queueWrapper button.play-shuffle')->getText());
// Now we selected all songs it to read "(Shuffle) Selected"
$this->selectAll();
static::assertContains('SELECTED', $this->el('#queueWrapper button.play-shuffle')->getText());
// Add to favorites
$this->el('#queueWrapper tr.song-item:nth-child(1)')->click();
$this->click('#queueWrapper .buttons button.btn.btn-green');
$this->click('#queueWrapper .buttons li:nth-child(1)');
$this->goto('favorites');
static::assertCount(1, $this->els('#favoritesWrapper tr.song-item'));
$this->goto('queue');
$this->click('#queueWrapper tr.song-item:nth-child(1)');
// Try adding a song into a new playlist
$this->click('#queueWrapper .buttons button.btn.btn-green');
$this->typeIn('#queueWrapper .buttons input[type="text"]', 'Foo');
$this->enter();
$this->waitUntil(WebDriverExpectedCondition::textToBePresentInElement(
WebDriverBy::cssSelector('#playlists > ul'), 'Foo'
));
}
public function testSorting()
{
$this->loginAndWait()->repopulateList();
// Confirm that we can't sort in Queue screen
/** @var WebDriverElement $th */
foreach ($this->els('#queueWrapper div.song-list-wrap th') as $th) {
if (!$th->isDisplayed()) {
continue;
}
foreach ($th->findElements(WebDriverBy::tagName('i')) as $sortDirectionIcon) {
static::assertFalse($sortDirectionIcon->isDisplayed());
}
}
// Now go to All Songs screen and sort there
$this->goto('songs');
$this->click('#songsWrapper div.song-list-wrap th:nth-child(2)');
$last = null;
$results = [];
/** @var WebDriverElement $td */
foreach ($this->els('#songsWrapper div.song-list-wrap td.title') as $td) {
$current = $td->getText();
$results[] = $last === null ? true : $current <= $last;
$last = $current;
}
static::assertNotContains(false, $results);
// Second click will reverse the sort
$this->click('#songsWrapper div.song-list-wrap th:nth-child(2)');
$last = null;
$results = [];
/** @var WebDriverElement $td */
foreach ($this->els('#songsWrapper div.song-list-wrap td.title') as $td) {
$current = $td->getText();
$results[] = $last === null ? true : $current >= $last;
$last = $current;
}
static::assertNotContains(false, $results);
}
public function testContextMenu()
{
$this->loginAndWait()->goto('songs');
$this->rightClick('#songsWrapper tr.song-item:nth-child(1)');
$by = WebDriverBy::cssSelector('#songsWrapper .song-menu');
$this->waitUntil(WebDriverExpectedCondition::visibilityOfElementLocated($by));
// 7 sub menu items
static::assertCount(7, $this->els('#songsWrapper .song-menu > li'));
// Clicking the "Go to Album" menu item
$this->click('#songsWrapper .song-menu > li:nth-child(2)');
$this->waitUntil(WebDriverExpectedCondition::visibilityOfElementLocated(
WebDriverBy::cssSelector('#albumWrapper')
));
// Clicking the "Go to Artist" menu item
$this->back();
$this->rightClick('#songsWrapper tr.song-item:nth-child(1)');
$this->click('#songsWrapper .song-menu > li:nth-child(3)');
$this->waitUntil(WebDriverExpectedCondition::visibilityOfElementLocated(
WebDriverBy::cssSelector('#artistWrapper')
));
// Clicking "Edit"
$this->back();
$this->rightClick('#songsWrapper tr.song-item:nth-child(1)');
$this->click('#songsWrapper .song-menu > li:nth-child(5)');
$this->waitUntil(WebDriverExpectedCondition::visibilityOfElementLocated(
WebDriverBy::cssSelector('#editSongsOverlay form')
));
// Updating song
$this->typeIn('#editSongsOverlay form input[name="title"]', 'Foo');
$this->typeIn('#editSongsOverlay form input[name="track"]', 99);
$this->enter();
$this->waitUntil(WebDriverExpectedCondition::invisibilityOfElementLocated(
WebDriverBy::cssSelector('#editSongsOverlay form')
));
static::assertEquals('99', $this->el('#songsWrapper tr.song-item:nth-child(1) .track-number')->getText());
static::assertEquals('Foo', $this->el('#songsWrapper tr.song-item:nth-child(1) .title')->getText());
}
private function repopulateList()
{
// Go back to Albums and queue an album of 10 songs
$this->goto('albums');
$this->click('#albumsWrapper > div > article:nth-child(1) > footer > p > span.right > a:nth-child(1)');
$this->goto('queue');
}
private function selectAll()
{
$this->focusIntoApp(); // make sure focus is there before executing shortcut keys
(new WebDriverActions($this->driver))
->keyDown(null, WebDriverKeys::COMMAND)
->keyDown(null, 'A')
->keyUp(null, 'A')
->keyUp(null, WebDriverKeys::COMMAND)
->perform();
}
}

133
tests/e2e/TestCase.php Normal file
View file

@ -0,0 +1,133 @@
<?php
namespace E2E;
use App\Application;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
abstract class TestCase extends \PHPUnit_Framework_TestCase
{
use WebDriverShortcuts;
/**
* @var Application
*/
protected $app;
/**
* The default Koel URL for E2E (server by `php artisan serve --port=8081`).
*
* @var string
*/
protected $url = 'http://localhost:8081';
protected $coverPath;
/**
* TestCase constructor.
*/
public function __construct()
{
parent::__construct();
$this->createApp();
$this->prepareForE2E();
$this->driver = RemoteWebDriver::create('http://localhost:4444/wd/hub', DesiredCapabilities::chrome());
}
/**
* @return Application
*/
protected function createApp()
{
$this->app = require __DIR__.'/../../bootstrap/app.php';
$this->app->make(Kernel::class)->bootstrap();
return $this->app;
}
private function prepareForE2E()
{
// Make sure we have a fresh database.
@unlink(__DIR__.'/../../database/e2e.sqlite');
touch(__DIR__.'/../../database/e2e.sqlite');
Artisan::call('migrate');
Artisan::call('db:seed');
Artisan::call('db:seed', ['--class' => 'E2EDataSeeder']);
if (!file_exists($this->coverPath)) {
@mkdir($this->coverPath, 0777, true);
}
}
/**
* Log into Koel.
*
* @param string $username
* @param string $password
*/
protected function login($username = 'koel@example.com', $password = 'SoSecureK0el')
{
$this->typeIn("#app > div.login-wrapper > form > [type='email']", $username);
$this->typeIn("#app > div.login-wrapper > form > [type='password']", $password);
$this->enter();
}
protected function loginAndWait()
{
$this->login();
$this->waitUntil(WebDriverExpectedCondition::textToBePresentInElement(
WebDriverBy::cssSelector('#userBadge > a.view-profile.control > span'), 'Koel Admin'
));
return $this;
}
/**
* A helper to allow going to a specific screen.
*
* @param $screen
*
* @return \Facebook\WebDriver\Remote\RemoteWebElement
*
* @throws \Exception
*/
protected function goto($screen)
{
if ($screen === 'favorites') {
$this->click('#sidebar .favorites a');
} else {
$this->click("#sidebar a.$screen");
}
$this->waitUntil(WebDriverExpectedCondition::visibilityOfElementLocated(
WebDriverBy::cssSelector("#{$screen}Wrapper")
));
}
protected function waitForUserInput()
{
if (trim(fgets(fopen('php://stdin', 'rb'))) !== chr(13)) {
return;
}
}
protected function focusIntoApp()
{
$this->click('#app');
}
public function setUp()
{
$this->driver->get($this->url);
}
public function tearDown()
{
$this->driver->quit();
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace E2E;
use Facebook\WebDriver\Interactions\Internal\WebDriverDoubleClickAction;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverElement;
use Facebook\WebDriver\WebDriverKeys;
trait WebDriverShortcuts
{
/**
* @var RemoteWebDriver
*/
protected $driver;
/**
* @param $selector WebDriverElement|string
*
* @return WebDriverElement
*/
protected function el($selector)
{
if (is_string($selector)) {
return $this->driver->findElement(WebDriverBy::cssSelector($selector));
}
return $selector;
}
protected function els($selector)
{
return $this->driver->findElements(WebDriverBy::cssSelector($selector));
}
protected function type($string)
{
return $this->driver->getKeyboard()->sendKeys($string);
}
protected function typeIn($element, $string)
{
$this->click($element)->clear();
return $this->type($string);
}
protected function press($key = WebDriverKeys::ENTER)
{
return $this->driver->getKeyboard()->pressKey($key);
}
protected function enter()
{
return $this->press();
}
protected function click($element)
{
return $this->el($element)->click();
}
protected function rightClick($element)
{
return $this->driver->getMouse()->contextClick($this->el($element)->getCoordinates());
}
protected function doubleClick($element)
{
$action = new WebDriverDoubleClickAction($this->driver->getMouse(), $this->el($element));
$action->perform();
}
/**
* Sleep (implicit wait) for some seconds.
*
* @param $seconds
*/
protected function sleep($seconds)
{
$this->driver->manage()->timeouts()->implicitlyWait($seconds);
}
/**
* Wait until a condition is met.
*
* @param $func (closure|WebDriverExpectedCondition)
* @param int $timeout
*
* @return mixed
* @throws \Exception
*/
protected function waitUntil($func, $timeout = 10)
{
return $this->driver->wait($timeout)->until($func);
}
protected function back()
{
return $this->driver->navigate()->back();
}
protected function forward()
{
return $this->driver->navigate()->forward();
}
protected function refresh()
{
return $this->driver->navigate()->refresh();
}
}