jqh 5 лет назад
Родитель
Сommit
1b290e5cbf
63 измененных файлов с 2737 добавлено и 45 удалено
  1. 1 2
      composer.json
  2. 2 2
      resources/views/form/tree.blade.php
  3. 2 2
      resources/views/widgets/tree.blade.php
  4. 1 0
      src/AdminServiceProvider.php
  5. 34 0
      src/Console/Tests/InstallCommand.php
  6. 1 1
      src/Controllers/AuthController.php
  7. 1 1
      src/Form/Field/Tree.php
  8. 3 3
      src/Grid/Displayers/DialogTree.php
  9. 1 1
      src/Widgets/Tree.php
  10. 0 25
      tests/Browser/LoginTest.php
  11. 13 5
      tests/artisan
  12. 18 0
      tests/browser-tests/.ide-helper.php
  13. 77 0
      tests/browser-tests/Browser/AuthTest.php
  14. 32 0
      tests/browser-tests/Browser/Components/Component.php
  15. 32 0
      tests/browser-tests/Browser/Components/Form/Field/MultipleSelect2.php
  16. 69 0
      tests/browser-tests/Browser/Components/Form/Field/Select2.php
  17. 120 0
      tests/browser-tests/Browser/Components/Form/Field/Tree.php
  18. 130 0
      tests/browser-tests/Browser/Components/Form/MenuCreationForm.php
  19. 67 0
      tests/browser-tests/Browser/Components/Form/MenuEditForm.php
  20. 60 0
      tests/browser-tests/Browser/IndexTest.php
  21. 40 0
      tests/browser-tests/Browser/InstallTest.php
  22. 141 0
      tests/browser-tests/Browser/MenuTest.php
  23. 55 0
      tests/browser-tests/Browser/Pages/MenuEditPage.php
  24. 90 0
      tests/browser-tests/Browser/Pages/MenuPage.php
  25. 20 0
      tests/browser-tests/Browser/Pages/Page.php
  26. 2 0
      tests/browser-tests/Browser/console/.gitignore
  27. 2 0
      tests/browser-tests/Browser/screenshots/.gitignore
  28. 126 0
      tests/browser-tests/BrowserExtension.php
  29. 21 0
      tests/browser-tests/ChromeProcess.php
  30. 86 0
      tests/browser-tests/Controllers/DropdownController.php
  31. 62 0
      tests/browser-tests/Controllers/ReportController.php
  32. 211 0
      tests/browser-tests/Controllers/UserController.php
  33. 113 0
      tests/browser-tests/CreatesApplication.php
  34. 106 0
      tests/browser-tests/DuskTestCase.php
  35. 89 0
      tests/browser-tests/InteractsWithDatabase.php
  36. 10 0
      tests/browser-tests/Models/File.php
  37. 10 0
      tests/browser-tests/Models/Image.php
  38. 22 0
      tests/browser-tests/Models/MultipleImage.php
  39. 15 0
      tests/browser-tests/Models/Profile.php
  40. 15 0
      tests/browser-tests/Models/Tag.php
  41. 28 0
      tests/browser-tests/Models/Tree.php
  42. 40 0
      tests/browser-tests/Models/User.php
  43. 61 0
      tests/browser-tests/Repositories/Report.php
  44. 11 0
      tests/browser-tests/Repositories/User.php
  45. 7 0
      tests/browser-tests/TestCase.php
  46. 12 0
      tests/browser-tests/helpers.php
  47. BIN
      tests/browser-tests/resources/assets/test.jpg
  48. 381 0
      tests/browser-tests/resources/config/admin.php
  49. 95 0
      tests/browser-tests/resources/config/filesystems.php
  50. BIN
      tests/browser-tests/resources/drivers/chromedriver-linux
  51. BIN
      tests/browser-tests/resources/drivers/chromedriver-mac
  52. BIN
      tests/browser-tests/resources/drivers/chromedriver-win.exe
  53. 15 0
      tests/browser-tests/resources/lang/en/global.php
  54. 16 0
      tests/browser-tests/resources/lang/en/user.php
  55. 98 0
      tests/browser-tests/resources/migrations/2016_11_22_093148_create_test_tables.php
  56. 21 0
      tests/browser-tests/resources/seeds/UserTableSeeder.php
  57. 36 0
      tests/browser-tests/resources/seeds/factory.php
  58. 1 0
      tests/browser-tests/resources/views/test.blade.php
  59. 10 0
      tests/browser-tests/routes.php
  60. BIN
      tests/resources/drivers/chromedriver-linux
  61. BIN
      tests/resources/drivers/chromedriver-mac
  62. BIN
      tests/resources/drivers/chromedriver-win.exe
  63. 5 3
      tests/routes.php

+ 1 - 2
composer.json

@@ -26,8 +26,7 @@
         "phpunit/phpunit": "^7.5",
         "fzaninotto/faker": "^1.4",
         "mockery/mockery": "^1.0",
