Merge pull request #3877 from NoelDeMartin/MOBILE-3947

MOBILE-3947: Fix application startup and behat gestures
main
Dani Palou 2023-12-11 08:05:11 +01:00 committed by GitHub
commit 3b1f1fb638
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 114 additions and 64 deletions

View File

@ -16,7 +16,7 @@ on:
required: true required: true
default: 'https://github.com/moodle/moodle' default: 'https://github.com/moodle/moodle'
pull_request: pull_request:
branches: [ main, v*.x ] branches: [ main, ionic7, v*.x ]
jobs: jobs:
behat: behat:
@ -24,7 +24,7 @@ jobs:
env: env:
MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: '8.0' MOODLE_DOCKER_PHP_VERSION: '8.1'
MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }} MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }}
MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }} MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }}
BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }} BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }}

View File

@ -214,10 +214,7 @@ class behat_app extends behat_app_helper {
return true; return true;
}); });
$this->wait_for_pending_js(); $this->wait_animations_done();
// Wait scroll animation to finish.
$this->getSession()->wait(300);
} }
/** /**
@ -263,10 +260,7 @@ class behat_app extends behat_app_helper {
throw new DriverException('Error when swiping - ' . $result); throw new DriverException('Error when swiping - ' . $result);
} }
$this->wait_for_pending_js(); $this->wait_animations_done();
// Wait swipe animation to finish.
$this->getSession()->wait(300);
} }
/** /**
@ -689,10 +683,7 @@ class behat_app extends behat_app_helper {
return true; return true;
}); });
$this->wait_for_pending_js(); $this->wait_animations_done();
// Wait for UI to settle after refreshing.
$this->getSession()->wait(300);
if (is_null($locator)) { if (is_null($locator)) {
return; return;

View File

@ -641,4 +641,15 @@ EOF;
return $text; return $text;
} }
} }
/**
* Wait until animations have finished.
*/
protected function wait_animations_done() {
$this->wait_for_pending_js();
// Ideally, we wouldn't wait a fixed amount of time. But it is not straightforward to wait for animations
// to finish, so for now we'll just wait 300ms.
usleep(300000);
}
} }

View File

