import 'dart:io'; import 'package:appflowy/core/config/kv.dart'; import 'package:appflowy/core/config/kv_keys.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/presentation.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart'; import 'package:appflowy/plugins/shared/share/share_button.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/presentation/screens/screens.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart'; import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/widget/buttons/primary_button.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'emoji.dart'; import 'util.dart'; extension CommonOperations on WidgetTester { /// Tap the GetStart button on the launch page. Future tapAnonymousSignInButton() async { // local version final goButton = find.byType(GoButton); if (goButton.evaluate().isNotEmpty) { await tapButton(goButton); } else { // cloud version final anonymousButton = find.byType(SignInAnonymousButtonV2); await tapButton(anonymousButton); } if (Platform.isWindows) { await pumpAndSettle(const Duration(milliseconds: 200)); } } /// Tap the + button on the home page. Future tapAddViewButton({ String name = gettingStarted, ViewLayoutPB layout = ViewLayoutPB.Document, }) async { await hoverOnPageName( name, onHover: () async { final addButton = find.byType(ViewAddButton); await tapButton(addButton); }, ); } /// Tap the 'New Page' Button on the sidebar. Future tapNewPageButton() async { final newPageButton = find.byType(SidebarNewPageButton); await tapButton(newPageButton); } /// Tap the import button. /// /// Must call [tapAddViewButton] first. Future tapImportButton() async { await tapButtonWithName(LocaleKeys.moreAction_import.tr()); } /// Tap the import from text & markdown button. /// /// Must call [tapImportButton] first. Future tapTextAndMarkdownButton() async { await tapButtonWithName(LocaleKeys.importPanel_textAndMarkdown.tr()); } /// Tap the LanguageSelectorOnWelcomePage widget on the launch page. Future tapLanguageSelectorOnWelcomePage() async { final languageSelector = find.byType(LanguageSelectorOnWelcomePage); await tapButton(languageSelector); } /// Tap languageItem on LanguageItemsListView. /// /// [scrollDelta] is the distance to scroll the ListView. /// Default value is 100 /// /// If it is positive -> scroll down. /// /// If it is negative -> scroll up. Future tapLanguageItem({ required String languageCode, String? countryCode, double? scrollDelta, }) async { final languageItemsListView = find.descendant( of: find.byType(ListView), matching: find.byType(Scrollable), ); final languageItem = find.byWidgetPredicate( (widget) => widget is LanguageItem && widget.locale.languageCode == languageCode && widget.locale.countryCode == countryCode, ); // scroll the ListView until zHCNLanguageItem shows on the screen. await scrollUntilVisible( languageItem, scrollDelta ?? 100, scrollable: languageItemsListView, // maxHeight of LanguageItemsListView maxScrolls: 400, ); try { await tapButton(languageItem); } on FlutterError catch (e) { Log.warn('tapLanguageItem error: $e'); } } /// Hover on the widget. Future hoverOnWidget( Finder finder, { Offset? offset, Future Function()? onHover, bool removePointer = true, }) async { try { final gesture = await createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: offset ?? getCenter(finder)); await pumpAndSettle(); await onHover?.call(); await gesture.removePointer(); } catch (err) { Log.error('hoverOnWidget error: $err'); } } /// Hover on the page name. Future hoverOnPageName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, Future Function()? onHover, bool useLast = true, }) async { final pageNames = findPageName(name, layout: layout); if (useLast) { await hoverOnWidget(pageNames.last, onHover: onHover); } else { await hoverOnWidget(pageNames.first, onHover: onHover); } } /// open the page with given name. Future openPage( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, }) async { final finder = findPageName(name, layout: layout); expect(finder, findsOneWidget); await tapButton(finder); } /// Tap the ... button beside the page name. /// /// Must call [hoverOnPageName] first. Future tapPageOptionButton() async { final optionButton = find.byType(ViewMoreActionButton); await tapButton(optionButton); } /// Tap the delete page button. Future tapDeletePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.delete.name); } /// Tap the rename page button. Future tapRenamePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.rename.name); } /// Tap the favorite page button Future tapFavoritePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.favorite.name); } /// Tap the unfavorite page button Future tapUnfavoritePageButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.unFavorite.name); } /// Tap the Open in a new tab button Future tapOpenInTabButton() async { await tapPageOptionButton(); await tapButtonWithName(ViewMoreActionType.openInNewTab.name); } /// Rename the page. Future renamePage(String name) async { await tapRenamePageButton(); await enterText(find.byType(TextFormField), name); await tapOKButton(); } Future tapOKButton() async { final okButton = find.byWidgetPredicate( (widget) => widget is PrimaryTextButton && widget.label == LocaleKeys.button_ok.tr(), ); await tapButton(okButton); } /// Tap the restore button. /// /// the restore button will show after the current page is deleted. Future tapRestoreButton() async { final restoreButton = find.textContaining( LocaleKeys.deletePagePrompt_restore.tr(), ); await tapButton(restoreButton); } /// Tap the delete permanently button. /// /// the restore button will show after the current page is deleted. Future tapDeletePermanentlyButton() async { final restoreButton = find.textContaining( LocaleKeys.deletePagePrompt_deletePermanent.tr(), ); await tapButton(restoreButton); } /// Tap the share button above the document page. Future tapShareButton() async { final shareButton = find.byWidgetPredicate( (widget) => widget is ShareButton, ); await tapButton(shareButton); } /// Tap the export markdown button /// /// Must call [tapShareButton] first. Future tapMarkdownButton() async { final markdownButton = find.textContaining( LocaleKeys.shareAction_markdown.tr(), ); await tapButton(markdownButton); } Future createNewPageWithNameUnderParent({ String? name, ViewLayoutPB layout = ViewLayoutPB.Document, String? parentName, bool openAfterCreated = true, }) async { // create a new page await tapAddViewButton(name: parentName ?? gettingStarted, layout: layout); await tapButtonWithName(layout.menuName); final settingsOrFailure = await getIt().getWithFormat( KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); final showRenameDialog = settingsOrFailure ?? false; if (showRenameDialog) { await tapOKButton(); } await pumpAndSettle(); // hover on it and change it's name if (name != null) { await hoverOnPageName( LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layout: layout, onHover: () async { await renamePage(name); await pumpAndSettle(); }, ); await pumpAndSettle(); } // open the page after created if (openAfterCreated) { await openPage( // if the name is null, use the default name name ?? LocaleKeys.menuAppHeader_defaultNewPageName.tr(), layout: layout, ); await pumpAndSettle(); } } Future createNewPage({ ViewLayoutPB layout = ViewLayoutPB.Document, bool openAfterCreated = true, }) async { await tapButton(find.byType(SidebarNewPageButton)); } Future simulateKeyEvent( LogicalKeyboardKey key, { bool isControlPressed = false, bool isShiftPressed = false, bool isAltPressed = false, bool isMetaPressed = false, }) async { if (isControlPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.control); } if (isShiftPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.shift); } if (isAltPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.alt); } if (isMetaPressed) { await simulateKeyDownEvent(LogicalKeyboardKey.meta); } await simulateKeyDownEvent(key); await simulateKeyUpEvent(key); if (isControlPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.control); } if (isShiftPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.shift); } if (isAltPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.alt); } if (isMetaPressed) { await simulateKeyUpEvent(LogicalKeyboardKey.meta); } await pumpAndSettle(); } Future openAppInNewTab(String name, ViewLayoutPB layout) async { await hoverOnPageName( name, onHover: () async { await tapOpenInTabButton(); await pumpAndSettle(); }, ); await pumpAndSettle(); } Future favoriteViewByName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, }) async { await hoverOnPageName( name, layout: layout, onHover: () async { await tapFavoritePageButton(); await pumpAndSettle(); }, ); } Future unfavoriteViewByName( String name, { ViewLayoutPB layout = ViewLayoutPB.Document, }) async { await hoverOnPageName( name, layout: layout, onHover: () async { await tapUnfavoritePageButton(); await pumpAndSettle(); }, ); } Future movePageToOtherPage({ required String name, required String parentName, required ViewLayoutPB layout, required ViewLayoutPB parentLayout, DraggableHoverPosition position = DraggableHoverPosition.center, }) async { final from = findPageName(name, layout: layout); final to = findPageName(parentName, layout: parentLayout); final gesture = await startGesture(getCenter(from)); Offset offset = Offset.zero; switch (position) { case DraggableHoverPosition.center: offset = getCenter(to); break; case DraggableHoverPosition.top: offset = getTopLeft(to); break; case DraggableHoverPosition.bottom: offset = getBottomLeft(to); break; default: } await gesture.moveTo(offset, timeStamp: const Duration(milliseconds: 400)); await gesture.up(); await pumpAndSettle(); } // tap the button with [FlowySvgData] Future tapButtonWithFlowySvgData(FlowySvgData svg) async { final button = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg.path == svg.path, ); await tapButton(button); } // update the page icon in the sidebar Future updatePageIconInSidebarByName({ required String name, required String parentName, required ViewLayoutPB layout, required String icon, }) async { final iconButton = find.descendant( of: findPageName( name, layout: layout, parentName: parentName, ), matching: find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()), ); await tapButton(iconButton); await tapEmoji(icon); await pumpAndSettle(); } // update the page icon in the sidebar Future updatePageIconInTitleBarByName({ required String name, required ViewLayoutPB layout, required String icon, }) async { await openPage( name, layout: layout, ); final title = find.descendant( of: find.byType(ViewTitleBar), matching: find.text(name), ); await tapButton(title); await tapButton(find.byType(EmojiPickerButton)); await tapEmoji(icon); await pumpAndSettle(); } Future openNotificationHub({int tabIndex = 0}) async { final finder = find.descendant( of: find.byType(NotificationButton), matching: find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg == FlowySvgs.clock_alarm_s, ), ); await tap(finder); await pumpAndSettle(); if (tabIndex == 1) { final tabFinder = find.descendant( of: find.byType(NotificationTabBar), matching: find.byType(FlowyTabItem).at(1), ); await tap(tabFinder); await pumpAndSettle(); } } Future toggleCommandPalette() async { // Press CMD+P or CTRL+P to open the command palette await simulateKeyEvent( LogicalKeyboardKey.keyP, isControlPressed: !Platform.isMacOS, isMetaPressed: Platform.isMacOS, ); await pumpAndSettle(); } Future openCollaborativeWorkspaceMenu() async { if (!FeatureFlag.collaborativeWorkspace.isOn) { throw UnsupportedError('Collaborative workspace is not enabled'); } final workspace = find.byType(SidebarWorkspace); expect(workspace, findsOneWidget); await tapButton(workspace, pumpAndSettle: false); await pump(const Duration(seconds: 5)); } Future createCollaborativeWorkspace(String name) async { if (!FeatureFlag.collaborativeWorkspace.isOn) { throw UnsupportedError('Collaborative workspace is not enabled'); } await openCollaborativeWorkspaceMenu(); // expect to see the workspace list, and there should be only one workspace final workspacesMenu = find.byType(WorkspacesMenu); expect(workspacesMenu, findsOneWidget); // click the create button final createButton = find.byKey(createWorkspaceButtonKey); expect(createButton, findsOneWidget); await tapButton(createButton, pumpAndSettle: false); await pump(const Duration(seconds: 5)); // see the create workspace dialog final createWorkspaceDialog = find.byType(CreateWorkspaceDialog); expect(createWorkspaceDialog, findsOneWidget); // input the workspace name await enterText(find.byType(TextField), name); await tapButtonWithName(LocaleKeys.button_ok.tr(), pumpAndSettle: false); await pump(const Duration(seconds: 5)); } // For mobile platform to launch the app in anonymous mode Future launchInAnonymousMode() async { assert( [TargetPlatform.android, TargetPlatform.iOS] .contains(defaultTargetPlatform), 'This method is only supported on mobile platforms', ); await initializeAppFlowy(); final anonymousSignInButton = find.byType(SignInAnonymousButtonV2); expect(anonymousSignInButton, findsOneWidget); await tapButton(anonymousSignInButton); await pumpUntilFound(find.byType(MobileHomeScreen)); } Future tapSvgButton(FlowySvgData svg) async { final button = find.byWidgetPredicate( (widget) => widget is FlowySvg && widget.svg.path == svg.path, ); await tapButton(button); } Future openMoreViewActions() async { final button = find.byType(MoreViewActions); await tap(button); await pumpAndSettle(); } /// Presses on the Duplicate ViewAction in the [MoreViewActions] popup. /// /// [openMoreViewActions] must be called beforehand! /// Future duplicateByMoreViewActions() async { final button = find.descendant( of: find.byType(ListView), matching: find.byWidgetPredicate( (widget) => widget is ViewAction && widget.type == ViewActionType.duplicate, ), ); await tap(button); await pump(); } /// Presses on the Delete ViewAction in the [MoreViewActions] popup. /// /// [openMoreViewActions] must be called beforehand! /// Future deleteByMoreViewActions() async { final button = find.descendant( of: find.byType(ListView), matching: find.byWidgetPredicate( (widget) => widget is ViewAction && widget.type == ViewActionType.delete, ), ); await tap(button); await pump(); } } extension SettingsFinder on CommonFinders { Finder findSettingsScrollable() => find .descendant( of: find .descendant( of: find.byType(SettingsBody), matching: find.byType(SingleChildScrollView), ) .first, matching: find.byType(Scrollable), ) .first; Finder findSettingsMenuScrollable() => find .descendant( of: find .descendant( of: find.byType(SettingsMenu), matching: find.byType(SingleChildScrollView), ) .first, matching: find.byType(Scrollable), ) .first; } extension ViewLayoutPBTest on ViewLayoutPB { String get menuName { switch (this) { case ViewLayoutPB.Grid: return LocaleKeys.grid_menuName.tr(); case ViewLayoutPB.Board: return LocaleKeys.board_menuName.tr(); case ViewLayoutPB.Document: return LocaleKeys.document_menuName.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.calendar_menuName.tr(); default: throw UnsupportedError('Unsupported layout: $this'); } } String get referencedMenuName { switch (this) { case ViewLayoutPB.Grid: return LocaleKeys.document_plugins_referencedGrid.tr(); case ViewLayoutPB.Board: return LocaleKeys.document_plugins_referencedBoard.tr(); case ViewLayoutPB.Calendar: return LocaleKeys.document_plugins_referencedCalendar.tr(); default: throw UnsupportedError('Unsupported layout: $this'); } } }