-        "matt-allan/laravel-code-style": "^0.3.0",
-        "beyondcode/dusk-dashboard": "^1.2"
+        "matt-allan/laravel-code-style": "^0.3.0"
     },
     "autoload": {
         "psr-4": {

+ 2 - 2
resources/views/form/tree.blade.php

@@ -11,7 +11,7 @@
 
             <div class="jstree-wrapper {{$class}}-tree-wrapper">
                 {!! $checkboxes !!}
-                <div class="_tree" style="margin-top:10px"></div>
+                <div class="da-tree" style="margin-top:10px"></div>
             </div>
         </div>
 
@@ -25,7 +25,7 @@
 @endphp
 <script data-exec-on-popstate>
 LA.ready(function () {
-    var $tree = $('{!!$formId !!} .{{$class}}-tree-wrapper').find('._tree'),
+    var $tree = $('{!!$formId !!} .{{$class}}-tree-wrapper').find('.da-tree'),
         opts = {!! $options !!},
         $input = $('{!!$formId !!} input[name="{{$name}}"]'),
         parents = {!! $parents !!};

+ 2 - 2
resources/views/widgets/tree.blade.php

@@ -1,8 +1,8 @@
-<div  {!! $attributes !!}><div class="_tree"></div></div>
+<div  {!! $attributes !!}><div class="da-tree"></div></div>
 
 <script>
 LA.ready(function () {
-    var opts = {!! json_encode($options) !!}, tree = $('#{{$id}}').find('._tree');
+    var opts = {!! json_encode($options) !!}, tree = $('#{{$id}}').find('.da-tree');
 
     opts.core.data = {!! json_encode($nodes) !!};
 

+ 1 - 0
src/AdminServiceProvider.php

@@ -30,6 +30,7 @@ class AdminServiceProvider extends ServiceProvider
         Console\FormCommand::class,
         Console\ActionCommand::class,
         Console\MenuCacheCommand::class,
+        Console\Tests\InstallCommand::class,
     ];
 
     /**

+ 34 - 0
src/Console/Tests/InstallCommand.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Dcat\Admin\Console\Tests;
+
+use Illuminate\Console\Command;
+use Illuminate\Filesystem\Filesystem;
+
+class InstallCommand extends Command
+{
+    protected $signature = 'admin:tests:install';
+
+    protected $description = 'Install the admin tests package';
+
+    /**
+     * @var Filesystem
+     */
+    protected $files;
+
+    protected $directory;
+
+    public function handle()
+    {
+        $this->files = app('files');
+
+        $this->initTestsDirectory();
+
+        $this->files->copyDirectory(realpath(__DIR__.'/../../../browser-tests'), base_path('tests'));
+    }
+
+    protected function initTestsDirectory()
+    {
+        $this->directory = base_path();
+    }
+}

+ 1 - 1
src/Controllers/AuthController.php

@@ -226,7 +226,7 @@ class AuthController extends Controller
      */
     protected function redirectPath()
     {
-        return $this->redirectTo ?: config('admin.route.prefix');
+        return $this->redirectTo ?: admin_url('/');
     }
 
     /**

+ 1 - 1
src/Form/Field/Tree.php

@@ -116,7 +116,7 @@ class Tree extends Field
      *
      * @return $this
      */
-    public function columnNames(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
+    public function name(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
     {
         $this->columnNames['id'] = $idColumn;
         $this->columnNames['text'] = $textColumn;

+ 3 - 3
src/Grid/Displayers/DialogTree.php

@@ -127,7 +127,7 @@ class DialogTree extends AbstractDisplayer
      *
      * @return $this
      */
-    public function columnNames(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
+    public function name(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
     {
         $this->columnNames['id'] = $idColumn;
         $this->columnNames['text'] = $textColumn;
@@ -176,7 +176,7 @@ EOF;
         Admin::script(
             <<<JS
 $('.{$this->getSelectorPrefix()}-open-tree').off('click').click(function () {
-    var tpl = '<div class="jstree-wrapper" style="border:0"><div class="_tree" style="margin-top:10px"></div></div>', 
+    var tpl = '<div class="jstree-wrapper" style="border:0"><div class="da-tree" style="margin-top:10px"></div></div>', 
         opts = $opts,
         url = '{$this->url}',
         t = $(this),
@@ -215,7 +215,7 @@ $('.{$this->getSelectorPrefix()}-open-tree').off('click').click(function () {
             content: tpl,
             title: '{$title}',
             success: function (a, idx) {
-                var tree = $('#layui-layer'+idx).find('._tree');
+                var tree = $('#layui-layer'+idx).find('.da-tree');
                 
                 tree.on("loaded.jstree", function () {
                     tree.jstree('open_all');

+ 1 - 1
src/Widgets/Tree.php

@@ -74,7 +74,7 @@ class Tree extends Widget
      *
      * @return $this
      */
-    public function columnNames(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
+    public function name(string $idColumn = 'id', string $textColumn = 'name', string $parentColumn = 'parent_id')
     {
         $this->columnNames['id'] = $idColumn;
         $this->columnNames['text'] = $textColumn;

+ 0 - 25
tests/Browser/LoginTest.php

@@ -1,25 +0,0 @@
-<?php
-
-namespace Dcat\Admin\Tests\Browser;
-
-use Laravel\Dusk\Browser;
-use Dcat\Admin\Tests\DuskTestCase;
-
-/**
- * @group login
- */
-class LoginTest extends DuskTestCase
-{
-    /**
-     * A Dusk test example.
-     *
-     * @return void
-     */
-    public function testExample()
-    {
-        $this->browse(function (Browser $browser) {
-            $browser->visit('/admin')
-                ->assertSee('Dcat Admin');
-        });
-    }
-}

+ 13 - 5
tests/artisan

@@ -32,16 +32,24 @@ $app = require_once __DIR__.'/../vendor/laravel/laravel/bootstrap/app.php';
 
 $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
 
+Illuminate\Console\Application::starting(function ($artisan) {
+    $artisan->resolveCommands([
+        \Dcat\Admin\Tests\Console\InstallCommand::class,
+    ]);
+});
+
+$app->booting(function () use ($app) {
+    $app['env'] = 'local';
+
+    $app->register(\Laravel\Dusk\DuskServiceProvider::class);
+    $app->register(\BeyondCode\DuskDashboard\DuskDashboardServiceProvider::class);
+});
+
 $status = $kernel->handle(
     $input = new Symfony\Component\Console\Input\ArgvInput,
     new Symfony\Component\Console\Output\ConsoleOutput
 );
 
-$app['env'] = false;
-
-$app->register(\Laravel\Dusk\DuskServiceProvider::class);
-$app->register(\BeyondCode\DuskDashboard\DuskDashboardServiceProvider::class);
-
 /*
 |--------------------------------------------------------------------------
 | Shutdown The Application

+ 18 - 0
tests/browser-tests/.ide-helper.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Laravel\Dusk
+{
+    use Laravel\Dusk\Component;
+
+    /**
+     * @method $this whenTextAvailable(string $text, $callbackOrSeconds = null, int $seconds = null)
+     * @method $this whenElementAvailable($selector, $callbackOrSeconds = null, int $seconds = null)
+     * @method $this hasInput($field)
+     * @method $this wait(int $seconds, $callback = null)
+     * @method $this assertHidden($selector)
+     * @method $this assert(Component $component)
+     */
+    class Browser
+    {
+    }
+}

+ 77 - 0
tests/browser-tests/Browser/AuthTest.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Tests\Browser;
+
+use Laravel\Dusk\Browser;
+use Tests\TestCase;
+
+/**
+ * @group auth
+ */
+class AuthTest extends TestCase
+{
+    protected $login = false;
+
+    public function testLoginPage()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit(test_admin_path('auth/login'))
+                ->assertSee(__('admin.login'));
+        });
+    }
+
+    public function testVisitWithoutLogin()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit(test_admin_path('/'))
+                ->assertPathIs(test_admin_path('auth/login'))
+                ->assertGuest('admin');
+        });
+    }
+
+    public function testLogin()
+    {
+        $this->browse(function (Browser $browser) {
+            $credentials = ['username' => 'admin', 'password' => 'admin'];
+
+            $browser->visit(test_admin_path('auth/login'))
+                ->assertPathIs(test_admin_path('auth/login'))
+                ->assertSee('Login')
+                ->type('username', $credentials['username'])
+                ->type('password', $credentials['password'])
+                ->press('Login')
+                ->assertPathIs(test_admin_path('/'))
+                ->assertSee('Administrator')
+                ->assertSee('Dashboard')
+                ->assertSee('Description...')
+                ->assertSee('Environment')
+                ->assertSee('PHP version')
+                ->assertSee('Laravel version')
+                ->assertSee('Extensions')
+                ->assertSee('Dependencies')
+                ->assertSee('php')
+                ->assertSee('laravel/framework');
+
+                //->assertAuthenticated('admin');
+
+            $browser->within('.main-sidebar', function (Browser $browser) {
+                $browser->assertSee('Admin')
+                    ->clickLink('Admin')
+                    ->waitForText('Users', 1)
+                    ->waitForText('Roles', 1)
+                    ->waitForText('Permission', 1)
+                    ->waitForText('Operation log', 1)
+                    ->waitForText('Menu', 1);
+            });
+        });
+    }
+
+    public function testLogout()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit(test_admin_path('auth/logout'))
+                ->assertPathIs(test_admin_path('auth/login'))
+                ->assertGuest('admin');
+        });
+    }
+}

+ 32 - 0
tests/browser-tests/Browser/Components/Component.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Tests\Browser\Components;
+
+use Laravel\Dusk\Browser;
+use Laravel\Dusk\Component as BaseComponent;
+
+abstract class Component extends BaseComponent
+{
+    /**
+     * @param Browser $browser
+     *
+     * @return string
+     */
+    public function parentSelector(Browser $browser)
+    {
+        return str_replace($this->selector(), '', $browser->resolver->prefix);
+    }
+
+    /**
+     * 获取完整的css选择器
+     *
+     * @param Browser $browser
+     * @param string $selector
+     *
+     * @return string
+     */
+    public function formatSelector(Browser $browser, $selector = null)
+    {
+        return $this->parentSelector($browser).' '.($selector ?: $this->selector());
+    }
+}

+ 32 - 0
tests/browser-tests/Browser/Components/Form/Field/MultipleSelect2.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Tests\Browser\Components\Form\Field;
+
+use Laravel\Dusk\Browser;
+
+class MultipleSelect2 extends Select2
+{
+    /**
+     * 选中下拉选框
+     *
+     * @param  Browser $browser
+     * @param  array   $values
+     *
+     * @return Browser
+     */
+    public function choose($browser, $values)
+    {
+        $values = implode(',', (array) $values);
+
+//dump($browser->resolver->prefix.' || '.$this->formatSelector($browser));
+
+        $browser->script(
+            <<<JS
+var values = '{$values}';
+$('{$this->formatSelector($browser)}').val(values.split(',')).change();
+JS
+        );
+
+        return $browser;
+    }
+}

+ 69 - 0
tests/browser-tests/Browser/Components/Form/Field/Select2.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Tests\Browser\Components\Form\Field;
+
+use Laravel\Dusk\Browser;
+use Tests\Browser\Components\Component;
+
+class Select2 extends Component
+{
+    protected $selector;
+
+    public function __construct($selector = null)
+    {
+        $this->selector = $selector;
+    }
+
+    /**
+     * 获取组件的 root selector
+     *
+     * @return string
+     */
+    public function selector()
+    {
+        return $this->selector;
+    }
+
+    /**
+     * 浏览器包含组件的断言
+     *
+     * @param  Browser  $browser
+     * @return void
+     */
+    public function assert(Browser $browser)
+    {
+        $browser->assertHidden($this->selector())
+            ->assertVisible('@container');
+    }
+
+    /**
+     * 读取组件的元素快捷方式
+     *
+     * @return array
+     */
+    public function elements()
+    {
+        return [
+            '@container' => '.select2'
+        ];
+    }
+
+    /**
+     * 选中下拉选框
+     *
+     * @param  Browser  $browser
+     * @param  mixed    $value
+     *
+     * @return Browser
+     */
+    public function choose(Browser $browser, $value)
+    {
+        $browser->script(
+            <<<JS
+$('{$this->formatSelector($browser)}').val('{$value}').change();
+JS
+        );
+
+        return $browser;
+    }
+}

+ 120 - 0
tests/browser-tests/Browser/Components/Form/Field/Tree.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace Tests\Browser\Components\Form\Field;
+
+use Laravel\Dusk\Browser;
+use Tests\Browser\Components\Component;
+
+class Tree extends Component
+{
+    protected $name;
+
+    public function __construct($name = null)
+    {
+        $this->name = $name;
+    }
+
+    /**
+     * 获取组件的 root selector
+     *
+     * @return string
+     */
+    public function selector()
+    {
+        return ".{$this->name}-tree-wrapper";
+    }
+
+    /**
+     * 浏览器包含组件的断言
+     *
+     * @param Browser $browser
+     * @return void
+     */
+    public function assert(Browser $browser)
+    {
+        $browser
+            ->whenElementAvailable('@tree', 2)
+            ->hasInput($this->name);
+    }
+
+    /**
+     * 读取组件的元素快捷方式
+     *
+     * @return array
+     */
+    public function elements()
+    {
+        return [
+            '@container' => $this->selector(),
+            '@tree'      => "{$this->selector()} .da-tree",
+            '@input'     => sprintf('input[name="%s"][type="hidden"]', $this->name),
+        ];
+    }
+
+    /**
+     * 选中下拉选框
+     *
+     * @param Browser $browser
+     * @param mixed   $values
+     *
+     * @return Browser
+     */
+    public function choose(Browser $browser, $values)
+    {
+        $values = json_encode((array) $values);
+
+        $browser->script(<<<JS
+var tree = $('{$this->getTreeSelector($browser)}');        
+        
+tree.jstree("uncheck_all");
+tree.jstree("select_node", {$values});
+JS
+        );
+
+        return $browser;
+    }
+
+    /**
+     * 选中所有
+     *
+     * @param Browser $browser
+     *
+     * @return Browser
+     */
+    public function checkAll(Browser $browser)
+    {
+        $browser->script(<<<JS
+$('{$this->getTreeSelector($browser)}').jstree("check_all");        
+JS
+        );
+
+        return $browser;
+    }
+
+    /**
+     * 取消选中所有
+     *
+     * @param Browser $browser
+     *
+     * @return Browser
+     */
+    public function unCheckAll(Browser $browser)
+    {
+        $browser->script(<<<JS
+$('{$this->getTreeSelector($browser)}').jstree("uncheck_all");        
+JS
+        );
+
+        return $browser;
+    }
+
+    /**
+     * @param \Laravel\Dusk\Browser $browser
+     *
+     * @return string
+     */
+    protected function getTreeSelector(Browser $browser)
+    {
+        return $this->formatSelector($browser, $this->elements()['@tree']);
+    }
+}

+ 130 - 0
tests/browser-tests/Browser/Components/Form/MenuCreationForm.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace Tests\Browser\Components\Form;
+
+use Laravel\Dusk\Browser;
+use Tests\Browser\Components\Component;
+use Tests\Browser\Components\Form\Field\MultipleSelect2;
+use Tests\Browser\Components\Form\Field\Select2;
+use Tests\Browser\Components\Form\Field\Tree;
+
+class MenuCreationForm extends Component
+{
+    protected $selector;
+
+    public function __construct($selector = 'form[method="POST"]')
+    {
+        $this->selector = $selector;
+    }
+
+    /**
+     * 获取组件的 css selector
+     *
+     * @return string
+     */
+    public function selector()
+    {
+        return $this->selector;
+    }
+
+    /**
+     * 浏览器包含组件的断言
+     *
+     * @param  Browser  $browser
+     * @return void
+     */
+    public function assert(Browser $browser)
+    {
+        $browser->assertSee(__('admin.submit'))
+            ->assertSee(__('admin.reset'))
+            ->within('@form', function (Browser $browser) {
+                $browser
+                    ->assertSee(__('admin.parent_id'))
+                    ->assertSee(__('admin.title'))
+                    ->assertSee(__('admin.icon'))
+                    ->assertSee(__('admin.uri'))
+                    ->assertSee(__('admin.roles'))
+                    ->assertSee(__('admin.permission'))
+                    ->assertSee(__('admin.selectall'))
+                    ->assertSee(__('admin.expand'))
+                    ->hasInput('title')
+                    ->hasInput('icon')
+                    ->hasInput('uri')
+                    ->assertSelected('parent_id', 0)
+                    ->assert(new Tree('permissions'))
+                    ->assert(new Select2('select[name="parent_id"]'))
+                    ->assert(new MultipleSelect2('select[name="roles[]"]'));
+            });
+    }
+
+    /**
+     * 注入表单
+     *
+     * @param Browser $browser
+     * @param array $input
+     *
+     * @return Browser
+     */
+    public function fill(Browser $browser, array $input)
+    {
+        $inputKeys = [
+            'title',
+            'icon',
+            'uri',
+        ];
+
+        $selectKeys = [
+            'parent_id'
+        ];
+
+        $multipleSelectKeys = [
+            'roles',
+        ];
+
+        foreach ($input as $key => $value) {
+            if (in_array($key, $inputKeys, true)) {
+                $browser->type($key, $value);
+
+                continue;
+            }
+
+            if (in_array($key, $selectKeys, true)) {
+                $selector = sprintf('select[name="%s"]', $key);
+                $browser->within(new Select2($selector), function ($browser) use ($value) {
+                    $browser->choose($value);
+                });
+
+                continue;
+            }
+
+            if (in_array($key, $multipleSelectKeys, true)) {
+                $selector = sprintf('select[name="%s[]"]', $key);
+                $browser->within(new MultipleSelect2($selector), function ($browser) use ($value) {
+                    $browser->choose($value);
+                });
+
+                continue;
+            }
+
+            if ($key === 'permissions') {
+                $browser->within(new Tree($key), function ($browser) use ($value) {
+                    $browser->choose($value);
+                });
+            }
+        }
+
+        return $browser;
+    }
+
+    /**
+     * 读取组件的元素快捷方式
+     *
+     * @return array
+     */
+    public function elements()
+    {
+        return [
+            '@form' => $this->selector,
+        ];
+    }
+}

+ 67 - 0
tests/browser-tests/Browser/Components/Form/MenuEditForm.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Tests\Browser\Components\Form;
+
+use Dcat\Admin\Models\Menu;
+use Laravel\Dusk\Browser;
+use Tests\Browser\Components\Form\Field\MultipleSelect2;
+use Tests\Browser\Components\Form\Field\Select2;
+use Tests\Browser\Components\Form\Field\Tree;
+
+class MenuEditForm extends MenuCreationForm
+{
+    protected $id;
+    protected $selector;
+
+    public function __construct($id = null, $selector = 'form[method="POST"]')
+    {
+        if ($id && ! is_numeric($id)) {
+            $selector = $id;
+            $id = null;
+        }
+
+        $this->id = $id;
+        $this->selector = $selector;
+    }
+
+    /**
+     * 浏览器包含组件的断言
+     *
+     * @param  Browser  $browser
+     * @return void
+     */
+    public function assert(Browser $browser)
+    {
+        $browser->assertSee(__('admin.submit'))
+            ->assertSee(__('admin.reset'))
+            ->within('@form', function (Browser $browser) {
+                $browser
+                    ->assertSee('ID')
+                    ->assertSee(__('admin.parent_id'))
+                    ->assertSee(__('admin.title'))
+                    ->assertSee(__('admin.icon'))
+                    ->assertSee(__('admin.uri'))
+                    ->assertSee(__('admin.roles'))
+                    ->assertSee(__('admin.permission'))
+                    ->assertSee(__('admin.created_at'))
+                    ->assertSee(__('admin.updated_at'))
+                    ->assertSee(__('admin.selectall'))
+                    ->assertSee(__('admin.expand'))
+                    ->hasInput('title')
+                    ->hasInput('icon')
+                    ->hasInput('uri')
+                    ->assert(new Tree('permissions'))
+                    ->assert(new Select2('select[name="parent_id"]'))
+                    ->assert(new MultipleSelect2('select[name="roles[]"]'));
+
+                if (! $this->id) {
+                    return;
+                }
+
+                $menu = Menu::find($this->id);
+                if ($menu) {
+                    $browser->assertSelected('parent_id', $menu->parent_id);
+                }
+            });
+    }
+}

+ 60 - 0
tests/browser-tests/Browser/IndexTest.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Tests\Browser;
+
+use Laravel\Dusk\Browser;
+use Tests\TestCase;
+
+/**
+ * @group index
+ */
+class IndexTest extends TestCase
+{
+    public function testIndex()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit(test_admin_path('/'))
+                ->assertSee('Dashboard')
+                ->assertSee('Description...')
+                ->assertSee('Environment')
+                ->assertSee('PHP version')
+                ->assertSee('Laravel version')
+                ->assertSee('Extensions')
+                ->assertSee('Dependencies')
+                ->assertSee('php')
+                ->assertSee('laravel/framework');
+        });
+    }
+
+    public function testClickMenu()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit(test_admin_path('/'))
+                ->within('.main-sidebar', function (Browser $browser) {
+                    $browser
+                        ->clickLink('Admin')
+                        ->whenTextAvailable('Users', 2)
+                        ->clickLink('Users')
+                        ->assertPathIs(test_admin_path('auth/users'))
+                        ->clickLink('Roles')
+                        ->assertPathIs(test_admin_path('auth/roles'))
+                        ->clickLink('Permission')
+                        ->assertPathIs(test_admin_path('auth/permissions'))
+                        ->clickLink('Menu')
+                        ->assertPathIs(test_admin_path('auth/menu'))
+                        ->clickLink('Operation log')
+                        ->assertPathIs(test_admin_path('auth/logs'))
+                        ->clickLink('Helpers')
+                        ->whenTextAvailable('Extensions', 2)
+                        ->clickLink('Extensions')
+                        ->assertPathIs(test_admin_path('helpers/extensions'))
+                        ->clickLink('Scaffold')
+                        ->assertPathIs(test_admin_path('helpers/scaffold'))
+                        ->clickLink('Routes')
+                        ->assertPathIs(test_admin_path('helpers/routes'))
+                        ->clickLink('Icons')
+                        ->assertPathIs(test_admin_path('helpers/icons'));
+                });
+        });
+    }
+}

+ 40 - 0
tests/browser-tests/Browser/InstallTest.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Tests\Browser;
+
+use Tests\TestCase;
+
+/**
+ * @group install
+ */
+class InstallTest extends TestCase
+{
+    protected $login = false;
+
+    public function testInstalledDirectories()
+    {
+        $this->assertFileExists(admin_path());
+
+        $this->assertFileExists(admin_path('Controllers'));
+
+        $this->assertFileExists(admin_path('routes.php'));
+
+        $this->assertFileExists(admin_path('bootstrap.php'));
+
+        $this->assertFileExists(admin_path('Controllers/HomeController.php'));
+
+        $this->assertFileExists(admin_path('Controllers/AuthController.php'));
+
+        $this->assertFileExists(config_path('admin.php'));
+
+        $this->assertFileExists(public_path('vendor/dcat-admin'));
+
+        $this->assertFileExists(database_path('migrations/2016_01_04_173148_create_admin_tables.php'));
+
+        $this->assertFileExists(resource_path('lang/en/admin.php'));
+
+        $this->assertFileExists(resource_path('lang/zh-CN/admin.php'));
+
+        $this->assertFileExists(resource_path('lang/zh-CN/global.php'));
+    }
+}

+ 141 - 0
tests/browser-tests/Browser/MenuTest.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace Tests\Browser;
+
+use Dcat\Admin\Models\Menu;
+use Laravel\Dusk\Browser;
+use Tests\Browser\Components\Form\MenuEditForm;
+use Tests\Browser\Components\Form\Field\MultipleSelect2;
+use Tests\Browser\Components\Form\Field\Select2;
+use Tests\Browser\Pages\MenuEditPage;
+use Tests\Browser\Pages\MenuPage;
+use Tests\TestCase;
+
+/**
+ * @group menu
+ */
+class MenuTest extends TestCase
+{
+    public function testMenuIndex()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit(new MenuPage());
+        });
+    }
+
+    public function testAddMenu()
+    {
+        $this->browse(function (Browser $browser) {
+            $item = [
+                'parent_id'   => '0',
+                'title'       => 'Test',
+                'uri'         => 'test',
+                'icon'        => 'fa-user',
+                'roles'       => [1],
+                'permissions' => [4, 5],
+            ];
+
+            $browser
+                ->visit(new MenuPage())
+                ->newMenu($item)
+                ->waitForText(__('admin.save_succeeded'), 2);
+
+            $newMenuId = Menu::query()->orderByDesc('id')->first()->id;
+
+            // 检测是否写入数据库
+            $this->assertDatabase($newMenuId, $item);
+            $this->assertEquals(8, Menu::count());
+        });
+    }
+
+    public function testDeleteMenu()
+    {
+        $this->delete('admin/auth/menu/8');
+        $this->assertEquals(7, Menu::count());
+    }
+
+    public function testEditMenu()
+    {
+        $this->browse(function (Browser $browser) {
+            $browser->visit(new MenuEditPage(1))
+                ->type('title', 'blablabla')
+                ->press(__('admin.submit'))
+                ->waitForLocation(test_admin_path('auth/menu'), 2);
+
+            $this->seeInDatabase(config('admin.database.menu_table'), ['title' => 'blablabla'])
+                ->assertEquals(7, Menu::count());
+        });
+    }
+
+    public function testEditMenuParent()
+    {
+        $this->browse(function (Browser $browser) {
+            $id = 5;
+
+            $browser->visit(new MenuEditPage($id))
+                ->within(new Select2('select[name="parent_id"]'), function ($browser) use ($id) {
+                    $browser->choose($id);
+                })
+                ->press(__('admin.submit'))
+                ->waitForText('500 Internal Server Error', 2);
+        });
+    }
+
+    public function testQuickEditMenu()
+    {
+        $this->browse(function (Browser $browser) {
+            $id = 5;
+
+            $updates = [
+                'title'       => 'balabala',
+                'icon'        => 'fa-list',
+                'parent_id'   => 0,
+                'roles'       => 1,
+                'permissions' => [4, 5, 6],
+            ];
+
+            $browser->visit(new MenuPage())
+                ->within(sprintf('li[data-id="%d"]', $id), function (Browser $browser) {
+                    $browser->click('.tree-quick-edit');
+                })
+                ->whenAvailable('.layui-layer-page', function (Browser $browser) use ($id, $updates) {
+                    $browser->whenElementAvailable(new MenuEditForm($id), function (Browser $browser) use ($updates) {
+                        // 检测表单
+                        $browser->fill($updates);
+                    }, 3)
+                        ->assertSee(__('admin.edit'))
+                        ->click('div')
+                        ->whenElementAvailable(new MultipleSelect2('select[name="roles[]"]'), function (Browser $browser) {
+                            $browser->choose(1);
+                        }, 2)
+                        ->clickLink(__('admin.submit'));
+                }, 3)
+                ->waitForText(__('admin.update_succeeded'), 3)
+                ->waitForLocation(test_admin_path('auth/menu'), 2)
+                ->waitForText('balabala', 2);
+
+            // 检测是否写入数据库
+            $this->assertDatabase($id, $updates);
+        });
+    }
+
+    private function assertDatabase($id, $updates)
+    {
+        $roles = $updates['roles'];
+        $permissions = $updates['permissions'];
+
+        unset($updates['roles'], $updates['permissions']);
+
+        // 检测是否写入数据库
+        return $this
+            ->seeInDatabase(config('admin.database.menu_table'), $updates)
+            ->seeInDatabase(
+                config('admin.database.role_menu_table'),
+                ['role_id' => $roles, 'menu_id' => $id]
+            )
+            ->seeInDatabase(
+                config('admin.database.permission_menu_table'),
+                ['permission_id' => $permissions, 'menu_id' => $id]
+            );
+    }
+}

+ 55 - 0
tests/browser-tests/Browser/Pages/MenuEditPage.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Browser;
+use Tests\Browser\Components\Form\MenuEditForm;
+
+class MenuEditPage extends Page
+{
+    protected $id;
+
+    public function __construct($id)
+    {
+        $this->id = $id;
+    }
+
+    /**
+     * Get the URL for the page.
+     *
+     * @return string
+     */
+    public function url()
+    {
+        return test_admin_path("auth/menu/{$this->id}/edit");
+    }
+
+    /**
+     * Assert that the browser is on the page.
+     *
+     * @param  \Laravel\Dusk\Browser  $browser
+     * @return void
+     */
+    public function assert(Browser $browser)
+    {
+        $browser->assertSee(__('admin.menu'))
+            ->assertSee(__('admin.edit'))
+            ->assertSee(__('admin.list'))
+            ->assertSee(__('admin.delete'))
+            ->assertSee(__('admin.submit'))
+            ->assertSee(__('admin.reset'))
+            ->assert(new MenuEditForm($this->id));
+    }
+
+    /**
+     * Get the element shortcuts for the page.
+     *
+     * @return array
+     */
+    public function elements()
+    {
+        return [
+            '@form' => 'form[method="POST"]',
+        ];
+    }
+}

+ 90 - 0
tests/browser-tests/Browser/Pages/MenuPage.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Browser;
+use Tests\Browser\Components\Form\MenuCreationForm;
+
+class MenuPage extends Page
+{
+    /**
+     * Get the URL for the page.
+     *
+     * @return string
+     */
+    public function url()
+    {
+        return test_admin_path('auth/menu');
+    }
+
+    /**
+     * Assert that the browser is on the page.
+     *
+     * @param  \Laravel\Dusk\Browser  $browser
+     * @return void
+     */
+    public function assert(Browser $browser)
+    {
+        $browser->assertSee('Expand')
+            ->assertSee('Collapse')
+            ->assertSee('Save')
+            ->assertSee('New')
+            ->whenAvailable('@tree', function (Browser $browser) {
+                $browser->assertSee('Menu')
+                    ->assertSee('Index')
+                    ->assertSee('Admin')
+                    ->assertSee('Users')
+                    ->assertSee('Roles')
+                    ->assertSee('Permission')
+                    ->assertSee('Menu')
+                    ->assertSee('Operation log');
+            }, 1)
+            ->within('@form', function (Browser $browser) {
+                $browser->assertSee(__('admin.parent_id'))
+                    ->assertSee(__('admin.title'))
+                    ->assertSee(__('admin.icon'))
+                    ->assertSee(__('admin.uri'))
+                    ->assertSee(__('admin.roles'))
+                    ->assertSee(__('admin.permissions'))
+                    ->assertSee(__('admin.selectall'))
+                    ->assertSee(__('admin.expand'))
+                    ->assertSelected('parent_id', 0)
+                    ->hasInput('title')
+                    ->hasInput('icon')
+                    ->hasInput('uri')
+                    ->assertButtonEnabled('Submit')
+                    ->assertButtonEnabled('Reset');
+            });
+    }
+
+    /**
+     * 创建
+     *
+     * @param Browser $browser
+     * @param array $input
+     *
+     * @return Browser
+     */
+    public function newMenu(Browser $browser, array $input)
+    {
+        return $browser->within(new MenuCreationForm(), function (Browser $browser) use ($input) {
+            $browser->fill($input);
+
+            $browser->pressAndWaitFor(__('admin.submit'), 2);
+            $browser->waitForLocation($this->url(), 2);
+        });
+    }
+
+    /**
+     * Get the element shortcuts for the page.
+     *
+     * @return array
+     */
+    public function elements()
+    {
+        return [
+            '@tree' => '.dd',
+            '@form' => 'form[method="POST"]',
+        ];
+    }
+}

+ 20 - 0
tests/browser-tests/Browser/Pages/Page.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page as BasePage;
+
+abstract class Page extends BasePage
+{
+    /**
+     * Get the global element shortcuts for the site.
+     *
+     * @return array
+     */
+    public static function siteElements()
+    {
+        return [
+            '@element' => '#selector',
+        ];
+    }
+}

+ 2 - 0
tests/browser-tests/Browser/console/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 2 - 0
tests/browser-tests/Browser/screenshots/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 126 - 0
tests/browser-tests/BrowserExtension.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace Tests;
+
+use Facebook\WebDriver\Exception\TimeoutException;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Str;
+use Laravel\Dusk\Browser;
+use Laravel\Dusk\Component;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+trait BrowserExtension
+{
+    public function extendBrowser()
+    {
+        $functions = [
+            'whenTextAvailable' => function ($text, $callbackOrSeconds = null, $seconds = null) {
+                $callback = null;
+
+                if (is_callable($callbackOrSeconds)) {
+                    $callback = $callbackOrSeconds;
+                } elseif (is_int($callbackOrSeconds)) {
+                    $seconds = $callbackOrSeconds;
+                }
+
+                $text = Arr::wrap($text);
+                $message = $this->formatTimeOutMessage('Waited %s seconds for text', implode("', '", $text));
+
+                return $this->waitUsing($seconds, 100, function () use ($text, $callback)  {
+                    $results = Str::contains($this->resolver->findOrFail('')->getText(), $text);
+
+                    if ($results) {
+                        $callback && $callback($this);
+                    }
+
+                    return $results;
+                }, $message);
+            },
+
+            'whenElementAvailable' => function ($selector, $callbackOrSeconds = null, $seconds = null) {
+                $callback = null;
+                if (is_callable($callbackOrSeconds)) {
+                    $callback = $callbackOrSeconds;
+                } elseif (is_int($callbackOrSeconds)) {
+                    $seconds = $callbackOrSeconds;
+                }
+
+                return $this->whenAvailable($selector, function ($value) use ($callback) {
+                    $callback && $callback($value);
+                }, $seconds);
+            },
+
+            'hasInput' => function ($field) {
+                /* @var \Facebook\WebDriver\Remote\RemoteWebElement $element */
+                $this->resolver->resolveForTyping($field);
+
+                return $this;
+            },
+
+            'wait' => function ($seconds, \Closure $callback = null) {
+                try {
+                    $this->waitUsing($seconds, 200, function () {});
+                } catch (TimeoutException $e) {
+                    $callback && $callback();
+                }
+
+                return $this;
+            },
+
+            'assertHidden' => function ($selector) {
+                $fullSelector = $this->resolver->format($selector);
+
+                PHPUnit::assertTrue(
+                    $this->resolver->findOrFail($selector)->isDisplayed(),
+                    "Element [{$fullSelector}] is visible."
+                );
+
+                return $this;
+            },
+            'assert' => function (Component $component) {
+                return $this->with($component, function () {});
+            },
+        ];
+
+        foreach ($functions as $method => $callback) {
+            Browser::macro($method, $callback);
+        }
+    }
+
+    public function makeDelayBrowser($browser)
+    {
+        return new class($browser) {
+            protected $browser;
+
+            protected $callbacks = [];
+
+            public function __construct(Browser $browser)
+            {
+                $this->browser = $browser;
+            }
+
+            public function __call($method, $arguments = [])
+            {
+                $this->callbacks[] = [
+                    'method'    => $method,
+                    'arguments' => $arguments,
+                ];
+
+                return $this;
+            }
+
+            public function __invoke()
+            {
+                $browser = $this->browser;
+
+                foreach ($this->callbacks as $value) {
+                    $method = $value['method'];
+
+                    $browser = $browser->{$method}(...$value['arguments']);
+                }
+
+                return $browser;
+            }
+        };
+    }
+}

+ 21 - 0
tests/browser-tests/ChromeProcess.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace Tests;
+
+use Laravel\Dusk\Chrome\ChromeProcess as BaseChromeProcess;
+
+class ChromeProcess extends BaseChromeProcess
+{
+    public function __construct($driver = null)
+    {
+        parent::__construct($driver);
+
+        if ($this->onWindows()) {
+            $this->driver = realpath(__DIR__.'/resources/drivers/chromedriver-win.exe');
+        } elseif ($this->onMac()) {
+            $this->driver = realpath(__DIR__.'/resources/drivers/chromedriver-mac');
+        } else {
+            $this->driver = realpath(__DIR__.'/resources/drivers/chromedriver-linux');
+        }
+    }
+}

+ 86 - 0
tests/browser-tests/Controllers/DropdownController.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace Tests\Controllers;
+
+use Dcat\Admin\Admin;
+use Dcat\Admin\Layout\Content;
+use Dcat\Admin\Layout\Row;
+use Dcat\Admin\Widgets\Box;
+use Dcat\Admin\Widgets\Dropdown;
+use Illuminate\Routing\Controller;
+
+class DropdownController extends Controller
+{
+    protected $tian = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'];
+    protected $di = ['寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥', '子', '丑'];
+
+    public function index(Content $content)
+    {
+        return $content->header('Dropdown Menu')
+            ->row(function (Row $row) {
+                $row->column(3, $this->example1());
+                $row->column(3, $this->example2());
+                $row->column(3, $this->example3());
+            });
+    }
+
+    protected function example1()
+    {
+        $menu1 = Dropdown::make($this->tian)->button('天干');
+
+        $menu2 = Dropdown::make()
+            ->button('使用标题')
+            ->buttonClass('btn btn-sm btn-inverse')
+            ->options($this->tian, '天干')
+            ->options($this->di, '地支');
+
+        $menu3 = Dropdown::make([1, 2, 3, Dropdown::DIVIDER, 4, 5])->button('中间加分隔线');
+
+        return Box::make(
+            'Example1',
+            $menu1->render().' &nbsp; '.$menu2->render().' &nbsp; '.$menu3->render()
+        );
+    }
+
+    protected function example2()
+    {
+        $menu = Dropdown::make($this->tian);
+
+        $menu->map(function ($v, $k) {
+            if ($k === 7) {
+                $this->divider();
+            }
+            $k++;
+
+            return "{$k}. $v";
+        });
+
+        return Box::make('Example2', function () use ($menu) {
+            return "<div class='dropdown'><a class='btn no-shadow text-muted' data-toggle='dropdown' href='javascript:void(0)'><i class='ti-email'></i> 自定义按钮 </a>{$menu->render()}</div>";
+        });
+    }
+
+    protected function example3()
+    {
+        $menu1 = Dropdown::make()
+            ->options($this->tian, '天干')
+            ->options($this->di, '地支')
+            ->click()
+            ->buttonClass('btn btn-sm btn-light')
+            ->map(function ($v, $k) {
+                $k++;
+
+                return "<a class='test_item' data-id='$k', data-value='{$v}' data-test='Hello world.' href='javascript:void(0)'>{$k}. $v</a>";
+            });
+
+        Admin::script(
+            <<<'JS'
+$('.test_item').click(function () {
+    LA.info("Selected: " + JSON.stringify($(this).data()));
+});
+JS
+        );
+
+        return Box::make('Example3', $menu1);
+    }
+}

+ 62 - 0
tests/browser-tests/Controllers/ReportController.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Tests\Controllers;
+
+use App\Http\Controllers\Controller;
+use Dcat\Admin\Grid;
+use Dcat\Admin\Layout\Content;
+use Tests\Repositories\Report;
+
+class ReportController extends Controller
+{
+    public function index(Content $content)
+    {
+        return $content
+            ->header('报表')
+            ->body($this->grid());
+    }
+
+    protected function grid()
+    {
+        $grid = new Grid(new Report());
+
+        // 开启responsive插件
+        $grid->responsive();
+
+        $grid->disableActions();
+        $grid->disableBatchDelete();
+        $grid->disableCreateButton();
+
+        $grid->rowSelector()->style('success')->click();
+
+        $grid->combine('avgCost', ['avgMonthCost', 'avgQuarterCost', 'avgYearCost'])->responsive()->help('test');
+        $grid->combine('avgVist', ['avgMonthVist', 'avgQuarterVist', 'avgYearVist'])->responsive();
+        $grid->combine('top', ['topCost', 'topVist', 'topIncr'])->responsive()->style('color:#1867c0');
+
+        $grid->content->limit(50)->responsive();
+        $grid->cost->sortable()->responsive();
+        $grid->avgMonthCost->responsive();
+        $grid->avgQuarterCost->responsive()->setHeaderAttributes(['style' => 'color:#5b69bc']);
+        $grid->avgYearCost->responsive();
+        $grid->avgMonthVist->responsive();
+        $grid->avgQuarterVist->responsive();
+        $grid->avgYearVist->responsive();
+        $grid->incrs->hide();
+        $grid->avgVists->hide();
+        $grid->topCost->responsive();
+        $grid->topVist->responsive();
+        $grid->topIncr->responsive();
+        $grid->date->sortable()->responsive();
+
+        $grid->filter(function (Grid\Filter $filter) {
+            $filter->scope(1, admin_trans_field('month'))->where('date', 2019, '<=');
+            $filter->scope(2, admin_trans_label('quarter'))->where('date', 2019, '<=');
+            $filter->scope(3, admin_trans_label('year'))->where('date', 2019, '<=');
+
+            $filter->equal('content');
+            $filter->equal('cost');
+        });
+
+        return $grid;
+    }
+}

+ 211 - 0
tests/browser-tests/Controllers/UserController.php

@@ -0,0 +1,211 @@
+<?php
+
+namespace Tests\Controllers;
+
+use App\Http\Controllers\Controller;
+use Dcat\Admin\Controllers\HasResourceActions;
+use Dcat\Admin\Form;
+use Dcat\Admin\Grid;
+use Dcat\Admin\Layout\Content;
+use Dcat\Admin\Show;
+use Tests\Models\Tag;
+use Tests\Repositories\User;
+
+class UserController extends Controller
+{
+    use HasResourceActions;
+
+    /**
+     * Index interface.
+     *
+     * @return Content
+     */
+    public function index(Content $content)
+    {
+        $content->header('All users');
+        $content->description('description');
+
+        return $content->body($this->grid());
+    }
+
+    /**
+     * Edit interface.
+     *
+     * @param $id
+     *
+     * @return Content
+     */
+    public function edit(Content $content, $id)
+    {
+        $content->header('Edit user');
+        $content->description('description');
+
+        $content->body($this->form()->edit($id));
+
+        return $content;
+    }
+
+    /**
+     * Create interface.
+     *
+     * @return Content
+     */
+    public function create(Content $content)
+    {
+        $content->header('Create user');
+
+        return $content->body($this->form());
+    }
+
+    /**
+     * Show interface.
+     *
+     * @param mixed   $id
+     * @param Content $content
+     *
+     * @return Content
+     */
+    public function show($id, Content $content)
+    {
+        return $content
+            ->header('User')
+            ->description('Detail')
+            ->body($this->detail($id));
+    }
+
+    /**
+     * Make a grid builder.
+     *
+     * @return Grid
+     */
+    protected function grid()
+    {
+        $grid = new Grid(new User());
+
+        $grid->model()->with(['tags', 'profile']);
+
+        $grid->id('ID')->sortable();
+
+        $grid->username();
+        $grid->email();
+        $grid->mobile();
+        $grid->full_name();
+        $grid->avatar()->display(function ($avatar) {
+            return "<img src='{$avatar}' />";
+        });
+        $grid->column('profile.postcode', 'Post code');
+        $grid->column('profile.address');
+        $grid->column('profile.color');
+        $grid->column('profile.start_at', '开始时间');
+        $grid->column('profile.end_at', '结束时间');
+
+        $grid->column('column1_not_in_table')->display(function () {
+            return 'full name:'.$this->full_name;
+        });
+
+        $grid->column('column2_not_in_table')->display(function () {
+            return $this->email.'#'.$this->profile['color'];
+        });
+
+        $grid->tags()->display(function ($tags) {
+            $tags = collect($tags)->map(function ($tag) {
+                return "<code>{$tag['name']}</code>";
+            })->toArray();
+
+            return implode('', $tags);
+        });
+
+        $grid->created_at();
+        $grid->updated_at();
+
+        $grid->filter(function (Grid\Filter $filter) {
+            $filter->equal('id');
+            $filter->like('username');
+            $filter->like('email');
+            $filter->like('profile.postcode');
+            $filter->between('profile.start_at')->datetime();
+            $filter->between('profile.end_at')->datetime();
+        });
+
+        $grid->actions(function (Grid\Displayers\Actions $actions) {
+            if ($actions->getKey() % 2 == 0) {
+                $actions->append('<a href="/" class="btn btn-xs btn-danger">detail</a>');
+            }
+        });
+
+        return $grid;
+    }
+
+    /**
+     * Make a show builder.
+     *
+     * @param mixed $id
+     *
+     * @return Show
+     */
+    protected function detail($id)
+    {
+        return Show::make($id, new User(), function (Show $show) {
+            $show->id('ID');
+            $show->username();
+            $show->email;
+
+            $show->divider();
+
+            $show->full_name();
+            $show->field('profile.postcode');
+
+            $show->tags->json();
+        });
+    }
+
+    /**
+     * Make a form builder.
+     *
+     * @return Form
+     */
+    protected function form()
+    {
+        Form::extend('map', Form\Field\Map::class);
+        Form::extend('editor', Form\Field\Editor::class);
+
+        $form = new Form(new User());
+
+        $form->disableDeleteButton();
+
+        $form->display('id', 'ID');
+        $form->text('username');
+        $form->email('email')->rules('required');
+        $form->mobile('mobile');
+        $form->image('avatar')->help('上传头像', 'fa-image');
+        $form->ignore(['password_confirmation']);
+        $form->password('password')->rules('confirmed');
+        $form->password('password_confirmation');
+
+        $form->divider();
+
+        $form->text('profile.first_name');
+        $form->text('profile.last_name');
+        $form->text('profile.postcode')->help('Please input your postcode');
+        $form->textarea('profile.address')->rows(15);
+        $form->map('profile.latitude', 'profile.longitude', 'Position');
+        $form->color('profile.color');
+        $form->datetime('profile.start_at');
+        $form->datetime('profile.end_at');
+
+        $form->multipleSelect('tags', 'Tags')->options(Tag::all()->pluck('name', 'id'))->customFormat(function ($value) {
+            if (! $value) {
+                return [];
+            }
+
+            return array_column($value, 'id');
+        });
+
+        $form->display('created_at', 'Created At');
+        $form->display('updated_at', 'Updated At');
+
+        $form->html('<a html-field>html...</a>');
+
+        return $form;
+    }
+}

+ 113 - 0
tests/browser-tests/CreatesApplication.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace Tests;
+
+use Dcat\Admin\Models\Administrator;
+use Illuminate\Contracts\Console\Kernel;
+use Illuminate\Filesystem\Filesystem;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+trait CreatesApplication
+{
+    /**
+     * Creates the application.
+     *
+     * @return \Illuminate\Foundation\Application
+     */
+    public function createApplication()
+    {
+        $app = require __DIR__.'/../bootstrap/app.php';
+
+        $app->make(Kernel::class)->bootstrap();
+
+        return $app;
+    }
+
+
+    protected function boot()
+    {
+        $this->config();
+
+        $this->artisan('admin:publish');
+
+        Schema::defaultStringLength(191);
+
+        $this->artisan('admin:install');
+
+        $this->migrateTestTables();
+
+        if (file_exists($routes = admin_path('routes.php'))) {
+            require $routes;
+        }
+
+        require __DIR__.'/helpers.php';
+
+        require __DIR__.'/routes.php';
+
+        require __DIR__.'/resources/seeds/factory.php';
+
+        view()->addNamespace('admin-tests', __DIR__.'/resources/views');
+    }
+
+    protected function destory()
+    {
+        (new \CreateAdminTables())->down();
+
+        (new \CreateTestTables())->down();
+
+        DB::select("delete from `migrations` where `migration` = '2016_01_04_173148_create_admin_tables'");
+        DB::select("delete from `migrations` where `migration` = '2016_11_22_093148_create_test_tables'");
+
+        Artisan::call('migrate:rollback');
+    }
+
+    /**
+     * run package database migrations.
+     *
+     * @return void
+     */
+    public function migrateTestTables()
+    {
+        $fileSystem = new Filesystem();
+
+        $fileSystem->requireOnce(__DIR__.'/resources/migrations/2016_11_22_093148_create_test_tables.php');
+
+        (new \CreateTestTables())->up();
+    }
+
+    protected function config()
+    {
+        $adminConfig = require __DIR__.'/resources/config/admin.php';
+
+        $config = $this->app['config'];
+
+        $config->set('database.default', 'mysql');
+        $config->set('database.connections.mysql.host', env('MYSQL_HOST', 'localhost'));
+        $config->set('database.connections.mysql.database', 'laravel_dcat_admin_test');
+        $config->set('database.connections.mysql.username', env('MYSQL_USER', 'root'));
+        $config->set('database.connections.mysql.password', env('MYSQL_PASSWORD', ''));
+        $config->set('app.key', 'AckfSECXIvnK5r28GVIWUAxmbBSjTsmF');
+        $config->set('filesystems', require __DIR__.'/resources/config/filesystems.php');
+        $config->set('admin', $adminConfig);
+        $config->set('app.debug', true);
+
+        foreach (Arr::dot(Arr::get($adminConfig, 'auth'), 'auth.') as $key => $value) {
+            $this->app['config']->set($key, $value);
+        }
+    }
+
+    /**
+     * @return Administrator
+     */
+    protected function getUser()
+    {
+        if ($this->user) {
+            return $this->user;
+        }
+
+        return $this->user = Administrator::first();
+    }
+}

+ 106 - 0
tests/browser-tests/DuskTestCase.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace Tests;
+
+use Dcat\Admin\Models\Administrator;
+use Facebook\WebDriver\Chrome\ChromeOptions;
+use Facebook\WebDriver\Remote\DesiredCapabilities;
+use Facebook\WebDriver\Remote\RemoteWebDriver;
+use Laravel\Dusk\Browser;
+use Laravel\Dusk\TestCase as BaseTestCase;
+
+abstract class DuskTestCase extends BaseTestCase
+{
+    use CreatesApplication, BrowserExtension, InteractsWithDatabase;
+
+    /**
+     * @var Administrator
+     */
+    protected $user;
+
+    /**
+     * @var bool
+     */
+    protected $login = true;
+
+    public function login(Browser $browser)
+    {
+        $browser->loginAs($this->getUser(), 'admin');
+    }
+
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        $this->extendBrowser();
+
+        $this->boot();
+    }
+
+    public function tearDown(): void
+    {
+        $this->destory();
+
+        parent::tearDown();
+    }
+
+    /**
+     * Prepare for Dusk test execution.
+     *
+     * @beforeClass
+     * @return void
+     */
+    public static function prepare()
+    {
+        static::startChromeDriver();
+    }
+
+    /**
+     * @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver
+     *
+     * @return \Laravel\Dusk\Browser
+     */
+    protected function newBrowser($driver)
+    {
+        $browser = parent::newBrowser($driver)->resize(1566, 1080);
+
+        if ($this->login) {
+            $this->login($browser);
+        }
+
+        return $browser;
+    }
+
+    /**
+     * Create the RemoteWebDriver instance.
+     *
+     * @return \Facebook\WebDriver\Remote\RemoteWebDriver
+     */
+    protected function driver()
+    {
+        $options = (new ChromeOptions)->addArguments([
+            '--disable-gpu',
+            '--headless',
+            '--window-size=1920,1080',
+        ]);
+
+        return RemoteWebDriver::create(
+            'http://localhost:9515', DesiredCapabilities::chrome()->setCapability(
+                ChromeOptions::CAPABILITY_W3C, $options
+            )
+        );
+    }
+
+    /**
+     * Build the process to run the Chromedriver.
+     *
+     * @param  array  $arguments
+     * @return \Symfony\Component\Process\Process
+     *
+     * @throws \RuntimeException
+     */
+    protected static function buildChromeProcess(array $arguments = [])
+    {
+        return (new ChromeProcess(static::$chromeDriver))->toProcess($arguments);
+    }
+}

