diff --git a/src/resources/js/app.js b/src/resources/js/app.js index ebd77287..1caefa0b 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,469 +1,477 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() }) const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { - isAdmin: window.isAdmin + isAdmin: window.isAdmin, + appName: window.config['app.name'], + appUrl: window.config['app.url'], + themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh').then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, + logo(mode) { + let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' + + return `${this.appName}` + }, // Display "loading" overlay inside of the specified element addLoader(elem) { $(elem).css({position: 'relative'}).append($(loader).addClass('small')) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { let link = $(event.target).closest('tr').find('a')[0] if (link) { link.click() } } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog') // FIXME: Find a nicer way of doing this if (!dialog.length) { let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = $(form.$el) } dialog.on('shown.bs.modal', () => { dialog.find('input').first().focus() }).modal() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles let className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') $(document.body).removeClass().addClass(className) } } }) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { let error_msg let status = error.response ? error.response.status : 200 // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss index 7a85decd..ef782b9f 100644 --- a/src/resources/themes/meet.scss +++ b/src/resources/themes/meet.scss @@ -1,401 +1,451 @@ .meet-nickname { padding: 0; line-height: 2em; border-radius: 1em; max-width: 100%; white-space: nowrap; display: inline-flex; .icon { display: inline-block; min-width: 2em; } .content { order: 1; height: 2em; outline: none; overflow: hidden; text-overflow: ellipsis; &:not(:empty) { margin-left: 0.5em; padding-right: 0.5em; & + .icon { margin-right: -0.75em; } } } .self & { .content { &:focus { min-width: 0.5em; } } } & + .dropdown-menu { .permissions > label { margin: 0; padding-left: 3.75rem; } } } .meet-video { position: relative; background: $menu-bg-color; // Use flexbox for centering .watermark display: flex; align-items: center; justify-content: center; video { // To make object-fit:cover working we have to set the height in pixels // on the wrapper element. This is what javascript method will do. object-fit: cover; width: 100%; height: 100%; background: #000; & + .watermark { display: none; } } &.screen video { // For shared screen videos we use the original aspect ratio object-fit: scale-down; background: none; } &.fullscreen { video { // We don't want the video to be cut in fullscreen // This will preserve the aspect ratio of the video stream object-fit: contain; } } .watermark { color: darken($menu-bg-color, 20%); width: 50%; height: 50%; } .controls { position: absolute; bottom: 0; right: 0; margin: 0.5em; padding: 0 0.05em; line-height: 2em; border-radius: 1em; background: rgba(#000, 0.7); button { line-height: 2; border-radius: 50%; padding: 0; width: 2em; } } .status { position: absolute; bottom: 0; left: 0; margin: 0.5em; line-height: 2em; span { display: inline-block; color: #fff; border-radius: 50%; width: 2em; text-align: center; margin-right: 0.25em; } } .dropdown { position: absolute !important; top: 0; left: 0; right: 0; } .meet-nickname { margin: 0.5em; max-width: calc(100% - 1em); border: 0; &:not(:hover) { background-color: rgba(#fff, 0.8); } } &:not(.moderated):not(.self) .meet-nickname { .icon { display: none; } } } #meet-component { flex-grow: 1; display: flex; flex-direction: column; & + .filler { display: none; } } #app.meet { height: 100%; #meet-component { overflow: hidden; } + + nav.navbar { + display: none; + } } #meet-setup { max-width: 720px; } #meet-auth { margin-top: 2rem; margin-bottom: 2rem; flex: 1; } #meet-session-toolbar { display: flex; justify-content: center; } #meet-session-menu { + background: #f6f5f3; + border-radius: 0 2em 2em 0; + margin: 0.25em 0; + button { font-size: 1.3em; padding: 0 0.25em; margin: 0.5em; position: relative; .badge { font-size: 0.5em; position: absolute; right: -0.5em; &:empty { display: none; } } } } +#meet-session-logo { + background: #e9e7e2; + border-radius: 2em 0 0 2em; + margin: 0.25em 0; + display: flex; + flex-direction: column; + justify-content: center; + + img { + height: 1.25em; + padding: 0 1.5em; + } +} + #meet-session-layout { flex: 1; overflow: hidden; } #meet-publishers { height: 100%; position: relative; } #meet-subscribers { padding: 0.15em; overflow-y: auto; .meet-subscriber { margin: 0.15em; max-width: calc(25% - 0.4em); } // Language interpreters will be displayed as subscribers, but will have still // the video element that we will hide video { display: none; } } #meet-session { display: flex; flex-direction: column; flex: 1; overflow: hidden; & > div { display: flex; flex-wrap: wrap; width: 100%; &:empty { display: none; } } #meet-publishers:empty { & + #meet-subscribers { justify-content: center; align-content: center; flex: 1; } } #meet-publishers:not(:empty) { & + #meet-subscribers { max-height: 30%; } } } #meet-chat { width: 0; display: none; flex-direction: column; &.open { width: 30%; display: flex !important; .mobile & { width: 100%; z-index: 1; background: $body-bg; } } .chat { flex: 1; overflow-y: auto; scrollbar-width: thin; } .message { margin: 0 0.5em 0.5em 0.5em; padding: 0.25em 0.5em; border-radius: 1em; background: $menu-bg-color; overflow-wrap: break-word; &.self { background: lighten($main-color, 30%); } &:last-child { margin-bottom: 0; } } .nickname { font-size: 80%; color: $secondary; text-align: right; } // TODO: mobile mode } #meet-queue { display: none; width: 150px; .head { text-align: center; font-size: 1.75em; background: $menu-bg-color; } .dropdown { margin: 0.2em; display: flex; position: relative; transition: top 10s ease; top: 15em; .meet-nickname { width: 100%; } &.widdle { top: 0; animation-name: wiggle; animation-duration: 1s; animation-timing-function: ease-in-out; animation-iteration-count: 8; } } } @keyframes wiggle { 0% { transform: rotate(0deg); } 25% { transform: rotate(10deg); } 50% { transform: rotate(0deg); } 75% { transform: rotate(-10deg); } 100% { transform: rotate(0deg); } } .media-setup-form { .input-group svg { width: 1em; } } .media-setup-preview { display: flex; position: relative; video { width: 100%; background: #000; } .volume { height: 50%; position: absolute; bottom: 1em; right: 2em; width: 0.5em; background: rgba(0, 0, 0, 0.5); .bar { width: 100%; position: absolute; bottom: 0; } #media-setup-dialog & { right: 1em; } } } .toast.join-request { .toast-header { color: #eee; } .toast-body { display: flex; } .picture { margin-right: 1em; img { width: 64px; height: 64px; border: 1px solid #555; border-radius: 50%; object-fit: cover; } } .content { flex: 1; } } + +@include media-breakpoint-down(sm) { + #meet-session-logo { + display: none; + } + + #meet-session-menu { + background: transparent; + } + + #app.meet { + #footer-menu { + display: block !important; + height: 2em; + padding: 0; + + .navbar-brand { + padding: 0; + margin: 0; + } + + img { + width: auto !important; + height: 1em; + } + } + } +} diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index f5c9249b..377d731f 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,93 +1,92 @@ diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index 0754cdf8..5903d93e 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,82 +1,81 @@ diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue index 639cc585..67748bc8 100644 --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -1,759 +1,762 @@ diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue index f6a3d99e..a589ec1e 100644 --- a/src/resources/vue/User/ProfileDelete.vue +++ b/src/resources/vue/User/ProfileDelete.vue @@ -1,57 +1,56 @@ diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue index c94beb1c..8ebd6285 100644 --- a/src/resources/vue/Widgets/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -1,108 +1,99 @@ diff --git a/src/tests/Browser/Components/Menu.php b/src/tests/Browser/Components/Menu.php index 82833b50..2ac83067 100644 --- a/src/tests/Browser/Components/Menu.php +++ b/src/tests/Browser/Components/Menu.php @@ -1,113 +1,113 @@ mode = $mode; } /** * Get the root selector for the component. * * @return string */ public function selector() { return '#' . $this->mode . '-menu'; } /** * Assert that the browser page contains the component. * * @param \Tests\Browser $browser * * @return void */ public function assert($browser) { $browser->assertVisible($this->selector()); } /** * Assert that menu contains only specified menu items. * * @param \Tests\Browser $browser * @param array $items List of menu items * @param string $active Expected active item * * @return void */ public function assertMenuItems($browser, array $items, string $active = null) { // On mobile the links are not visible, show them first (wait for transition) - if ($browser->isPhone()) { + if (!$browser->isDesktop()) { $browser->click('@toggler')->waitFor('.navbar-collapse.show'); } foreach ($items as $item) { $browser->assertVisible('.link-' . $item); } // Check number of items, to make sure there's no extra items PHPUnit::assertCount(count($items), $browser->elements('li')); if ($active) { $browser->assertPresent(".link-{$active}.active"); } - if ($browser->isPhone()) { + if (!$browser->isDesktop()) { $browser->click('@toggler')->waitUntilMissing('.navbar-collapse.show'); } } /** * Click menu link. * * @param \Tests\Browser $browser The browser object * @param string $name Menu item name * * @return void */ public function clickMenuItem($browser, string $name) { // On mobile the links are not visible, show them first (wait for transition) if ($browser->isPhone()) { $browser->click('@toggler')->waitFor('.navbar-collapse.show'); } $browser->click('.link-' . $name); if ($browser->isPhone()) { $browser->waitUntilMissing('.navbar-collapse.show'); } } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { $selector = $this->selector(); return [ '@list' => ".navbar-nav", '@brand' => ".navbar-brand", '@toggler' => ".navbar-toggler", ]; } } diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php index 42346a17..5acc6848 100644 --- a/src/tests/Browser/Meet/RoomSetupTest.php +++ b/src/tests/Browser/Meet/RoomSetupTest.php @@ -1,569 +1,569 @@ setupTestRoom(); } public function tearDown(): void { $this->resetTestRoom(); parent::tearDown(); } /** * Test non-existing room * * @group openvidu */ public function testRoomNonExistingRoom(): void { $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('unknown')) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } // FIXME: Maybe it would be better to just display the usual 404 Not Found error page? $browser->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-status-message', "The room does not exist.") ->assertButtonDisabled('@setup-button'); }); } /** * Test the room setup page * * @group openvidu */ public function testRoomSetup(): void { $this->browse(function (Browser $browser) { $browser->visit(new RoomPage('john')) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } // Note: I've found out that if I have another Chrome instance running // that uses media, here the media devices will not be available // TODO: Test enabling/disabling cam/mic in the setup widget $browser->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-title', 'Set up your session') ->assertVisible('@setup-video') ->assertVisible('@setup-form .input-group:nth-child(1) svg') ->assertAttribute('@setup-form .input-group:nth-child(1) .input-group-text', 'title', 'Microphone') ->assertVisible('@setup-mic-select') ->assertVisible('@setup-form .input-group:nth-child(2) svg') ->assertAttribute('@setup-form .input-group:nth-child(2) .input-group-text', 'title', 'Camera') ->assertVisible('@setup-cam-select') ->assertVisible('@setup-form .input-group:nth-child(3) svg') ->assertAttribute('@setup-form .input-group:nth-child(3) .input-group-text', 'title', 'Nickname') ->assertValue('@setup-nickname-input', '') ->assertAttribute('@setup-nickname-input', 'placeholder', 'Your name') ->assertMissing('@setup-password-input') ->assertSeeIn( '@setup-status-message', "The room is closed. Please, wait for the owner to start the session." ) ->assertSeeIn('@setup-button', "I'm the owner"); }); } /** * Test two users in a room (joining/leaving and some basic functionality) * * @group openvidu * @depends testRoomSetup */ public function testTwoUsersInARoom(): void { $this->browse(function (Browser $browser, Browser $guest) { // In one browser window act as a guest $guest->visit(new RoomPage('john')) ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertSeeIn( '@setup-status-message', "The room is closed. Please, wait for the owner to start the session." ) ->assertSeeIn('@setup-button', "I'm the owner"); // In another window join the room as the owner (authenticate) $browser->on(new RoomPage('john')) ->assertSeeIn('@setup-button', "I'm the owner") ->clickWhenEnabled('@setup-button') ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@setup-form') ->assertVisible('@login-form') ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') - ->assertMissing('@login-form') + ->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']); + }); + + if ($browser->isDesktop()) { + $browser->within(new Menu('footer'), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'dashboard', 'logout']); + }); + } + + $browser->assertMissing('@login-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.") ->assertSeeIn('@setup-button', "JOIN") ->type('@setup-nickname-input', 'john') // Join the room (click the button twice, to make sure it does not // produce redundant participants/subscribers in the room) ->clickWhenEnabled('@setup-button') ->pause(10) ->click('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('div.meet-video.self', function (Browser $browser) { $browser->waitFor('video') ->assertSeeIn('.meet-nickname', 'john') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) - ->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']); - }); + ->assertMissing('#header-menu'); - if ($browser->isDesktop()) { - $browser->within(new Menu('footer'), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'dashboard', 'logout']); - }); + if (!$browser->isPhone()) { + $browser->assertMissing('#footer-menu'); + } else { + $browser->assertVisible('#footer-menu'); } // After the owner "opened the room" guest should be able to join $guest->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') //->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('div.meet-video.self', function (Browser $browser) { $browser->waitFor('video') ->assertVisible('.meet-nickname') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertMissing('.status .status-video'); }) ->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) { $browser->waitFor('video') ->assertSeeIn('.meet-nickname', 'john') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) - ->assertElementsCount('@session div.meet-video', 2) - ->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'signup', 'login']); - }); - - if ($guest->isDesktop()) { - $guest->within(new Menu('footer'), function ($browser) { - $browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'signup', 'login']); - }); - } + ->assertElementsCount('@session div.meet-video', 2); // Check guest's elements in the owner's window $browser ->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) { $browser->waitFor('video') ->assertVisible('.meet-nickname') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertMissing('.controls button.link-setup') ->assertVisible('.status .status-audio') ->assertMissing('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2); // Test leaving the room // Guest is leaving $guest->click('@menu button.link-logout') - ->waitForLocation('/login'); + ->waitForLocation('/login') + ->assertVisible('#header-menu'); // Expect the participant removed from other users windows $browser->waitUntilMissing('@session div.meet-video:not(.self)'); // Join the room as guest again $guest->visit(new RoomPage('john')) ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->assertMissing('@setup-status-message') ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') //->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session'); // Leave the room as the room owner // TODO: Test leaving the room by closing the browser window, // it should not destroy the session $browser->click('@menu button.link-logout') ->waitForLocation('/dashboard'); // Expect other participants be informed about the end of the session $guest->with(new Dialog('#leave-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Room closed') ->assertSeeIn('@body', "The session has been closed by the room owner.") ->assertMissing('@button-cancel') ->assertSeeIn('@button-action', 'Close') ->click('@button-action'); }) ->assertMissing('#leave-dialog') ->waitForLocation('/login'); }); } /** * Test two subscribers-only users in a room * * @group openvidu * @depends testTwoUsersInARoom */ public function testSubscribers(): void { $this->browse(function (Browser $browser, Browser $guest) { // Join the room as the owner $browser->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->type('@setup-nickname-input', 'john') ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) { $browser->assertSeeIn('.meet-nickname', 'john'); }) ->assertElementsCount('@session div.meet-video', 0) ->assertElementsCount('@session video', 0) ->assertElementsCount('@session .meet-subscriber', 1) ->assertToolbar([ 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, 'options' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, ]); // After the owner "opened the room" guest should be able to join // In one browser window act as a guest $guest->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") // Join the room, disable cam/mic ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) { $browser->assertVisible('.meet-nickname'); }) ->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) { $browser->assertSeeIn('.meet-nickname', 'john'); }) ->assertElementsCount('@session div.meet-video', 0) ->assertElementsCount('@session video', 0) ->assertElementsCount('@session div.meet-subscriber', 2) ->assertToolbar([ 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED, 'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, ]); // Check guest's elements in the owner's window $browser ->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) { $browser->assertVisible('.meet-nickname'); }) ->assertElementsCount('@session div.meet-video', 0) ->assertElementsCount('@session video', 0) ->assertElementsCount('@session .meet-subscriber', 2); // Test leaving the room // Guest is leaving $guest->click('@menu button.link-logout') ->waitForLocation('/login'); // Expect the participant removed from other users windows $browser->waitUntilMissing('@session .meet-subscriber:not(.self)'); }); } /** * Test demoting publisher to a subscriber * * @group openvidu * @depends testSubscribers */ public function testDemoteToSubscriber(): void { $this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) { // Join the room as the owner $browser->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->type('@setup-nickname-input', 'john') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->waitFor('@session video'); // In one browser window act as a guest $guest1->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->waitFor('div.meet-video.self') ->waitFor('div.meet-video:not(.self)') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0) // assert there's no moderator-related features for this guess available ->click('@session .meet-video.self .meet-nickname') ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { $browser->assertMissing('.permissions'); }) ->click('@session .meet-video:not(.self) .meet-nickname') ->pause(50) ->assertMissing('.dropdown-menu'); // Demote the guest to a subscriber $browser ->waitFor('div.meet-video.self') ->waitFor('div.meet-video:not(.self)') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session .meet-subscriber', 0) ->click('@session .meet-video:not(.self) .meet-nickname') ->whenAvailable('@session .meet-video:not(.self) .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitUntilMissing('@session .meet-video:not(.self)') ->waitFor('@session div.meet-subscriber') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 1); $guest1 ->waitUntilMissing('@session .meet-video.self') ->waitFor('@session div.meet-subscriber') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 1); // Join as another user to make sure the role change is propagated to new connections $guest2->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form') ->waitFor('div.meet-subscriber:not(.self)') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 2) ->click('@toolbar .link-logout'); // Promote the guest back to a publisher $browser ->click('@session .meet-subscriber .meet-nickname') ->whenAvailable('@session .meet-subscriber .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->assertNotChecked('.action-role-publisher input') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitFor('@session .meet-video:not(.self) video') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0); $guest1 ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Media setup') ->click('@button-action'); }) ->waitFor('@session .meet-video.self') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0); // Demote the owner to a subscriber $browser ->click('@session .meet-video.self .meet-nickname') ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->assertChecked('.action-role-publisher input') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitUntilMissing('@session .meet-video.self') ->waitFor('@session div.meet-subscriber.self') ->assertElementsCount('@session div.meet-video', 1) ->assertElementsCount('@session video', 1) ->assertElementsCount('@session div.meet-subscriber', 1); // Promote the owner to a publisher $browser ->click('@session .meet-subscriber.self .meet-nickname') ->whenAvailable('@session .meet-subscriber.self .dropdown-menu', function (Browser $browser) { $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') ->assertNotChecked('.action-role-publisher input') ->click('.action-role-publisher') ->waitUntilMissing('.dropdown-menu'); }) ->waitUntilMissing('@session .meet-subscriber.self') ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Media setup') ->click('@button-action'); }) ->waitFor('@session div.meet-video.self') ->assertElementsCount('@session div.meet-video', 2) ->assertElementsCount('@session video', 2) ->assertElementsCount('@session div.meet-subscriber', 0); }); } /** * Test the media setup dialog * * @group openvidu * @depends testDemoteToSubscriber */ public function testMediaSetupDialog(): void { $this->browse(function (Browser $browser, $guest) { // Join the room as the owner $browser->visit(new RoomPage('john')) ->waitFor('@setup-form') ->waitUntilMissing('@setup-status-message.loading') ->waitFor('@setup-status-message') ->type('@setup-nickname-input', 'john') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form'); // In one browser window act as a guest $guest->visit(new RoomPage('john')) ->waitUntilMissing('@setup-status-message', 10) ->assertSeeIn('@setup-button', "JOIN") ->select('@setup-mic-select', '') ->select('@setup-cam-select', '') ->clickWhenEnabled('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form'); $browser->waitFor('@session video') ->click('.controls button.link-setup') ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Media setup') ->assertVisible('form video') ->assertVisible('form > div:nth-child(1) video') ->assertVisible('form > div:nth-child(1) .volume') ->assertVisible('form > div:nth-child(2) svg') ->assertAttribute('form > div:nth-child(2) .input-group-text', 'title', 'Microphone') ->assertVisible('form > div:nth-child(2) select') ->assertVisible('form > div:nth-child(3) svg') ->assertAttribute('form > div:nth-child(3) .input-group-text', 'title', 'Camera') ->assertVisible('form > div:nth-child(3) select') ->assertMissing('@button-cancel') ->assertSeeIn('@button-action', 'Close') ->click('@button-action'); }) ->assertMissing('#media-setup-dialog') // Test mute audio and video ->click('.controls button.link-setup') ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) { $browser->select('form > div:nth-child(2) select', '') ->select('form > div:nth-child(3) select', '') ->click('@button-action'); }) ->assertMissing('#media-setup-dialog') ->assertVisible('@session .meet-video .status .status-audio') ->assertVisible('@session .meet-video .status .status-video'); $guest->waitFor('@session video') ->assertVisible('@session .meet-video .status .status-audio') ->assertVisible('@session .meet-video .status .status-video'); }); } }