@ -27,6 +27,8 @@ import {
import { CoreArray } from '@singletons/array'; import { CoreArray } from '@singletons/array';
const modulesRoutes: WeakMap<InjectionToken<unknown>, ModuleRoutes> = new WeakMap();
/** /**
* Build app routes. * Build app routes.
* *
@ -175,6 +177,10 @@ export function conditionalRoutes(routes: Routes, condition: () => boolean): Rou
* @returns Routes. * @returns Routes.
*/ */
export function resolveModuleRoutes(injector: Injector, token: InjectionToken<ModuleRoutesConfig[]>): ModuleRoutes { export function resolveModuleRoutes(injector: Injector, token: InjectionToken<ModuleRoutesConfig[]>): ModuleRoutes {
if (modulesRoutes.has(token)) {
return modulesRoutes.get(token) as ModuleRoutes;
}
const configs = injector.get(token, []); const configs = injector.get(token, []);
const routes = configs.map(config => { const routes = configs.map(config => {
if (Array.isArray(config)) { if (Array.isArray(config)) {
@ -190,10 +196,14 @@ export function resolveModuleRoutes(injector: Injector, token: InjectionToken<Mo
}; };
}); });
return { const moduleRoutes = {
children: CoreArray.flatten(routes.map(r => r.children)), children: CoreArray.flatten(routes.map(r => r.children)),
siblings: CoreArray.flatten(routes.map(r => r.siblings)), siblings: CoreArray.flatten(routes.map(r => r.siblings)),
}; };
modulesRoutes.set(token, moduleRoutes);
return moduleRoutes;
} }
export const APP_ROUTES = new InjectionToken('APP_ROUTES'); export const APP_ROUTES = new InjectionToken('APP_ROUTES');

View File

@ -1,5 +1,4 @@
<swiper-container #swiperRef *ngIf="loaded" (slidechangetransitionstart)="slideWillChange()" (slidechangetransitionend)="slideDidChange()" <swiper-container #swiperRef *ngIf="loaded">
[initialSlide]="options.initialSlide" [runCallbacksOnInit]="options.runCallbacksOnInit">
<swiper-slide *ngFor="let item of items; index as index" [attr.aria-hidden]="!isActive(index)"> <swiper-slide *ngFor="let item of items; index as index" [attr.aria-hidden]="!isActive(index)">
<ng-container *ngIf="template" [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{item: item, active: isActive(index)}" /> <ng-container *ngIf="template" [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{item: item, active: isActive(index)}" />
</swiper-slide> </swiper-slide>

View File

@ -50,6 +50,13 @@ export class CoreSwipeSlidesComponent<Item = unknown> implements OnChanges, OnDe
if (swiperRef?.nativeElement?.swiper) { if (swiperRef?.nativeElement?.swiper) {
this.swiper = swiperRef.nativeElement.swiper as Swiper; this.swiper = swiperRef.nativeElement.swiper as Swiper;
if (this.options.initialSlide) {
this.swiper.slideTo(this.options.initialSlide, 0, this.options.runCallbacksOnInit);
}
this.swiper.on('slideChangeTransitionStart', () => this.slideWillChange());
this.swiper.on('slideChangeTransitionEnd', () => this.slideDidChange());
Object.keys(this.options).forEach((key) => { Object.keys(this.options).forEach((key) => {
if (this.swiper) { if (this.swiper) {
this.swiper.params[key] = this.options[key]; this.swiper.params[key] = this.options[key];

View File

@ -14,8 +14,15 @@
import envJson from '@/assets/env.json'; import envJson from '@/assets/env.json';
import { EnvironmentConfig } from '@/types/config'; import { EnvironmentConfig } from '@/types/config';
import { InjectionToken } from '@angular/core';
import { CoreBrowser } from '@singletons/browser'; import { CoreBrowser } from '@singletons/browser';
/**
* Injection token used for dependencies marked as optional that will never
* be resolved by Angular injectors.
*/
export const NULL_INJECTION_TOKEN = new InjectionToken('null');
/** /**
* Context levels enumeration. * Context levels enumeration.
*/ */

View File

@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { CoreConstants } from '@/core/constants';
import { AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; import { AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core';
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
import { CoreSwipeNavigationTourComponent } from '@components/swipe-navigation-tour/swipe-navigation-tour'; import { CoreSwipeNavigationTourComponent } from '@components/swipe-navigation-tour/swipe-navigation-tour';
@ -42,6 +43,11 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy {
constructor(el: ElementRef) { constructor(el: ElementRef) {
this.element = el.nativeElement; this.element = el.nativeElement;
if (CoreConstants.enableDevTools()) {
this.element['swipeNavigation'] = this;
this.element.classList.add('uses-swipe-navigation');
}
} }
get enabled(): boolean { get enabled(): boolean {

View File

@ -36,10 +36,6 @@ function buildRoutes(injector: Injector): Routes {
path: '', path: '',
component: CoreMainMenuPage, component: CoreMainMenuPage,
children: [ children: [
{
path: '',
pathMatch: 'full',
},
{ {
path: CoreMainMenuHomeHandlerService.PAGE_NAME, path: CoreMainMenuHomeHandlerService.PAGE_NAME,
loadChildren: () => import('./mainmenu-home-lazy.module').then(m => m.CoreMainMenuHomeLazyModule), loadChildren: () => import('./mainmenu-home-lazy.module').then(m => m.CoreMainMenuHomeLazyModule),

View File

@ -18,24 +18,64 @@ import { Route, Routes } from '@angular/router';
import { ModuleRoutesConfig, resolveModuleRoutes } from '@/app/app-routing.module'; import { ModuleRoutesConfig, resolveModuleRoutes } from '@/app/app-routing.module';
const MAIN_MENU_TAB_ROUTES = new InjectionToken('MAIN_MENU_TAB_ROUTES'); const MAIN_MENU_TAB_ROUTES = new InjectionToken('MAIN_MENU_TAB_ROUTES');
const modulesPaths: Record<string, Set<string>> = {};
/**
* Get the name of the module the injector belongs to.
*
* @param injector Injector.
* @returns Injector module name.
*/
function getInjectorModule(injector: Injector): string | null {
if (!('source' in injector) || typeof injector.source !== 'string') {
return null;
}
// Get module name from R3Injector source.
// See https://github.com/angular/angular/blob/16.2.0/packages/core/src/di/r3_injector.ts#L161C8
return injector.source;
}
/**
* Get module paths.
*
* @param injector Injector.
* @returns Module paths.
*/
function getModulePaths(injector: Injector): Set<string> | null {
const module = getInjectorModule(injector);
if (!module) {
return null;
}
return modulesPaths[module] ??= new Set();
}
/** /**
* Build module routes. * Build module routes.
* *
* @param injector Injector. * @param injector Injector.
* @param mainRoute Main route.
* @returns Routes. * @returns Routes.
*/ */
export function buildTabMainRoutes(injector: Injector, mainRoute: Route): Routes { export function buildTabMainRoutes(injector: Injector, mainRoute: Route): Routes {
const path = mainRoute.path ?? '';
const modulePaths = getModulePaths(injector);
const isRootRoute = modulePaths && !modulePaths.has(path);
const routes = resolveModuleRoutes(injector, MAIN_MENU_TAB_ROUTES); const routes = resolveModuleRoutes(injector, MAIN_MENU_TAB_ROUTES);
mainRoute.path = mainRoute.path || ''; mainRoute.path = path;
mainRoute.children = mainRoute.children || []; modulePaths?.add(path);
mainRoute.children = mainRoute.children.concat(routes.children);
return [ if (isRootRoute && !('redirectTo' in mainRoute)) {
mainRoute, mainRoute.children = mainRoute.children || [];
...routes.siblings, mainRoute.children = mainRoute.children.concat(routes.children);
]; }
return isRootRoute
? [mainRoute, ...routes.siblings]
: [mainRoute];
} }
@NgModule() @NgModule()

View File

@ -213,7 +213,7 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy {
} }
const showDevOptionsOnConfig = await CoreConfig.get('showDevOptions', 0); const showDevOptionsOnConfig = await CoreConfig.get('showDevOptions', 0);
this.devOptionsForced = CoreConstants.BUILD.isDevelopment || CoreConstants.BUILD.isTesting; this.devOptionsForced = CoreConstants.enableDevTools();
this.showDevOptions = this.devOptionsForced || showDevOptionsOnConfig == 1; this.showDevOptions = this.devOptionsForced || showDevOptionsOnConfig == 1;
const publicKey = this.deviceInfo.pushId ? const publicKey = this.deviceInfo.pushId ?

View File

@ -24,6 +24,7 @@ import { SQLiteDB } from '@classes/sqlitedb';
import { APP_SCHEMA, CoreStorageRecord, TABLE_NAME } from './database/storage'; import { APP_SCHEMA, CoreStorageRecord, TABLE_NAME } from './database/storage';
import { CoreSites } from './sites'; import { CoreSites } from './sites';
import { CoreSite } from '@classes/sites/site'; import { CoreSite } from '@classes/sites/site';
import { NULL_INJECTION_TOKEN } from '@/core/constants';
/** /**
* Service to store data using key-value pairs. * Service to store data using key-value pairs.
@ -38,7 +39,7 @@ export class CoreStorageService {
table: AsyncInstance<CoreStorageTable>; table: AsyncInstance<CoreStorageTable>;
constructor(@Optional() @Inject(null) lazyTableConstructor?: () => Promise<CoreStorageTable>) { constructor(@Optional() @Inject(NULL_INJECTION_TOKEN) lazyTableConstructor?: () => Promise<CoreStorageTable>) {
this.table = asyncInstance(lazyTableConstructor); this.table = asyncInstance(lazyTableConstructor);
} }

View File

@ -404,17 +404,13 @@ export class TestingBehatRuntimeService {
this.log('Action - pullToRefresh'); this.log('Action - pullToRefresh');
try { try {
// 'el' is protected, but there's no other way to trigger refresh programatically. const ionRefresher = this.getElement('ion-refresher');
const ionRefresher = this.getAngularInstance<{ el: HTMLIonRefresherElement }>(
'ion-refresher',
'IonRefresher',
);
if (!ionRefresher) { if (!ionRefresher) {
return 'ERROR: It\'s not possible to pull to refresh the current page.'; return 'ERROR: It\'s not possible to pull to refresh the current page.';
} }
ionRefresher.el.dispatchEvent(new CustomEvent('ionRefresh')); ionRefresher.dispatchEvent(new CustomEvent('ionRefresh'));
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
@ -521,20 +517,13 @@ export class TestingBehatRuntimeService {
} }
/** /**
* Get an Angular component instance. * Get element instance.
* *
* @param selector Element selector * @param selector Element selector.
* @param className Constructor class name
* @param referenceLocator The locator to the reference element to start looking for. If not specified, document body. * @param referenceLocator The locator to the reference element to start looking for. If not specified, document body.
* @returns Component instance * @returns Element instance.
*/ */
getAngularInstance<T = unknown>( private getElement<T = Element>(selector: string, referenceLocator?: TestingBehatElementLocator): T | null {
selector: string,
className: string,
referenceLocator?: TestingBehatElementLocator,
): T | null {
this.log('Action - Get Angular instance ' + selector + ', ' + className, referenceLocator);
let startingElement: HTMLElement | undefined = document.body; let startingElement: HTMLElement | undefined = document.body;
let queryPrefix = ''; let queryPrefix = '';
@ -552,15 +541,8 @@ export class TestingBehatRuntimeService {
queryPrefix = '.ion-page:not(.ion-page-hidden) '; queryPrefix = '.ion-page:not(.ion-page-hidden) ';
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any return Array.from(startingElement.querySelectorAll(`${queryPrefix}${selector}`)).pop() as T
const activeElement = Array.from(startingElement.querySelectorAll<any>(`${queryPrefix}${selector}`)).pop() ?? ?? startingElement.closest(selector) as T;
startingElement.closest(selector);
if (!activeElement || !activeElement.__ngContext__) {
return null;
}
return activeElement.__ngContext__.find(node => node?.constructor?.name === className);
} }
/** /**
@ -632,26 +614,26 @@ export class TestingBehatRuntimeService {
if (locator) { if (locator) {
// Locator specified, try to find swiper-container first. // Locator specified, try to find swiper-container first.
const instance = this.getAngularInstance<Swiper>('swiper-container', 'Swiper', locator); const swiperContainer = this.getElement<{ swiper: Swiper }>('swiper-container', locator);
if (instance) {
direction === 'left' ? instance.slideNext() : instance.slidePrev(); if (swiperContainer) {
direction === 'left' ? swiperContainer.swiper.slideNext() : swiperContainer.swiper.slidePrev();
return 'OK'; return 'OK';
} }
} }
// No locator specified or swiper-container not found, search swipe navigation now. // No locator specified or swiper-container not found, search swipe navigation now.
const instance = this.getAngularInstance<CoreSwipeNavigationDirective>( const ionContent = this.getElement<{ swipeNavigation: CoreSwipeNavigationDirective }>(
'ion-content', 'ion-content.uses-swipe-navigation',
'CoreSwipeNavigationDirective',
locator, locator,
); );
if (!instance) { if (!ionContent) {
return 'ERROR: Element to swipe not found.'; return 'ERROR: Element to swipe not found.';
} }
direction === 'left' ? instance.swipeLeft() : instance.swipeRight(); direction === 'left' ? ionContent.swipeNavigation.swipeLeft() : ionContent.swipeNavigation.swipeRight();
return 'OK'; return 'OK';
} }