+ 89 - 0
tests/browser-tests/InteractsWithDatabase.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace Tests;
+
+trait InteractsWithDatabase
+{
+    /**
+     * Assert that a given where condition exists in the database.
+     *
+     * @param  string  $table
+     * @param  array  $data
+     * @param  string  $connection
+     * @return $this
+     */
+    protected function seeInDatabase($table, array $data, $connection = null)
+    {
+        $database = $this->app->make('db');
+
+        $connection = $connection ?: $database->getDefaultConnection();
+
+        $count = $database->connection($connection)->table($table)->where($data)->count();
+
+        $this->assertGreaterThan(0, $count, sprintf(
+            'Unable to find row in database table [%s] that matched attributes [%s].', $table, json_encode($data)
+        ));
+
+        return $this;
+    }
+
+    /**
+     * Assert that a given where condition does not exist in the database.
+     *
+     * @param  string  $table
+     * @param  array  $data
+     * @param  string  $connection
+     * @return $this
+     */
+    protected function missingFromDatabase($table, array $data, $connection = null)
+    {
+        return $this->notSeeInDatabase($table, $data, $connection);
+    }
+
+    /**
+     * Assert that a given where condition does not exist in the database.
+     *
+     * @param  string  $table
+     * @param  array  $data
+     * @param  string  $connection
+     * @return $this
+     */
+    protected function dontSeeInDatabase($table, array $data, $connection = null)
+    {
+        return $this->notSeeInDatabase($table, $data, $connection);
+    }
+
+    /**
+     * Assert that a given where condition does not exist in the database.
+     *
+     * @param  string  $table
+     * @param  array  $data
+     * @param  string  $connection
+     * @return $this
+     */
+    protected function notSeeInDatabase($table, array $data, $connection = null)
+    {
+        $database = $this->app->make('db');
+
+        $connection = $connection ?: $database->getDefaultConnection();
+
+        $count = $database->connection($connection)->table($table)->where($data)->count();
+
+        $this->assertEquals(0, $count, sprintf(
+            'Found unexpected records in database table [%s] that matched attributes [%s].', $table, json_encode($data)
+        ));
+
+        return $this;
+    }
+
+    /**
+     * Seed a given database connection.
+     *
+     * @param  string  $class
+     * @return void
+     */
+    public function seed($class = 'DatabaseSeeder')
+    {
+        $this->artisan('db:seed', ['--class' => $class]);
+    }
+}

+ 10 - 0
tests/browser-tests/Models/File.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Tests\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class File extends Model
+{
+    protected $table = 'test_files';
+}

+ 10 - 0
tests/browser-tests/Models/Image.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Tests\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Image extends Model
+{
+    protected $table = 'test_images';
+}

+ 22 - 0
tests/browser-tests/Models/MultipleImage.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class MultipleImage extends Model
+{
+    protected $table = 'test_multiple_images';
+
+    public function setPicturesAttribute($pictures)
+    {
+        if (is_array($pictures)) {
+            $this->attributes['pictures'] = json_encode($pictures);
+        }
+    }
+
+    public function getPicturesAttribute($pictures)
+    {
+        return json_decode($pictures, true) ?: [];
+    }
+}

+ 15 - 0
tests/browser-tests/Models/Profile.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Tests\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Profile extends Model
+{
+    protected $table = 'test_user_profiles';
+
+    public function user()
+    {
+        return $this->belongsTo(User::class, 'user_id');
+    }
+}

+ 15 - 0
tests/browser-tests/Models/Tag.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Tests\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class Tag extends Model
+{
+    protected $table = 'test_tags';
+
+    public function users()
+    {
+        return $this->belongsToMany(User::class, 'test_user_tags', 'tag_id', 'user_id');
+    }
+}

+ 28 - 0
tests/browser-tests/Models/Tree.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Tests\Models;
+
+use Encore\Admin\Traits\AdminBuilder;
+use Encore\Admin\Traits\ModelTree;
+use Illuminate\Database\Eloquent\Model;
+
+class Tree extends Model
+{
+    use AdminBuilder, ModelTree;
+
+    /**
+     * Create a new Eloquent model instance.
+     *
+     * @param array $attributes
+     */
+    public function __construct(array $attributes = [])
+    {
+        $connection = config('admin.database.connection') ?: config('database.default');
+
+        $this->setConnection($connection);
+
+        $this->setTable(config('admin.database.menu_table'));
+
+        parent::__construct($attributes);
+    }
+}

+ 40 - 0
tests/browser-tests/Models/User.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Tests\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class User extends Model
+{
+    protected $table = 'test_users';
+
+    protected $appends = ['full_name', 'position'];
+
+    public function profile()
+    {
+        return $this->hasOne(Profile::class, 'user_id');
+    }
+
+    public function getFullNameAttribute()
+    {
+        if (! $this->profile) {
+            return;
+        }
+
+        return "{$this->profile['first_name']} {$this->profile['last_name']}";
+    }
+
+    public function getPositionAttribute()
+    {
+        if (! $this->profile) {
+            return;
+        }
+
+        return "{$this->profile->latitude} {$this->profile->longitude}";
+    }
+
+    public function tags()
+    {
+        return $this->belongsToMany(Tag::class, 'test_user_tags', 'user_id', 'tag_id');
+    }
+}

+ 61 - 0
tests/browser-tests/Repositories/Report.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace Tests\Repositories;
+
+use Dcat\Admin\Grid;
+use Dcat\Admin\Repositories\Repository;
+use Faker\Factory;
+use Illuminate\Pagination\LengthAwarePaginator;
+
+class Report extends Repository
+{
+    public function get(Grid\Model $model)
+    {
+        $items = $this->fetch();
+
+        $paginator = new LengthAwarePaginator(
+            $items,
+            1000,
+            $model->getPerPage(), // 传入每页显示行数
+            $model->getCurrentPage() // 传入当前页码
+        );
+
+        // 必须设置链接
+        $paginator->setPath(\url()->current());
+
+        return $paginator;
+    }
+
+    /**
+     * 这里生成假数据演示报表功能.
+     *
+     * @return array
+     */
+    public function fetch()
+    {
+        $faker = Factory::create();
+
+        $data = [];
+
+        for ($i = 0; $i < 20; $i++) {
+            $data[] = [
+                'content'        => $faker->text,
+                'cost'           => $faker->randomFloat(),
+                'avgMonthCost'   => $faker->randomFloat(),
+                'avgQuarterCost' => $faker->randomFloat(),
+                'avgYearCost'    => $faker->randomFloat(),
+                'incrs'          => $faker->numberBetween(1, 999999999),
+                'avgMonthVist'   => $faker->numberBetween(1, 999999),
+                'avgQuarterVist' => $faker->numberBetween(1, 999999),
+                'avgYearVist'    => $faker->numberBetween(1, 999999),
+                'avgVists'       => $faker->numberBetween(1, 999999),
+                'topCost'        => $faker->numberBetween(1, 999999999),
+                'topVist'        => $faker->numberBetween(1, 9999990009),
+                'topIncr'        => $faker->numberBetween(1, 99999999),
+                'date'           => $faker->date(),
+            ];
+        }
+
+        return $data;
+    }
+}

+ 11 - 0
tests/browser-tests/Repositories/User.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace Tests\Repositories;
+
+use Dcat\Admin\Repositories\EloquentRepository;
+use Tests\Models\User as Model;
+
+class User extends EloquentRepository
+{
+    protected $eloquentClass = Model::class;
+}

+ 7 - 0
tests/browser-tests/TestCase.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Tests;
+
+abstract class TestCase extends DuskTestCase
+{
+}

+ 12 - 0
tests/browser-tests/helpers.php

@@ -0,0 +1,12 @@
+<?php
+
+if (! function_exists('test_admin_path')) {
+    function test_admin_path($path)
+    {
+        if (is_object($path)) {
+            return $path;
+        }
+
+        return admin_base_path($path);
+    }
+}

BIN
tests/browser-tests/resources/assets/test.jpg


+ 381 - 0
tests/browser-tests/resources/config/admin.php

@@ -0,0 +1,381 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin name
+    |--------------------------------------------------------------------------
+    |
+    | This value is the name of dcat-admin, This setting is displayed on the
+    | login page.
+    |
+    */
+    'name' => 'Dcat Admin',
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin logo
+    |--------------------------------------------------------------------------
+    |
+    | The logo of all admin pages. You can also set it as an image by using a
+    | `img` tag, eg '<img src="http://logo-url" alt="Admin logo">'.
+    |
+    */
+    'logo' => '<span>Dcat</span> Admin',
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin mini logo
+    |--------------------------------------------------------------------------
+    |
+    | The logo of all admin pages when the sidebar menu is collapsed. You can
+    | also set it as an image by using a `img` tag, eg
+    | '<img src="http://logo-url" alt="Admin logo">'.
+    |
+    */
+    'logo-mini' => 'Da',
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin route settings
+    |--------------------------------------------------------------------------
+    |
+    | The routing configuration of the admin page, including the path prefix,
+    | the controller namespace, and the default middleware. If you want to
+    | access through the root path, just set the prefix to empty string.
+    |
+    */
+    'route' => [
+
+        'prefix' => env('ADMIN_ROUTE_PREFIX', 'admin'),
+
+        'namespace' => 'App\\Admin\\Controllers',
+
+        'middleware' => ['web', 'admin'],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin install directory
+    |--------------------------------------------------------------------------
+    |
+    | The installation directory of the controller and routing configuration
+    | files of the administration page. The default is `app/Admin`, which must
+    | be set before running `artisan admin::install` to take effect.
+    |
+    */
+    'directory' => app_path('Admin'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin html title
+    |--------------------------------------------------------------------------
+    |
+    | Html title for all pages.
+    |
+    */
+    'title' => 'Admin',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Assets hostname
+    |--------------------------------------------------------------------------
+    |
+   */
+    'assets_server' => env('ADMIN_ASSETS_SERVER'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Cdn setting
+    |--------------------------------------------------------------------------
+    |
+   */
+    'cdn' => env('ADMIN_CDN', false),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Access via `https`
+    |--------------------------------------------------------------------------
+    |
+    | If your page is going to be accessed via https, set it to `true`.
+    |
+    */
+    'https' => env('ADMIN_HTTPS', false),
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin auth setting
+    |--------------------------------------------------------------------------
+    |
+    | Authentication settings for all admin pages. Include an authentication
+    | guard and a user provider setting of authentication driver.
+    |
+    | You can specify a controller for `login` `logout` and other auth routes.
+    |
+    */
+    'auth' => [
+        'enable' => true,
+
+        'controller' => Dcat\Admin\Controllers\AuthController::class,
+
+        'login_view' => 'admin::login',
+
+        'guard' => 'admin',
+
+        'guards' => [
+            'admin' => [
+                'driver'   => 'session',
+                'provider' => 'admin',
+            ],
+        ],
+
+        'providers' => [
+            'admin' => [
+                'driver' => 'eloquent',
+                'model'  => Dcat\Admin\Models\Administrator::class,
+            ],
+        ],
+
+        // Add "remember me" to login form
+        'remember' => true,
+
+        // All method to path like: auth/users/*/edit
+        // or specific method to path like: get:auth/users.
+        'except' => [
+            'auth/login',
+            'auth/logout',
+        ],
+
+    ],
+
+    'grid' => [
+
+        /*
+        |--------------------------------------------------------------------------
+        | The global Grid action display class.
+        |--------------------------------------------------------------------------
+        */
+        'grid_action_class' => Dcat\Admin\Grid\Displayers\DropdownActions::class,
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin helpers setting.
+    |--------------------------------------------------------------------------
+    */
+    'helpers' => [
+        'enable' => true,
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin permission setting
+    |--------------------------------------------------------------------------
+    |
+    | Permission settings for all admin pages.
+    |
+    */
+    'permission' => [
+        // Whether enable permission.
+        'enable' => true,
+
+        // All method to path like: auth/users/*/edit
+        // or specific method to path like: get:auth/users.
+        'except' => [
+            '/',
+            'auth/login',
+            'auth/logout',
+            'auth/setting',
+        ],
+
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin menu setting
+    |--------------------------------------------------------------------------
+    |
+    */
+    'menu' => [
+        'cache' => [
+            // enable cache or not
+            'enable' => true,
+            'store'  => 'file',
+        ],
+
+        // Whether enable menu bind to a permission.
+        'bind_permission' => true,
+
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin upload setting
+    |--------------------------------------------------------------------------
+    |
+    | File system configuration for form upload files and images, including
+    | disk and upload path.
+    |
+    */
+    'upload' => [
+
+        // Disk in `config/filesystem.php`.
+        'disk' => 'admin',
+
+        // Image and file upload path under the disk above.
+        'directory' => [
+            'image' => 'images',
+            'file'  => 'files',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | dcat-admin database settings
+    |--------------------------------------------------------------------------
+    |
+    | Here are database settings for dcat-admin builtin model & tables.
+    |
+    */
+    'database' => [
+
+        // Database connection for following tables.
+        'connection' => '',
+
+        // User tables and model.
+        'users_table' => 'admin_users',
+        'users_model' => Dcat\Admin\Models\Administrator::class,
+
+        // Role table and model.
+        'roles_table' => 'admin_roles',
+        'roles_model' => Dcat\Admin\Models\Role::class,
+
+        // Permission table and model.
+        'permissions_table' => 'admin_permissions',
+        'permissions_model' => Dcat\Admin\Models\Permission::class,
+
+        // Menu table and model.
+        'menu_table' => 'admin_menu',
+        'menu_model' => Dcat\Admin\Models\Menu::class,
+
+        // Pivot table for table above.
+        'operation_log_table'    => 'admin_operation_log',
+        'user_permissions_table' => 'admin_user_permissions',
+        'role_users_table'       => 'admin_role_users',
+        'role_permissions_table' => 'admin_role_permissions',
+        'role_menu_table'        => 'admin_role_menu',
+        'permission_menu_table'  => 'admin_permission_menu',
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | User operation log setting
+    |--------------------------------------------------------------------------
+    |
+    | By setting this option to open or close operation log in dcat-admin.
+    |
+    */
+    'operation_log' => [
+
+        'enable' => true,
+
+        // Only logging allowed methods in the list
+        'allowed_methods' => ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'],
+
+        // Routes that will not log to database.
+        // All method to path like: auth/logs/*/edit
+        // or specific method to path like: get:auth/logs.
+        'except' => [
+            'auth/logs*',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Admin map field provider
+    |--------------------------------------------------------------------------
+    |
+    | Supported: "tencent", "google", "yandex".
+    |
+    */
+    'map_provider' => 'google',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Application Skin
+    |--------------------------------------------------------------------------
+    |
+    | This value is the skin of admin pages.
+    | @see https://adminlte.io/docs/2.4/layout
+    |
+    | Supported:
+    |    "skin-blue-light", "skin-black", "skin-black-light".
+    |
+    */
+    'skin' => 'skin-black',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Application layout
+    |--------------------------------------------------------------------------
+    |
+    | This value is the layout of admin pages.
+    | @see https://adminlte.io/docs/2.4/layout
+    |
+    | Supported: "fixed", "layout-boxed", "layout-top-nav", "sidebar-collapse",
+    | "sidebar-mini".
+    |
+    */
+    'layout' => ['sidebar-mini', 'fixed'],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Login page background image
+    |--------------------------------------------------------------------------
+    |
+    | This value is used to set the background image of login page.
+    |
+    */
+    'login_background_image' => '',
+
+    /*
+    |--------------------------------------------------------------------------
+    | The exception handler class
+    |--------------------------------------------------------------------------
+    |
+    */
+    'exception_handler' => \Dcat\Admin\Exception\Handler::class,
+
+    /*
+    |--------------------------------------------------------------------------
+    | Enable default breadcrumb
+    |--------------------------------------------------------------------------
+    |
+    | Whether enable default breadcrumb for every page content.
+    */
+    'enable_default_breadcrumb' => true,
+
+    /*
+    |--------------------------------------------------------------------------
+    | Extension Directory
+    |--------------------------------------------------------------------------
+    |
+    | When you use command `php artisan admin:extend` to generate extensions,
+    | the extension files will be generated in this directory.
+    */
+    'extension_dir' => app_path('Admin/Extensions'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Settings for extensions.
+    |--------------------------------------------------------------------------
+    |
+    | You can find all available extensions here
+    | https://github.com/dcat-admin-extensions.
+    |
+    */
+    'extensions' => [
+
+    ],
+];

+ 95 - 0
tests/browser-tests/resources/config/filesystems.php

@@ -0,0 +1,95 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Default Filesystem Disk
+    |--------------------------------------------------------------------------
+    |
+    | Here you may specify the default filesystem disk that should be used
+    | by the framework. A "local" driver, as well as a variety of cloud
+    | based drivers are available for your choosing. Just store away!
+    |
+    | Supported: "local", "ftp", "s3", "rackspace"
+    |
+    */
+
+    'default' => 'public',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Default Cloud Filesystem Disk
+    |--------------------------------------------------------------------------
+    |
+    | Many applications store files both locally and in the cloud. For this
+    | reason, you may specify a default "cloud" driver here. This driver
+    | will be bound as the Cloud disk implementation in the container.
+    |
+    */
+
+    'cloud' => 's3',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Filesystem Disks
+    |--------------------------------------------------------------------------
+    |
+    | Here you may configure as many filesystem "disks" as you wish, and you
+    | may even configure multiple disks of the same driver. Defaults have
+    | been setup for each driver as an example of the required options.
+    |
+    */
+
+    'disks' => [
+
+        'local' => [
+            'driver' => 'local',
+            'root'   => storage_path('app'),
+        ],
+
+        'public' => [
+            'driver'     => 'local',
+            'root'       => storage_path('app/public'),
+            'visibility' => 'public',
+        ],
+
+        's3' => [
+            'driver' => 's3',
+            'key'    => 'your-key',
+            'secret' => 'your-secret',
+            'region' => 'your-region',
+            'bucket' => 'your-bucket',
+        ],
+
+        'admin' => [
+            'driver'     => 'local',
+            'root'       => public_path('uploads'),
+            'visibility' => 'public',
+            'url'        => 'http://localhost:8000/uploads/',
+        ],
+
+        'qiniu' => [
+            'driver'  => 'qiniu',
+            'domains' => [
+                'default' => 'of8kfibjo.bkt.clouddn.com', //你的七牛域名
+                'https'   => 'dn-yourdomain.qbox.me',         //你的HTTPS域名
+                'custom'  => 'static.abc.com',                //你的自定义域名
+            ],
+            'access_key' => 'tIyz5h5IDT1-PQS22iRrI4dCBEktWj76O-ls856K',  //AccessKey
+            'secret_key' => 'TCU2GuSlbzxKgnixYO_-pdo4odbXttm1RNNvEwSD',  //SecretKey
+            'bucket'     => 'laravel',  //Bucket名字
+            'notify_url' => '',  //持久化处理回调地址
+        ],
+
+        'aliyun' => [
+            'driver'     => 'oss',
+            'access_id'  => 'LTAIsOQNIDQN78Jr',
+            'access_key' => 'ChsYewaCxm1mi7AIBPRniuncEbFHNO',
+            'bucket'     => 'laravel-admin',
+            'endpoint'   => 'oss-cn-shanghai.aliyuncs.com',
+        ],
+
+    ],
+
+];

BIN
tests/browser-tests/resources/drivers/chromedriver-linux


BIN
tests/browser-tests/resources/drivers/chromedriver-mac


BIN
tests/browser-tests/resources/drivers/chromedriver-win.exe


+ 15 - 0
tests/browser-tests/resources/lang/en/global.php

@@ -0,0 +1,15 @@
+<?php
+
+return [
+    'fields' => [
+        'id' => 'ID',
+
+        'profile' => [
+            'address' => '地址',
+        ],
+
+    ],
+    'labels' => [
+        'List' => '列表',
+    ],
+];

+ 16 - 0
tests/browser-tests/resources/lang/en/user.php

@@ -0,0 +1,16 @@
+<?php
+
+return [
+    'fields' => [
+        'username' => '用户名',
+        'email'    => '邮箱',
+
+        'profile' => [
+            'postcode' => '邮政编码',
+        ],
+
+    ],
+    'labels' => [
+        'user' => '用户',
+    ],
+];

+ 98 - 0
tests/browser-tests/resources/migrations/2016_11_22_093148_create_test_tables.php

@@ -0,0 +1,98 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+
+class CreateTestTables extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('test_images', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('image1');
+            $table->string('image2');
+            $table->string('image3');
+            $table->string('image4');
+            $table->string('image5');
+            $table->string('image6');
+            $table->timestamps();
+        });
+
+        Schema::create('test_multiple_images', function (Blueprint $table) {
+            $table->increments('id');
+            $table->text('pictures');
+            $table->timestamps();
+        });
+
+        Schema::create('test_files', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('file1');
+            $table->string('file2');
+            $table->string('file3');
+            $table->string('file4');
+            $table->string('file5');
+            $table->string('file6');
+            $table->timestamps();
+        });
+
+        Schema::create('test_users', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('username');
+            $table->string('email');
+            $table->string('mobile')->nullable();
+            $table->string('avatar')->nullable();
+            $table->string('password');
+            $table->timestamps();
+        });
+
+        Schema::create('test_user_profiles', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('user_id');
+            $table->string('first_name')->nullable();
+            $table->string('last_name')->nullable();
+            $table->string('postcode')->nullable();
+            $table->string('address')->nullable();
+            $table->string('latitude')->nullable();
+            $table->string('longitude')->nullable();
+            $table->string('color')->nullable();
+            $table->timestamp('start_at')->nullable();
+            $table->timestamp('end_at')->nullable();
+
+            $table->timestamps();
+        });
+
+        Schema::create('test_tags', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name');
+            $table->timestamps();
+        });
+
+        Schema::create('test_user_tags', function (Blueprint $table) {
+            $table->integer('user_id');
+            $table->integer('tag_id');
+            $table->index(['user_id', 'tag_id']);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('test_images');
+        Schema::dropIfExists('test_multiple_images');
+        Schema::dropIfExists('test_files');
+        Schema::dropIfExists('test_users');
+        Schema::dropIfExists('test_user_profiles');
+        Schema::dropIfExists('test_tags');
+        Schema::dropIfExists('test_user_tags');
+    }
+}

+ 21 - 0
tests/browser-tests/resources/seeds/UserTableSeeder.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace Tests\Seeds;
+
+use Tests\Models\Profile;
+use Tests\Models\Tag;
+use Tests\Models\User;
+use Illuminate\Database\Seeder;
+
+class UserTableSeeder extends Seeder
+{
+    public function run()
+    {
+        factory(User::class, 50)
+            ->create()
+            ->each(function ($u) {
+                $u->profile()->save(factory(Profile::class)->make());
+                $u->tags()->saveMany(factory(Tag::class, 5)->make());
+            });
+    }
+}

+ 36 - 0
tests/browser-tests/resources/seeds/factory.php

@@ -0,0 +1,36 @@
+<?php
+
+use Faker\Generator as Faker;
+use Illuminate\Database\Eloquent\Factory;
+
+$factory = app(Factory::class);
+
+$factory->define(\Tests\Models\User::class, function (Faker $faker) {
+    return [
+        'username' => $faker->userName,
+        'email'    => $faker->email,
+        'mobile'   => $faker->phoneNumber,
+        'avatar'   => $faker->imageUrl(),
+        'password' => bcrypt('123456'),
+    ];
+});
+
+$factory->define(\Tests\Models\Profile::class, function (Faker $faker) {
+    return [
+        'first_name' => $faker->firstName,
+        'last_name'  => $faker->lastName,
+        'postcode'   => $faker->postcode,
+        'address'    => $faker->address,
+        'latitude'   => $faker->latitude,
+        'longitude'  => $faker->longitude,
+        'color'      => $faker->hexColor,
+        'start_at'   => $faker->dateTime,
+        'end_at'     => $faker->dateTime,
+    ];
+});
+
+$factory->define(\Tests\Models\Tag::class, function (Faker $faker) {
+    return [
+        'name' => $faker->word,
+    ];
+});

+ 1 - 0
tests/browser-tests/resources/views/test.blade.php

@@ -0,0 +1 @@
+<h1>Hello world</h1>

+ 10 - 0
tests/browser-tests/routes.php

@@ -0,0 +1,10 @@
+<?php
+
+Route::group([
+    'prefix'     => config('admin.route.prefix'),
+    'namespace'  => 'Tests\Controllers',
+    'middleware' => ['web', 'admin'],
+], function ($router) {
+    $router->resource('tests/users', UserController::class);
+    $router->resource('tests/report', ReportController::class);
+});

BIN
tests/resources/drivers/chromedriver-linux


BIN
tests/resources/drivers/chromedriver-mac


BIN
tests/resources/drivers/chromedriver-win.exe


+ 5 - 3
tests/routes.php

@@ -1,10 +1,12 @@
 <?php
 
+use Illuminate\Routing\Router;
+
 Route::group([
     'prefix'     => config('admin.route.prefix'),
     'namespace'  => 'Dcat\Admin\Tests\Controllers',
     'middleware' => ['web', 'admin'],
-], function ($router) {
-    $router->resource('tests/users', UserController::class);
-    $router->resource('tests/report', ReportController::class);
+], function (Router $router) {
+    $router->resource('tests/users', 'UserController');
+    $router->resource('tests/report', 'ReportController');
 });