From cbf6d41e4c23eeddd0dbb5cd0324a67fe92c6a97 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 31 Jan 2024 21:35:41 +1000 Subject: [PATCH] More E2E tests; `export default` classes from modules (#8730) This is a prerequisite for adding a CI action for E2E tests. - Fix E2E tests - Remove visual regression testing (VRT) and associated fixtures (screenshots) for now - Switch dashboard almost fully to Vite, from ESBuild, to match GUI2's build tooling. - Add some new E2E tests: - Creating assets - Deleting assets - Creating assets from the samples on the home page - Sort assets - Includes fixes for sorting: - Group sorted assets by type again (regression) (see https://github.com/enso-org/cloud-v2/issues/554) - Make sorting by title, case insensitive. This is because it is more intuitive for non-programmers if all items with uppercase names *aren't* separated from those with lowercase names - especially since the Windows FS is case-insensitive. - Normalization of Unicode letters is *not* currently being done. It can potentially be added later. - Double-clicking *anywhere* on a directory row now expands it. Previously it was only being expanded when double clicking - Add recursive label adding/removal to mirror backend - Note: The current implementation is not exactly the same as the backend's implementation. - Fix https://github.com/enso-org/cloud-v2/issues/872 - Unset "saved project details" (for opening the last open project) if fetching it produces an error. # Important Notes - All tests pass. (run `npm run test:e2e` in `app/ide-desktop/lib/dashboard`) - All `npm` commands should be run in `app/ide-desktop/lib/dashboard`. `dashboard:*` npm scripts have been removed from `app/ide-desktop` to prevent a mess. - `npm run dev` confirmed to still work. Note that it has not been changed as it was already using Vite. - `npm run build` now uses `vite build`. This has been tested using a local HTTP server that supports `404.html`. - Other cases have been tested: - `npm run test:e2e` works (all tests pass) - `./run ide build` works - `./run ide watch` works - `./run ide2 build` works - `./run gui watch` works --- .git-blame-ignore-revs | 2 + .prettierignore | 1 + app/gui2/src/components/GraphEditor.vue | 2 +- app/ide-desktop/eslint.config.js | 12 +- app/ide-desktop/lib/common/package.json | 4 +- app/ide-desktop/lib/common/src/index.d.ts | 28 + .../lib/common/src/{index.ts => index.js} | 5 +- app/ide-desktop/lib/common/tsconfig.json | 9 + app/ide-desktop/lib/content/src/entrypoint.ts | 2 +- app/ide-desktop/lib/dashboard/.prettierignore | 3 + app/ide-desktop/lib/dashboard/.prettierrc.cjs | 19 +- app/ide-desktop/lib/dashboard/404.html | 51 ++ app/ide-desktop/lib/dashboard/bundle.ts | 53 -- app/ide-desktop/lib/dashboard/e2e/actions.ts | 753 ++++++++++++++++++ app/ide-desktop/lib/dashboard/e2e/api.ts | 714 +++++++++++++++++ .../lib/dashboard/e2e/assetPanel.spec.ts | 54 ++ .../lib/dashboard/e2e/assetSearchBar.spec.ts | 118 +++ .../changePasswordModal.spec.ts | 20 +- .../lib/dashboard/e2e/copy.spec.ts | 171 ++++ .../lib/dashboard/e2e/createAsset.spec.ts | 60 ++ .../lib/dashboard/e2e/delete.spec.ts | 50 ++ .../lib/dashboard/e2e/driveView.spec.ts | 33 + .../lib/dashboard/e2e/editAssetName.spec.ts | 92 +++ .../lib/dashboard/e2e/homePage.spec.ts | 20 + .../lib/dashboard/e2e/labels.spec.ts | 202 +++++ .../lib/dashboard/e2e/labelsPanel.spec.ts | 51 ++ .../lib/dashboard/e2e/loginLogout.spec.ts | 24 + .../{test-e2e => e2e}/loginScreen.spec.ts | 13 +- .../lib/dashboard/e2e/pageSwitcher.spec.ts | 27 + .../{test-e2e => e2e}/signUpFlow.spec.ts | 23 +- .../signUpWithOrganizationId.spec.ts | 3 +- .../signUpWithoutOrganizationId.spec.ts | 3 +- .../lib/dashboard/e2e/sort.spec.ts | 137 ++++ .../lib/dashboard/e2e/userMenu.spec.ts | 21 + app/ide-desktop/lib/dashboard/index.html | 7 - .../lib/dashboard/log-screenshot-diffs.ts | 15 - app/ide-desktop/lib/dashboard/package.json | 15 +- .../dashboard/playwright-component.config.ts | 47 -- ...ght-e2e.config.ts => playwright.config.ts} | 11 +- .../lib/dashboard/playwright/index.html | 12 - .../lib/dashboard/playwright/index.tsx | 1 - app/ide-desktop/lib/dashboard/src/App.tsx | 48 +- .../authentication/cognito.mock.ts} | 26 +- .../dashboard/src/authentication/cognito.ts | 204 ++--- .../authentication/listen.mock.ts} | 0 .../dashboard/src/authentication/service.ts | 7 +- .../dashboard/src/components/ColorPicker.tsx | 2 +- .../dashboard/src/components/EditableSpan.tsx | 55 +- .../dashboard/src/components/MenuEntry.tsx | 19 +- .../lib/dashboard/src/components/Modal.tsx | 8 +- .../src/components/dashboard/AssetIcon.tsx | 7 +- .../src/components/dashboard/AssetInfoBar.tsx | 4 +- .../src/components/dashboard/AssetRow.tsx | 77 +- .../dashboard/AssetRow/assetRowUtils.tsx | 14 + .../src/components/dashboard/AssetSummary.tsx | 7 +- .../dashboard/ConfirmDeleteModal.tsx | 1 + .../dashboard/DirectoryNameColumn.tsx | 56 +- .../components/dashboard/FileNameColumn.tsx | 53 +- .../src/components/dashboard/Label.tsx | 7 +- .../components/dashboard/Label/labelUtils.ts | 2 +- .../dashboard/PermissionDisplay.tsx | 14 +- .../dashboard/PermissionSelector.tsx | 9 +- .../dashboard/PermissionTypeSelector.tsx | 3 +- .../src/components/dashboard/ProjectIcon.tsx | 18 +- .../dashboard/ProjectNameColumn.tsx | 61 +- .../components/dashboard/SecretNameColumn.tsx | 61 +- .../components/dashboard/UserPermissions.tsx | 7 +- .../src/components/dashboard/column.ts | 10 +- .../dashboard/column/LabelsColumn.tsx | 23 +- .../dashboard/column/LastModifiedColumn.tsx | 4 +- .../dashboard/column/NameColumn.tsx | 4 +- .../dashboard/column/SharedWithColumn.tsx | 18 +- .../dashboard/column/columnUtils.ts | 17 +- .../columnHeading/ModifiedColumnHeading.tsx | 26 +- .../columnHeading/NameColumnHeading.tsx | 28 +- .../components/dashboard/keyboardShortcut.tsx | 15 +- .../lib/dashboard/src/entrypoint.ts | 33 - .../lib/dashboard/src/events/assetEvent.ts | 3 +- .../dashboard/src/events/assetListEvent.ts | 3 +- .../lib/dashboard/src/hooks/navigateHooks.ts | 1 + .../lib/dashboard/src/hooks/setAssetHooks.ts | 36 + .../dashboard/src/hooks/toastAndLogHooks.ts | 1 + app/ide-desktop/lib/dashboard/src/index.tsx | 1 + .../layouts/dashboard/AssetContextMenu.tsx | 69 +- .../src/layouts/dashboard/AssetSearchBar.tsx | 213 ++--- .../layouts/dashboard/AssetSettingsPanel.tsx | 30 +- .../src/layouts/dashboard/AssetsTable.tsx | 291 ++++--- .../dashboard/AssetsTableContextMenu.tsx | 46 +- .../src/layouts/dashboard/BackendSwitcher.tsx | 3 +- .../layouts/dashboard/CategorySwitcher.tsx | 66 +- .../dashboard/CategorySwitcher/Category.ts | 2 - .../layouts/dashboard/ChangePasswordModal.tsx | 5 +- .../dashboard/src/layouts/dashboard/Chat.tsx | 85 +- .../src/layouts/dashboard/ChatPlaceholder.tsx | 36 +- .../dashboard/ConfirmDeleteUserModal.tsx | 1 + .../dashboard/src/layouts/dashboard/Drive.tsx | 38 +- .../src/layouts/dashboard/DriveBar.tsx | 42 +- .../dashboard/DuplicateAssetsModal.tsx | 11 +- .../src/layouts/dashboard/Editor.tsx | 10 +- .../layouts/dashboard/GlobalContextMenu.tsx | 26 +- .../layouts/dashboard/InviteUsersModal.tsx | 4 +- .../src/layouts/dashboard/Labels.tsx | 18 +- .../layouts/dashboard/ManageLabelsModal.tsx | 9 +- .../dashboard/ManagePermissionsModal.tsx | 9 +- .../src/layouts/dashboard/NewLabelModal.tsx | 4 +- .../src/layouts/dashboard/PageSwitcher.tsx | 8 +- .../src/layouts/dashboard/Samples.tsx | 2 +- .../src/layouts/dashboard/Settings.tsx | 3 +- .../src/layouts/dashboard/TopBar.tsx | 10 +- .../layouts/dashboard/UpsertSecretModal.tsx | 15 +- .../src/layouts/dashboard/UserBar.tsx | 11 +- .../src/layouts/dashboard/UserMenu.tsx | 32 +- .../settingsTab/AccountSettingsTab.tsx | 2 + .../settingsTab/MembersSettingsTab.tsx | 4 +- .../authentication/ConfirmRegistration.tsx | 26 +- .../pages/authentication/EnterOfflineMode.tsx | 2 + .../pages/authentication/ForgotPassword.tsx | 1 + .../src/pages/authentication/Login.tsx | 22 +- .../src/pages/authentication/Registration.tsx | 34 +- .../pages/authentication/ResetPassword.tsx | 28 +- .../src/pages/dashboard/Dashboard.tsx | 122 ++- .../dashboard/src/providers/AuthProvider.tsx | 99 ++- .../src/providers/BackendProvider.tsx | 19 +- .../src/providers/LocalStorageProvider.tsx | 6 +- .../src/providers/SessionProvider.tsx | 6 +- .../src/providers/ShortcutManagerProvider.tsx | 54 ++ .../src/providers/ShortcutsProvider.tsx | 42 - .../src/services/{backend.ts => Backend.ts} | 2 +- .../{localBackend.ts => LocalBackend.ts} | 12 +- .../{remoteBackend.ts => RemoteBackend.ts} | 12 +- .../src/services/remoteBackendPaths.ts | 2 +- .../{assetQuery.ts => AssetQuery.ts} | 2 +- .../{assetTreeNode.ts => AssetTreeNode.ts} | 35 +- .../src/utilities/{http.ts => HttpClient.ts} | 14 +- .../{localStorage.ts => LocalStorage.ts} | 8 +- .../{projectManager.ts => ProjectManager.ts} | 2 +- .../{shortcuts.ts => ShortcutManager.ts} | 38 +- .../{sorting.ts => SortDirection.ts} | 10 +- ...{assetQuery.test.ts => AssetQuery.test.ts} | 16 +- ...rtcuts.test.ts => ShortcutManager.test.ts} | 60 +- .../src/utilities/__tests__/array.test.ts | 2 +- .../src/utilities/__tests__/dateTime.test.ts | 2 +- .../src/utilities/__tests__/set.test.ts | 2 +- .../lib/dashboard/src/utilities/animations.ts | 176 ---- .../lib/dashboard/src/utilities/config.ts | 3 +- .../lib/dashboard/src/utilities/drag.ts | 3 +- .../{permissions.tsx => permissions.ts} | 5 +- .../dashboard/src/utilities/serviceWorker.ts | 42 - .../lib/dashboard/src/utilities/validation.ts | 4 + .../lib/dashboard/tailwind.config.js | 1 + .../authentication/src/useRefresh.spec.tsx | 26 - .../authentication/src/useRefresh/refresh.tsx | 27 - .../lib/dashboard/test-e2e/actions.ts | 394 --------- app/ide-desktop/lib/dashboard/test-e2e/api.ts | 371 --------- .../change-password-modal-1-darwin.png | Bin 56615 -> 0 bytes .../change-password-modal-1-linux.png | Bin 48993 -> 0 bytes .../change-password-modal-1-win32.png | Bin 49508 -> 0 bytes .../lib/dashboard/test-e2e/copy.spec.ts | 175 ---- .../lib/dashboard/test-e2e/driveView.spec.ts | 45 -- .../drive-view-1-darwin.png | Bin 52330 -> 0 bytes .../drive-view-1-linux.png | Bin 95134 -> 0 bytes .../drive-view-1-win32.png | Bin 49846 -> 0 bytes .../drive-view-2-darwin.png | Bin 14522 -> 0 bytes .../drive-view-2-linux.png | Bin 17143 -> 0 bytes .../drive-view-2-win32.png | Bin 14264 -> 0 bytes .../drive-view-3-darwin.png | Bin 76415 -> 0 bytes .../drive-view-3-linux.png | Bin 66539 -> 0 bytes .../drive-view-3-win32.png | Bin 72641 -> 0 bytes .../dashboard/test-e2e/labelsPanel.spec.ts | 49 -- .../labels-1-darwin.png | Bin 2624 -> 0 bytes .../labels-1-linux.png | Bin 2668 -> 0 bytes .../labels-2-darwin.png | Bin 30998 -> 0 bytes .../labels-2-linux.png | Bin 23944 -> 0 bytes .../labels-3-darwin.png | Bin 29949 -> 0 bytes .../labels-3-linux.png | Bin 23123 -> 0 bytes .../labels-4-darwin.png | Bin 28881 -> 0 bytes .../labels-4-linux.png | Bin 23232 -> 0 bytes .../labels-5-darwin.png | Bin 27168 -> 0 bytes .../labels-5-linux.png | Bin 22401 -> 0 bytes .../labels-6-darwin.png | Bin 2624 -> 0 bytes .../labels-6-linux.png | Bin 2668 -> 0 bytes .../dashboard/test-e2e/loginLogout.spec.ts | 22 - .../login-and-logout-1-darwin.png | Bin 67250 -> 0 bytes .../login-and-logout-1-linux.png | Bin 115257 -> 0 bytes .../login-and-logout-1-win32.png | Bin 64879 -> 0 bytes .../login-and-logout-2-darwin.png | Bin 63617 -> 0 bytes .../login-and-logout-2-linux.png | Bin 107387 -> 0 bytes .../login-and-logout-2-win32.png | Bin 63696 -> 0 bytes .../sign-up-flow-1-darwin.png | Bin 11153 -> 0 bytes .../sign-up-flow-1-linux.png | Bin 15609 -> 0 bytes .../sign-up-flow-1-win32.png | Bin 11495 -> 0 bytes .../sign-up-flow-2-darwin.png | Bin 50916 -> 0 bytes .../sign-up-flow-2-linux.png | Bin 100975 -> 0 bytes .../sign-up-flow-2-win32.png | Bin 50471 -> 0 bytes .../sign-up-flow-3-darwin.png | Bin 67250 -> 0 bytes .../sign-up-flow-3-linux.png | Bin 115257 -> 0 bytes .../sign-up-flow-3-win32.png | Bin 64879 -> 0 bytes .../lib/dashboard/test-e2e/userMenu.spec.ts | 14 - .../user-menu-1-darwin.png | Bin 12275 -> 0 bytes .../user-menu-1-linux.png | Bin 9166 -> 0 bytes .../user-menu-1-win32.png | Bin 14849 -> 0 bytes app/ide-desktop/lib/dashboard/test-server.ts | 73 -- app/ide-desktop/lib/dashboard/tsconfig.json | 1 + app/ide-desktop/lib/dashboard/vite.config.ts | 12 +- .../lib/dashboard/vite.test.config.ts | 34 + app/ide-desktop/lib/dashboard/watch.ts | 72 -- .../ts-plugin-namespace-auto-import/index.js | 13 + app/ide-desktop/package.json | 3 - package-lock.json | 12 +- 209 files changed, 4172 insertions(+), 3071 deletions(-) create mode 100644 app/ide-desktop/lib/common/src/index.d.ts rename app/ide-desktop/lib/common/src/{index.ts => index.js} (89%) create mode 100644 app/ide-desktop/lib/common/tsconfig.json create mode 100644 app/ide-desktop/lib/dashboard/.prettierignore create mode 100644 app/ide-desktop/lib/dashboard/404.html delete mode 100644 app/ide-desktop/lib/dashboard/bundle.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/actions.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/api.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts rename app/ide-desktop/lib/dashboard/{test-e2e => e2e}/changePasswordModal.spec.ts (77%) create mode 100644 app/ide-desktop/lib/dashboard/e2e/copy.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/delete.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/editAssetName.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/homePage.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/labels.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/labelsPanel.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts rename app/ide-desktop/lib/dashboard/{test-e2e => e2e}/loginScreen.spec.ts (73%) create mode 100644 app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts rename app/ide-desktop/lib/dashboard/{test-e2e => e2e}/signUpFlow.spec.ts (67%) rename app/ide-desktop/lib/dashboard/{test-e2e => e2e}/signUpWithOrganizationId.spec.ts (94%) rename app/ide-desktop/lib/dashboard/{test-e2e => e2e}/signUpWithoutOrganizationId.spec.ts (93%) create mode 100644 app/ide-desktop/lib/dashboard/e2e/sort.spec.ts create mode 100644 app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts delete mode 100644 app/ide-desktop/lib/dashboard/log-screenshot-diffs.ts delete mode 100644 app/ide-desktop/lib/dashboard/playwright-component.config.ts rename app/ide-desktop/lib/dashboard/{playwright-e2e.config.ts => playwright.config.ts} (88%) delete mode 100644 app/ide-desktop/lib/dashboard/playwright/index.html delete mode 100644 app/ide-desktop/lib/dashboard/playwright/index.tsx rename app/ide-desktop/lib/dashboard/{mock/authentication/cognito.ts => src/authentication/cognito.mock.ts} (94%) rename app/ide-desktop/lib/dashboard/{mock/authentication/listen.tsx => src/authentication/listen.mock.ts} (100%) create mode 100644 app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow/assetRowUtils.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/setAssetHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/providers/ShortcutManagerProvider.tsx delete mode 100644 app/ide-desktop/lib/dashboard/src/providers/ShortcutsProvider.tsx rename app/ide-desktop/lib/dashboard/src/services/{backend.ts => Backend.ts} (99%) rename app/ide-desktop/lib/dashboard/src/services/{localBackend.ts => LocalBackend.ts} (97%) rename app/ide-desktop/lib/dashboard/src/services/{remoteBackend.ts => RemoteBackend.ts} (99%) rename app/ide-desktop/lib/dashboard/src/utilities/{assetQuery.ts => AssetQuery.ts} (99%) rename app/ide-desktop/lib/dashboard/src/utilities/{assetTreeNode.ts => AssetTreeNode.ts} (81%) rename app/ide-desktop/lib/dashboard/src/utilities/{http.ts => HttpClient.ts} (96%) rename app/ide-desktop/lib/dashboard/src/utilities/{localStorage.ts => LocalStorage.ts} (98%) rename app/ide-desktop/lib/dashboard/src/utilities/{projectManager.ts => ProjectManager.ts} (99%) rename app/ide-desktop/lib/dashboard/src/utilities/{shortcuts.ts => ShortcutManager.ts} (94%) rename app/ide-desktop/lib/dashboard/src/utilities/{sorting.ts => SortDirection.ts} (65%) rename app/ide-desktop/lib/dashboard/src/utilities/__tests__/{assetQuery.test.ts => AssetQuery.test.ts} (92%) rename app/ide-desktop/lib/dashboard/src/utilities/__tests__/{shortcuts.test.ts => ShortcutManager.test.ts} (68%) delete mode 100644 app/ide-desktop/lib/dashboard/src/utilities/animations.ts rename app/ide-desktop/lib/dashboard/src/utilities/{permissions.tsx => permissions.ts} (97%) delete mode 100644 app/ide-desktop/lib/dashboard/src/utilities/serviceWorker.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh.spec.tsx delete mode 100644 app/ide-desktop/lib/dashboard/test-component/authentication/src/useRefresh/refresh.tsx delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/actions.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/api.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts-snapshots/change-password-modal-1-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/copy.spec.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-1-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-2-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/driveView.spec.ts-snapshots/drive-view-3-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-1-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-2-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-3-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-4-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-5-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/labelsPanel.spec.ts-snapshots/labels-6-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-1-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/loginLogout.spec.ts-snapshots/login-and-logout-2-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-1-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-2-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts-snapshots/sign-up-flow-3-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-darwin.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-linux.png delete mode 100644 app/ide-desktop/lib/dashboard/test-e2e/userMenu.spec.ts-snapshots/user-menu-1-win32.png delete mode 100644 app/ide-desktop/lib/dashboard/test-server.ts create mode 100644 app/ide-desktop/lib/dashboard/vite.test.config.ts delete mode 100644 app/ide-desktop/lib/dashboard/watch.ts diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 6fe0cb4343..987c40ca34 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,3 +3,5 @@ 0aa7d7ee4d969ec8e8f9d376e72741dca324fdf6 # Update code style to use rust fmt (#3131) c822256e6c531e56e894f9f92654654f63cfd6bc +# Change dashboard to use 2 spaces for indentation (#8798) +5f1333a519235b27fc04457de1fd07b1a0128073 diff --git a/.prettierignore b/.prettierignore index d834c32adb..7f69d83a60 100644 --- a/.prettierignore +++ b/.prettierignore @@ -32,6 +32,7 @@ app/ide-desktop/lib/client/electron-builder-config.json app/ide-desktop/lib/content-config/src/config.json app/ide-desktop/lib/dashboard/playwright-report/ app/ide-desktop/lib/dashboard/playwright/.cache/ +app/ide-desktop/lib/dashboard/dist/ app/gui/view/documentation/assets/stylesheet.css app/gui2/rust-ffi/pkg app/gui2/src/assets/font-*.css diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index e0f028703e..c65478d04c 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -36,7 +36,7 @@ import * as set from 'lib0/set' import { toast } from 'react-toastify' import type { NodeMetadata } from 'shared/yjsModel' import { computed, onMounted, onScopeDispose, onUnmounted, ref, watch } from 'vue' -import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/utilities/projectManager' +import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/utilities/ProjectManager' import { type Usage } from './ComponentBrowser/input' const EXECUTION_MODES = ['design', 'live'] diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index a204fd0a7a..ebd6f0bfae 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -512,12 +512,12 @@ export default [ }, { files: [ - 'lib/dashboard/test*/**/*.ts', - 'lib/dashboard/test*/**/*.mts', - 'lib/dashboard/test*/**/*.cts', - 'lib/dashboard/test*/**/*.tsx', - 'lib/dashboard/test*/**/*.mtsx', - 'lib/dashboard/test*/**/*.ctsx', + 'lib/dashboard/e2e/**/*.ts', + 'lib/dashboard/e2e/**/*.mts', + 'lib/dashboard/e2e/**/*.cts', + 'lib/dashboard/e2e/**/*.tsx', + 'lib/dashboard/e2e/**/*.mtsx', + 'lib/dashboard/e2e/**/*.ctsx', ], rules: { 'no-restricted-properties': [ diff --git a/app/ide-desktop/lib/common/package.json b/app/ide-desktop/lib/common/package.json index 7ebe536ded..d276cd34ee 100644 --- a/app/ide-desktop/lib/common/package.json +++ b/app/ide-desktop/lib/common/package.json @@ -2,9 +2,9 @@ "name": "enso-common", "version": "1.0.0", "type": "module", - "main": "./src/index.ts", + "main": "./src/index.js", "exports": { - ".": "./src/index.ts", + ".": "./src/index.js", "./src/detect": "./src/detect.ts", "./src/gtag": "./src/gtag.ts" } diff --git a/app/ide-desktop/lib/common/src/index.d.ts b/app/ide-desktop/lib/common/src/index.d.ts new file mode 100644 index 0000000000..d7aed7a0b1 --- /dev/null +++ b/app/ide-desktop/lib/common/src/index.d.ts @@ -0,0 +1,28 @@ +/** @file This module contains metadata about the product and distribution, + * and various other constants that are needed in multiple sibling packages. + * + * Code in this package is used by two or more sibling packages of this package. The code is defined + * here when it is not possible for a sibling package to own that code without introducing a + * circular dependency in our packages. */ + +// ======================== +// === Product metadata === +// ======================== + +/** URL protocol scheme for deep links to authentication flow pages, without the `:` suffix. + * + * For example: the deep link URL + * `enso://authentication/register?code=...&state=...` uses this scheme. */ +export const DEEP_LINK_SCHEME: string + +/** Name of the product. */ +export const PRODUCT_NAME: string + +/** Company name, used as the copyright holder. */ +export const COMPANY_NAME: string + +/** COOP, COEP, and CORP headers: https://web.dev/coop-coep/ + * + * These are required to increase the resolution of `performance.now()` timers, + * making profiling a lot more accurate and consistent. */ +export const COOP_COEP_CORP_HEADERS: [header: string, value: string][] diff --git a/app/ide-desktop/lib/common/src/index.ts b/app/ide-desktop/lib/common/src/index.js similarity index 89% rename from app/ide-desktop/lib/common/src/index.ts rename to app/ide-desktop/lib/common/src/index.js index ff0b801fbe..aabe1c0066 100644 --- a/app/ide-desktop/lib/common/src/index.ts +++ b/app/ide-desktop/lib/common/src/index.js @@ -21,11 +21,12 @@ export const PRODUCT_NAME = 'Enso' /** Company name, used as the copyright holder. */ export const COMPANY_NAME = 'New Byte Order sp. z o.o.' -/** COOP, COEP, and CORP headers: https://web.dev/coop-coep/ +/** @type {[header: string, value: string][]} + * COOP, COEP, and CORP headers: https://web.dev/coop-coep/ * * These are required to increase the resolution of `performance.now()` timers, * making profiling a lot more accurate and consistent. */ -export const COOP_COEP_CORP_HEADERS: [header: string, value: string][] = [ +export const COOP_COEP_CORP_HEADERS = [ ['Cross-Origin-Embedder-Policy', 'credentialless'], ['Cross-Origin-Opener-Policy', 'same-origin'], ['Cross-Origin-Resource-Policy', 'same-origin'], diff --git a/app/ide-desktop/lib/common/tsconfig.json b/app/ide-desktop/lib/common/tsconfig.json new file mode 100644 index 0000000000..0a921bf939 --- /dev/null +++ b/app/ide-desktop/lib/common/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowJs": false, + "checkJs": false, + "skipLibCheck": false + }, + "include": ["./src/"] +} diff --git a/app/ide-desktop/lib/content/src/entrypoint.ts b/app/ide-desktop/lib/content/src/entrypoint.ts index ae1b4473f6..89dbebfa43 100644 --- a/app/ide-desktop/lib/content/src/entrypoint.ts +++ b/app/ide-desktop/lib/content/src/entrypoint.ts @@ -300,7 +300,7 @@ class Main implements AppRunner { const ideElement = document.getElementById('root') if (ideElement) { ideElement.style.top = '-100vh' - ideElement.style.display = 'fixed' + ideElement.style.position = 'fixed' } const ide2Element = document.getElementById('app') if (ide2Element) { diff --git a/app/ide-desktop/lib/dashboard/.prettierignore b/app/ide-desktop/lib/dashboard/.prettierignore new file mode 100644 index 0000000000..fde09b23fe --- /dev/null +++ b/app/ide-desktop/lib/dashboard/.prettierignore @@ -0,0 +1,3 @@ +playwright-report/ +playwright/.cache/ +dist/ \ No newline at end of file diff --git a/app/ide-desktop/lib/dashboard/.prettierrc.cjs b/app/ide-desktop/lib/dashboard/.prettierrc.cjs index b4c3552c5d..52c3d61840 100644 --- a/app/ide-desktop/lib/dashboard/.prettierrc.cjs +++ b/app/ide-desktop/lib/dashboard/.prettierrc.cjs @@ -22,10 +22,27 @@ module.exports = { '', '^enso-', '', - '^#[/](?!components[/]).*$', + '^#[/]App', + '^#[/]appUtils', + '', + '^#[/]hooks[/]', + '', + '^#[/]providers[/]', + '', + '^#[/]events[/]', + '', + '^#[/]pages[/]', + '', + '^#[/]layouts[/]', '', '^#[/]components[/]', '', + '^#[/]services[/]', + '', + '^#[/]utilities[/]', + '', + '^#[/]authentication[/]', + '', '^[.]', ], importOrderParserPlugins: ['typescript', 'jsx', 'importAssertions'], diff --git a/app/ide-desktop/lib/dashboard/404.html b/app/ide-desktop/lib/dashboard/404.html new file mode 100644 index 0000000000..70124ad082 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/404.html @@ -0,0 +1,51 @@ + + + + + + + + + + + + Enso + + + +
+
+
+ + + + + diff --git a/app/ide-desktop/lib/dashboard/bundle.ts b/app/ide-desktop/lib/dashboard/bundle.ts deleted file mode 100644 index 0140f56b9b..0000000000 --- a/app/ide-desktop/lib/dashboard/bundle.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** @file Entry point for the bundler. */ -import * as fs from 'node:fs/promises' -import * as path from 'node:path' -import * as url from 'node:url' - -import * as esbuild from 'esbuild' - -import * as bundler from './esbuild-config' - -// ================= -// === Constants === -// ================= - -export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url))) -export const ANALYZE = process.argv.includes('--analyze') - -// =============== -// === Bundler === -// =============== - -/** Clean up old build output and runs the esbuild bundler. */ -async function bundle() { - try { - try { - await fs.rm('./build', { recursive: true }) - } catch { - // Ignored. - } - const opts = bundler.bundlerOptions({ - outputPath: './build', - devMode: false, - }) - opts.entryPoints.push( - path.resolve(THIS_PATH, 'src', 'index.html'), - path.resolve(THIS_PATH, 'src', 'entrypoint.ts') - ) - opts.metafile = ANALYZE - opts.loader['.html'] = 'copy' - const result = await esbuild.build(opts) - await fs.copyFile('build/index.html', 'build/404.html') - if (result.metafile) { - console.log(await esbuild.analyzeMetafile(result.metafile)) - } - return - } catch (error) { - console.error(error) - // The error is being re-thrown. - // eslint-disable-next-line no-restricted-syntax - throw error - } -} - -void bundle() diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts new file mode 100644 index 0000000000..0db3e3aec9 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -0,0 +1,753 @@ +/** @file Various actions, locators, and constants used in end-to-end tests. */ +import * as test from '@playwright/test' + +import * as apiModule from './api' + +// ================= +// === Constants === +// ================= + +/** An example password that does not meet validation requirements. */ +export const INVALID_PASSWORD = 'password' +/** An example password that meets validation requirements. */ +export const VALID_PASSWORD = 'Password0!' +/** An example valid email address. */ +export const VALID_EMAIL = 'email@example.com' + +// ================ +// === Locators === +// ================ + +// === Input locators === + +/** Find an email input (if any) on the current page. */ +export function locateEmailInput(page: test.Locator | test.Page) { + return page.getByLabel('Email') +} + +/** Find a password input (if any) on the current page. */ +export function locatePasswordInput(page: test.Locator | test.Page) { + return page.getByPlaceholder('Enter your password') +} + +/** Find a "confirm password" input (if any) on the current page. */ +export function locateConfirmPasswordInput(page: test.Locator | test.Page) { + return page.getByLabel('Confirm password') +} + +/** Find an "old password" input (if any) on the current page. */ +export function locateOldPasswordInput(page: test.Locator | test.Page) { + return page.getByLabel('Old password') +} + +/** Find a "new password" input (if any) on the current page. */ +export function locateNewPasswordInput(page: test.Locator | test.Page) { + return page.getByPlaceholder('Enter your new password') +} + +/** Find a "confirm new password" input (if any) on the current page. */ +export function locateConfirmNewPasswordInput(page: test.Locator | test.Page) { + return page.getByPlaceholder('Confirm your new password') +} + +/** Find a "username" input (if any) on the current page. */ +export function locateUsernameInput(page: test.Locator | test.Page) { + return page.getByPlaceholder('Enter your username') +} + +/** Find a "name" input for a "new label" modal (if any) on the current page. */ +export function locateNewLabelModalNameInput(page: test.Locator | test.Page) { + return locateNewLabelModal(page).getByLabel('Name') +} + +/** Find all color radio button inputs for a "new label" modal (if any) on the current page. */ +export function locateNewLabelModalColorButtons(page: test.Locator | test.Page) { + return ( + locateNewLabelModal(page) + .filter({ has: page.getByText('Color') }) + // The `radio` inputs are invisible, so they cannot be used in the locator. + .getByRole('button') + ) +} + +/** Find a "name" input for an "upsert secret" modal (if any) on the current page. */ +export function locateSecretNameInput(page: test.Locator | test.Page) { + return locateUpsertSecretModal(page).getByPlaceholder('Enter the name of the secret') +} + +/** Find a "value" input for an "upsert secret" modal (if any) on the current page. */ +export function locateSecretValueInput(page: test.Locator | test.Page) { + return locateUpsertSecretModal(page).getByPlaceholder('Enter the value of the secret') +} + +/** Find a search bar input (if any) on the current page. */ +export function locateSearchBarInput(page: test.Locator | test.Page) { + return locateSearchBar(page).getByPlaceholder( + 'Type to search for projects, data connectors, users, and more.' + ) +} + +/** Find the name column of the given assets table row. */ +export function locateAssetRowName(locator: test.Locator) { + return locator.getByTestId('asset-row-name') +} + +// === Button locators === + +/** Find a toast close button (if any) on the current page. */ +export function locateToastCloseButton(page: test.Locator | test.Page) { + // There is no other simple way to uniquely identify this element. + // eslint-disable-next-line no-restricted-properties + return page.locator('.Toastify__close-button') +} + +/** Find a login button (if any) on the current page. */ +export function locateLoginButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') +} + +/** Find a register button (if any) on the current page. */ +export function locateRegisterButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Register' }).getByText('Register') +} + +/** Find a reset button (if any) on the current page. */ +export function locateResetButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Reset' }).getByText('Reset') +} + +/** Find a user menu button (if any) on the current page. */ +export function locateUserMenuButton(page: test.Locator | test.Page) { + return page.getByAltText('Open user menu') +} + +/** Find a change password button (if any) on the current page. */ +export function locateChangePasswordButton(page: test.Locator | test.Page) { + return page + .getByRole('button', { name: 'Change your password' }) + .getByText('Change your password') +} + +/** Find a "sign out" button (if any) on the current page. */ +export function locateLogoutButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Logout' }).getByText('Logout') +} + +/** Find a "set username" button (if any) on the current page. */ +export function locateSetUsernameButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Set Username' }).getByText('Set Username') +} + +/** Find a "delete" button (if any) on the current page. */ +export function locateDeleteButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Delete' }).getByText('Delete') +} + +/** Find a button to delete something (if any) on the current page. */ +export function locateDeleteIcon(page: test.Locator | test.Page) { + return page.getByAltText('Delete') +} + +/** Find a "create" button (if any) on the current page. */ +export function locateCreateButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Create' }).getByText('Create') +} + +/** Find a button to open the editor (if any) on the current page. */ +export function locatePlayOrOpenProjectButton(page: test.Locator | test.Page) { + return page.getByAltText('Open in editor') +} + +/** Find a button to close the project (if any) on the current page. */ +export function locateStopProjectButton(page: test.Locator | test.Page) { + return page.getByAltText('Stop execution') +} + +/** Find all labels in the labels panel (if any) on the current page. */ +export function locateLabelsPanelLabels(page: test.Locator | test.Page) { + return ( + locateLabelsPanel(page) + .getByRole('button') + // The delete button is also a `button`. + // eslint-disable-next-line no-restricted-properties + .and(page.locator(':nth-child(1)')) + ) +} + +/** Find a "home" button (if any) on the current page. */ +export function locateHomeButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Home' }).getByText('Home') +} + +/** Find a "trash" button (if any) on the current page. */ +export function locateTrashButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Trash' }).getByText('Trash') +} + +/** Find a tick button (if any) on the current page. */ +export function locateEditingTick(page: test.Locator | test.Page) { + return page.getByAltText('Confirm Edit') +} + +/** Find a cross button (if any) on the current page. */ +export function locateEditingCross(page: test.Locator | test.Page) { + return page.getByAltText('Cancel Edit') +} + +/** Find labels in the "Labels" column of the assets table (if any) on the current page. */ +export function locateAssetLabels(page: test.Locator | test.Page) { + return page.getByTestId('asset-label') +} + +/** Find a toggle for the "Labels" column (if any) on the current page. */ +export function locateLabelsColumnToggle(page: test.Locator | test.Page) { + return page.getByAltText(/^(?:Show|Hide) Labels$/) +} + +/** Find a toggle for the "Accessed by projects" column (if any) on the current page. */ +export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) { + return page.getByAltText(/^(?:Show|Hide) Accessed by projects$/) +} + +/** Find a toggle for the "Accessed data" column (if any) on the current page. */ +export function locateAccessedDataColumnToggle(page: test.Locator | test.Page) { + return page.getByAltText(/^(?:Show|Hide) Accessed data$/) +} + +/** Find a toggle for the "Docs" column (if any) on the current page. */ +export function locateDocsColumnToggle(page: test.Locator | test.Page) { + return page.getByAltText(/^(?:Show|Hide) Docs$/) +} + +/** Find a button for the "Recent" category (if any) on the current page. */ +export function locateRecentCategory(page: test.Locator | test.Page) { + return page.getByTitle('Go To Recent') +} + +/** Find a button for the "Home" category (if any) on the current page. */ +export function locateHomeCategory(page: test.Locator | test.Page) { + return page.getByTitle('Go To Homoe') +} + +/** Find a button for the "Trash" category (if any) on the current page. */ +export function locateTrashCategory(page: test.Locator | test.Page) { + return page.getByTitle('Go To Trash') +} + +// === Context menu buttons === + +/** Find an "open" button (if any) on the current page. */ +export function locateOpenButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Open' }).getByText('Open') +} + +/** Find an "upload to cloud" button (if any) on the current page. */ +export function locateUploadToCloudButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Upload To Cloud' }).getByText('Upload To Cloud') +} + +/** Find a "rename" button (if any) on the current page. */ +export function locateRenameButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Rename' }).getByText('Rename') +} + +/** Find a "snapshot" button (if any) on the current page. */ +export function locateSnapshotButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Snapshot' }).getByText('Snapshot') +} + +/** Find a "move to trash" button (if any) on the current page. */ +export function locateMoveToTrashButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Move To Trash' }).getByText('Move To Trash') +} + +/** Find a "move all to trash" button (if any) on the current page. */ +export function locateMoveAllToTrashButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Move All To Trash' }).getByText('Move All To Trash') +} + +/** Find a "restore from trash" button (if any) on the current page. */ +export function locateRestoreFromTrashButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Restore From Trash' }).getByText('Restore From Trash') +} + +/** Find a "restore all from trash" button (if any) on the current page. */ +export function locateRestoreAllFromTrashButton(page: test.Locator | test.Page) { + return page + .getByRole('button', { name: 'Restore All From Trash' }) + .getByText('Restore All From Trash') +} + +/** Find a "share" button (if any) on the current page. */ +export function locateShareButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Share' }).getByText('Share') +} + +/** Find a "label" button (if any) on the current page. */ +export function locateLabelButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Label' }).getByText('Label') +} + +/** Find a "duplicate" button (if any) on the current page. */ +export function locateDuplicateButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate') +} + +/** Find a "copy" button (if any) on the current page. */ +export function locateCopyButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Copy' }).getByText('Copy') +} + +/** Find a "cut" button (if any) on the current page. */ +export function locateCutButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Cut' }).getByText('Cut') +} + +/** Find a "paste" button (if any) on the current page. */ +export function locatePasteButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Paste' }).getByText('Paste') +} + +/** Find a "download" button (if any) on the current page. */ +export function locateDownloadButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Download' }).getByText('Download') +} + +/** Find a "download app" button (if any) on the current page. */ +export function locateDownloadAppButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Download App' }).getByText('Download App') +} + +/** Find an "upload files" button (if any) on the current page. */ +export function locateUploadFilesButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'Upload Files' }).getByText('Upload Files') +} + +/** Find a "new project" button (if any) on the current page. */ +export function locateNewProjectButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'New Project' }).getByText('New Project') +} + +/** Find a "new folder" button (if any) on the current page. */ +export function locateNewFolderButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'New Folder' }).getByText('New Folder') +} + +/** Find a "new secret" button (if any) on the current page. */ +export function locateNewSecretButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'New Secret' }).getByText('New Secret') +} + +/** Find a "new data connector" button (if any) on the current page. */ +export function locateNewDataConnectorButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'New Data Connector' }).getByText('New Data Connector') +} + +/** Find a "new label" button (if any) on the current page. */ +export function locateNewLabelButton(page: test.Locator | test.Page) { + return page.getByRole('button', { name: 'new label' }).getByText('new label') +} + +/** Find an "upgrade" button (if any) on the current page. */ +export function locateUpgradeButton(page: test.Locator | test.Page) { + return page.getByRole('link', { name: 'Upgrade', exact: true }).getByText('Upgrade') +} + +/** Find a "new folder" icon (if any) on the current page. */ +export function locateNewFolderIcon(page: test.Locator | test.Page) { + return page.getByAltText('New Folder') +} + +/** Find a "new secret" icon (if any) on the current page. */ +export function locateNewSecretIcon(page: test.Locator | test.Page) { + return page.getByAltText('New Secret') +} + +/** Find a "upload files" icon (if any) on the current page. */ +export function locateUploadFilesIcon(page: test.Locator | test.Page) { + return page.getByAltText('Upload Files') +} + +/** Find a "download files" icon (if any) on the current page. */ +export function locateDownloadFilesIcon(page: test.Locator | test.Page) { + return page.getByAltText('Download Files') +} + +/** Find an icon to open or close the asset panel (if any) on the current page. */ +export function locateAssetPanelIcon(page: test.Locator | test.Page) { + return page.getByAltText('Open Asset Panel').or(page.getByAltText('Close Asset Panel')) +} + +/** Find a list of tags in the search bar (if any) on the current page. */ +export function locateSearchBarTags(page: test.Locator | test.Page) { + return locateSearchBar(page).getByTestId('asset-search-tag-names').getByRole('button') +} + +/** Find a list of labels in the search bar (if any) on the current page. */ +export function locateSearchBarLabels(page: test.Locator | test.Page) { + return locateSearchBar(page).getByTestId('asset-search-labels').getByRole('button') +} + +/** Find a list of labels in the search bar (if any) on the current page. */ +export function locateSearchBarSuggestions(page: test.Locator | test.Page) { + return locateSearchBar(page).getByTestId('asset-search-suggestion') +} + +// === Icon locators === + +// These are specifically icons that are not also buttons. +// Icons that *are* buttons belong in the "Button locators" section. + +/** Find a "sort ascending" icon (if any) on the current page. */ +export function locateSortAscendingIcon(page: test.Locator | test.Page) { + return page.getByAltText('Sort Ascending') +} + +/** Find a "sort descending" icon (if any) on the current page. */ +export function locateSortDescendingIcon(page: test.Locator | test.Page) { + return page.getByAltText('Sort Descending') +} + +// === Page locators === + +/** Find a "home page" icon (if any) on the current page. */ +export function locateHomePageIcon(page: test.Locator | test.Page) { + return page.getByAltText('Go to home page') +} + +/** Find a "drive page" icon (if any) on the current page. */ +export function locateDrivePageIcon(page: test.Locator | test.Page) { + return page.getByAltText('Go to drive page') +} + +/** Find an "editor page" icon (if any) on the current page. */ +export function locateEditorPageIcon(page: test.Locator | test.Page) { + return page.getByAltText('Go to editor page') +} + +/** Find a "name" column heading (if any) on the current page. */ +export function locateNameColumnHeading(page: test.Locator | test.Page) { + return page.getByTitle('Sort by name').or(page.getByTitle('Stop sorting by name')) +} + +/** Find a "modified" column heading (if any) on the current page. */ +export function locateModifiedColumnHeading(page: test.Locator | test.Page) { + return page + .getByTitle('Sort by modification date') + .or(page.getByTitle('Stop sorting by modification date')) +} + +// === Container locators === + +/** Find a drive view (if any) on the current page. */ +export function locateDriveView(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} + +/** Find a samples list (if any) on the current page. */ +export function locateSamplesList(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('samples') +} + +/** Find all samples list (if any) on the current page. */ +export function locateSamples(page: test.Locator | test.Page) { + // This has no identifying features. + return locateSamplesList(page).getByRole('button') +} + +/** Find a modal background (if any) on the current page. */ +export function locateModalBackground(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('modal-background') +} + +/** Find an editor container (if any) on the current page. */ +export function locateEditor(page: test.Page) { + // This is fine as this element is defined in `index.html`, rather than from React. + // Using `data-testid` may be more correct though. + // eslint-disable-next-line no-restricted-properties + return page.locator('#root') +} + +/** Find an assets table (if any) on the current page. */ +export function locateAssetsTable(page: test.Page) { + return locateDriveView(page).getByRole('table') +} + +/** Find assets table rows (if any) on the current page. */ +export function locateAssetRows(page: test.Page) { + return locateAssetsTable(page).locator('tbody').getByRole('row') +} + +/** Find the name column of the given asset row. */ +export function locateAssetName(locator: test.Locator) { + return locator.locator('> :nth-child(1)') +} + +/** Find assets table rows that represent directories that can be expanded (if any) + * on the current page. */ +export function locateExpandableDirectories(page: test.Page) { + return locateAssetRows(page).filter({ has: page.getByAltText('Expand') }) +} + +/** Find assets table rows that represent directories that can be collapsed (if any) + * on the current page. */ +export function locateCollapsibleDirectories(page: test.Page) { + return locateAssetRows(page).filter({ has: page.getByAltText('Collapse') }) +} + +/** Find a "change password" modal (if any) on the current page. */ +export function locateChangePasswordModal(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('change-password-modal') +} + +/** Find a "confirm delete" modal (if any) on the current page. */ +export function locateConfirmDeleteModal(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('confirm-delete-modal') +} + +/** Find a "new label" modal (if any) on the current page. */ +export function locateNewLabelModal(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('new-label-modal') +} + +/** Find an "upsert secret" modal (if any) on the current page. */ +export function locateUpsertSecretModal(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('upsert-secret-modal') +} + +/** Find a user menu (if any) on the current page. */ +export function locateUserMenu(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('user-menu') +} + +/** Find a "set username" panel (if any) on the current page. */ +export function locateSetUsernamePanel(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('set-username-panel') +} + +/** Find a set of context menus (if any) on the current page. */ +export function locateContextMenus(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('context-menus') +} + +/** Find a labels panel (if any) on the current page. */ +export function locateLabelsPanel(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('labels') +} + +/** Find a list of labels (if any) on the current page. */ +export function locateLabelsList(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('labels-list') +} + +/** Find an asset panel (if any) on the current page. */ +export function locateAssetPanel(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('asset-panel') +} + +/** Find a search bar (if any) on the current page. */ +export function locateSearchBar(page: test.Locator | test.Page) { + // This has no identifying features. + return page.getByTestId('asset-search-bar') +} + +// === Content locators === + +/** Find an asset description in an asset panel (if any) on the current page. */ +export function locateAssetPanelDescription(page: test.Locator | test.Page) { + // This has no identifying features. + return locateAssetPanel(page).getByTestId('asset-panel-description') +} + +/** Find asset permissions in an asset panel (if any) on the current page. */ +export function locateAssetPanelPermissions(page: test.Locator | test.Page) { + // This has no identifying features. + return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button') +} + +// =============================== +// === Visual layout utilities === +// =============================== + +/** Get the left side of the bounding box of an asset row. The locator MUST be for an asset row. + * DO NOT assume the left side of the outer container will change. This means that it is NOT SAFE + * to do anything with the returned values other than comparing them. */ +export function getAssetRowLeftPx(locator: test.Locator) { + return locator.evaluate(el => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) +} + +// ============================ +// === expectPlaceholderRow === +// ============================ + +/** A test assertion to confirm that there is only one row visible, and that row is the + * placeholder row displayed when there are no assets to show. */ +export async function expectPlaceholderRow(page: test.Page) { + const assetRows = locateAssetRows(page) + await test.test.step('Expect placeholder row', async () => { + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows).toHaveText(/You have no files/) + }) +} + +/** A test assertion to confirm that there is only one row visible, and that row is the + * placeholder row displayed when there are no assets in Trash. */ +export async function expectTrashPlaceholderRow(page: test.Page) { + const assetRows = locateAssetRows(page) + await test.test.step('Expect trash placeholder row', async () => { + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows).toHaveText(/Your trash is empty/) + }) +} + +// ========================== +// === Keyboard utilities === +// ========================== + +/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ +export async function modModifier(page: test.Page) { + let userAgent = '' + await test.test.step('Detect browser OS', async () => { + userAgent = await page.evaluate(() => navigator.userAgent) + }) + return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' +} + +/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` + * on all other platforms. */ +export async function press(page: test.Page, keyOrShortcut: string) { + if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { + let userAgent = '' + await test.test.step('Detect browser OS', async () => { + userAgent = await page.evaluate(() => navigator.userAgent) + }) + // This should be `Meta` (`Cmd`) on macOS, and `Control` on all other systems + const ctrlKey = /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' + const deleteKey = /\bMac OS\b/i.test(userAgent) ? 'Backspace' : 'Delete' + await page.keyboard.press( + keyOrShortcut.replace(/\bMod\b/g, ctrlKey).replace(/\bDelete\b/, deleteKey) + ) + } else { + await page.keyboard.press(keyOrShortcut) + } +} + +// ============= +// === login === +// ============= + +/** Perform a successful login. */ +// This syntax is required for Playwright to work properly. +// eslint-disable-next-line no-restricted-syntax +export async function login( + { page }: MockParams, + email = 'email@example.com', + password = VALID_PASSWORD +) { + await page.goto('/') + await locateEmailInput(page).fill(email) + await locatePasswordInput(page).fill(password) + await locateLoginButton(page).click() + await locateToastCloseButton(page).click() +} + +// ================ +// === mockDate === +// ================ + +/** A placeholder date for visual regression testing. */ +const MOCK_DATE = Number(new Date('01/23/45 01:23:45')) + +/** Parameters for {@link mockDate}. */ +interface MockParams { + page: test.Page +} + +/** Replace `Date` with a version that returns a fixed time. */ +// This syntax is required for Playwright to work properly. +// eslint-disable-next-line no-restricted-syntax +async function mockDate({ page }: MockParams) { + // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 + await page.addInitScript(`{ + Date = class extends Date { + constructor(...args) { + if (args.length === 0) { + super(${MOCK_DATE}); + } else { + super(...args); + } + } + } + const __DateNowOffset = ${MOCK_DATE} - Date.now(); + const __DateNow = Date.now; + Date.now = () => __DateNow() + __DateNowOffset; + }`) +} + +// ======================== +// === mockIDEContainer === +// ======================== + +/** Make the IDE container have a non-zero size. */ +// This syntax is required for Playwright to work properly. +// eslint-disable-next-line no-restricted-syntax +export async function mockIDEContainer({ page }: MockParams) { + await page.evaluate(() => { + const ideContainer = document.getElementById('root') + if (ideContainer) { + ideContainer.style.height = '100vh' + ideContainer.style.width = '100vw' + } + }) +} + +// =============== +// === mockApi === +// =============== + +// This is a function, even though it does not use function syntax. +// eslint-disable-next-line no-restricted-syntax +export const mockApi = apiModule.mockApi + +// =============== +// === mockAll === +// =============== + +/** Set up all mocks, without logging in. */ +// This syntax is required for Playwright to work properly. +// eslint-disable-next-line no-restricted-syntax +export async function mockAll({ page }: MockParams) { + const api = await mockApi({ page }) + await mockDate({ page }) + await mockIDEContainer({ page }) + return { api } +} + +// ======================= +// === mockAllAndLogin === +// ======================= + +/** Set up all mocks, and log in with dummy credentials. */ +// This syntax is required for Playwright to work properly. +// eslint-disable-next-line no-restricted-syntax +export async function mockAllAndLogin({ page }: MockParams) { + const mocks = await mockAll({ page }) + await login({ page }) + // This MUST run after login, otherwise the element's styles are reset when the browser + // is navigated to another page. + await mockIDEContainer({ page }) + return mocks +} diff --git a/app/ide-desktop/lib/dashboard/e2e/api.ts b/app/ide-desktop/lib/dashboard/e2e/api.ts new file mode 100644 index 0000000000..a593c01354 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/api.ts @@ -0,0 +1,714 @@ +/** @file The mock API. */ +import * as test from '@playwright/test' + +import * as object from '#/utilities/object' +import * as permissions from '#/utilities/permissions' + +import * as backend from '../src/services/Backend' +import type * as remoteBackend from '../src/services/RemoteBackend' +import * as remoteBackendPaths from '../src/services/remoteBackendPaths' +import * as config from '../src/utilities/config' +import * as dateTime from '../src/utilities/dateTime' +import * as uniqueString from '../src/utilities/uniqueString' + +// ================= +// === Constants === +// ================= + +/** The HTTP status code representing a response with an empty body. */ +const HTTP_STATUS_NO_CONTENT = 204 +/** The HTTP status code representing a bad request. */ +const HTTP_STATUS_BAD_REQUEST = 400 +/** The HTTP status code representing a URL that does not exist. */ +const HTTP_STATUS_NOT_FOUND = 404 +/** An asset ID that is a path glob. */ +const GLOB_ASSET_ID: backend.AssetId = backend.DirectoryId('*') +/** A directory ID that is a path glob. */ +const GLOB_DIRECTORY_ID = backend.DirectoryId('*') +/** A project ID that is a path glob. */ +const GLOB_PROJECT_ID = backend.ProjectId('*') +/** A tag ID that is a path glob. */ +const GLOB_TAG_ID = backend.TagId('*') +/* eslint-enable no-restricted-syntax */ +const BASE_URL = config.ACTIVE_CONFIG.apiUrl + '/' + +// =============== +// === mockApi === +// =============== + +/** Parameters for {@link mockApi}. */ +interface MockParams { + page: test.Page +} + +/** Add route handlers for the mock API to a page. */ +// This syntax is required for Playwright to work properly. +// eslint-disable-next-line no-restricted-syntax +export async function mockApi({ page }: MockParams) { + // eslint-disable-next-line no-restricted-syntax + const defaultEmail = 'email@example.com' as backend.EmailAddress + const defaultUsername = 'user name' + const defaultOrganizationId = backend.UserOrOrganizationId('organization-placeholder id') + const defaultDirectoryId = backend.DirectoryId('directory-placeholder id') + const defaultUser: backend.UserOrOrganization = { + email: defaultEmail, + name: defaultUsername, + id: defaultOrganizationId, + profilePicture: null, + isEnabled: true, + rootDirectoryId: defaultDirectoryId, + } + let currentUser: backend.UserOrOrganization | null = defaultUser + const assetMap = new Map() + const deletedAssets = new Set() + const assets: backend.AnyAsset[] = [] + const labels: backend.Label[] = [] + const labelsByValue = new Map() + const labelMap = new Map() + + const addAsset = (asset: T) => { + assets.push(asset) + assetMap.set(asset.id, asset) + return asset + } + + const deleteAsset = (assetId: backend.AssetId) => { + deletedAssets.add(assetId) + } + + const undeleteAsset = (assetId: backend.AssetId) => { + deletedAssets.delete(assetId) + } + + const createDirectory = ( + title: string, + rest: Partial = {} + ): backend.DirectoryAsset => + object.merge( + { + type: backend.AssetType.directory, + id: backend.DirectoryId('directory-' + uniqueString.uniqueString()), + projectState: null, + title, + modifiedAt: dateTime.toRfc3339(new Date()), + description: null, + labels: [], + parentId: defaultDirectoryId, + permissions: [], + }, + rest + ) + + const createProject = ( + title: string, + rest: Partial = {} + ): backend.ProjectAsset => + object.merge( + { + type: backend.AssetType.project, + id: backend.ProjectId('project-' + uniqueString.uniqueString()), + projectState: { + type: backend.ProjectState.opened, + // eslint-disable-next-line @typescript-eslint/naming-convention + volume_id: '', + }, + title, + modifiedAt: dateTime.toRfc3339(new Date()), + description: null, + labels: [], + parentId: defaultDirectoryId, + permissions: [], + }, + rest + ) + + const createFile = (title: string, rest: Partial = {}): backend.FileAsset => + object.merge( + { + type: backend.AssetType.file, + id: backend.FileId('file-' + uniqueString.uniqueString()), + projectState: null, + title, + modifiedAt: dateTime.toRfc3339(new Date()), + description: null, + labels: [], + parentId: defaultDirectoryId, + permissions: [], + }, + rest + ) + + const createSecret = ( + title: string, + rest: Partial = {} + ): backend.SecretAsset => + object.merge( + { + type: backend.AssetType.secret, + id: backend.SecretId('secret-' + uniqueString.uniqueString()), + projectState: null, + title, + modifiedAt: dateTime.toRfc3339(new Date()), + description: null, + labels: [], + parentId: defaultDirectoryId, + permissions: [], + }, + rest + ) + + const createLabel = (value: string, color: backend.LChColor): backend.Label => ({ + id: backend.TagId('tag-' + uniqueString.uniqueString()), + value: backend.LabelName(value), + color, + }) + + const addDirectory = (title: string, rest?: Partial) => { + return addAsset(createDirectory(title, rest)) + } + + const addProject = (title: string, rest?: Partial) => { + return addAsset(createProject(title, rest)) + } + + const addFile = (title: string, rest?: Partial) => { + return addAsset(createFile(title, rest)) + } + + const addSecret = (title: string, rest?: Partial) => { + return addAsset(createSecret(title, rest)) + } + + const addLabel = (value: string, color: backend.LChColor) => { + const label = createLabel(value, color) + labels.push(label) + labelsByValue.set(label.value, label) + labelMap.set(label.id, label) + return label + } + + const setLabels = (id: backend.AssetId, newLabels: backend.LabelName[]) => { + const ids = new Set([id]) + for (const [innerId, asset] of assetMap) { + if (ids.has(asset.parentId)) { + ids.add(innerId) + } + } + for (const innerId of ids) { + const asset = assetMap.get(innerId) + if (asset != null) { + asset.labels = newLabels + } + } + } + + await test.test.step('Mock API', async () => { + await page.route('https://www.google-analytics.com/**', async route => { + await route.fulfill() + }) + + await page.route('https://www.googletagmanager.com/gtag/js*', async route => { + await route.fulfill({ + contentType: 'text/javascript', + body: 'export {};', + }) + }) + + const isOnline = await page.evaluate(() => navigator.onLine) + + if (!isOnline) { + await page.route('https://fonts.googleapis.com/*', async route => { + await route.abort() + }) + } + + await page.route(BASE_URL + '**', (_route, request) => { + throw new Error(`Missing route handler for '${request.url().replace(BASE_URL, '')}'.`) + }) + + // === Endpoints returning arrays === + + await page.route( + BASE_URL + remoteBackendPaths.LIST_DIRECTORY_PATH + '*', + async (route, request) => { + /** The type for the search query for this endpoint. */ + interface Query { + /* eslint-disable @typescript-eslint/naming-convention */ + parent_id?: string + filter_by?: backend.FilterBy + labels?: backend.LabelName[] + recent_projects?: boolean + /* eslint-enable @typescript-eslint/naming-convention */ + } + // The type of the body sent by this app is statically known. + // eslint-disable-next-line no-restricted-syntax + const body = Object.fromEntries( + new URL(request.url()).searchParams.entries() + ) as unknown as Query + const parentId = body.parent_id ?? defaultDirectoryId + let filteredAssets = assets.filter(asset => asset.parentId === parentId) + // This lint rule is broken; there is clearly a case for `undefined` below. + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check + switch (body.filter_by) { + case backend.FilterBy.active: { + filteredAssets = filteredAssets.filter(asset => !deletedAssets.has(asset.id)) + break + } + case backend.FilterBy.trashed: { + filteredAssets = filteredAssets.filter(asset => deletedAssets.has(asset.id)) + break + } + case backend.FilterBy.recent: { + filteredAssets = assets + .filter(asset => !deletedAssets.has(asset.id)) + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + .slice(0, 10) + break + } + case backend.FilterBy.all: + case null: { + // do nothing + break + } + // eslint-disable-next-line no-restricted-syntax + case undefined: { + // do nothing + break + } + } + filteredAssets.sort( + (a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type] + ) + await route.fulfill({ + json: { + assets: filteredAssets, + } satisfies remoteBackend.ListDirectoryResponseBody, + }) + } + ) + await page.route(BASE_URL + remoteBackendPaths.LIST_FILES_PATH + '*', async route => { + await route.fulfill({ + json: { files: [] } satisfies remoteBackend.ListFilesResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_PROJECTS_PATH + '*', async route => { + await route.fulfill({ + json: { projects: [] } satisfies remoteBackend.ListProjectsResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_SECRETS_PATH + '*', async route => { + await route.fulfill({ + json: { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_TAGS_PATH + '*', async route => { + await route.fulfill({ + json: { tags: labels } satisfies remoteBackend.ListTagsResponseBody, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.LIST_USERS_PATH + '*', async route => { + await route.fulfill({ + json: { users: [] } satisfies remoteBackend.ListUsersResponseBody, + }) + }) + await page.route( + BASE_URL + remoteBackendPaths.LIST_VERSIONS_PATH + '*', + async (route, request) => { + await route.fulfill({ + json: { + versions: [ + { + ami: null, + created: dateTime.toRfc3339(new Date()), + number: { + lifecycle: + // eslint-disable-next-line no-restricted-syntax + 'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development, + value: '2023.2.1-dev', + }, + // eslint-disable-next-line @typescript-eslint/naming-convention, no-restricted-syntax + version_type: (new URL(request.url()).searchParams.get('version_type') ?? + '') as backend.VersionType, + } satisfies backend.Version, + ], + }, + }) + } + ) + + // === Unimplemented endpoints === + + await page.route( + BASE_URL + remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), + async (route, request) => { + const projectId = request.url().match(/[/]projects[/](.+?)[/]copy/)?.[1] ?? '' + await route.fulfill({ + json: { + /* eslint-disable @typescript-eslint/naming-convention */ + organizationId: defaultOrganizationId, + projectId: backend.ProjectId(projectId), + name: 'example project name', + state: { + type: backend.ProjectState.opened, + volume_id: '', + opened_by: defaultEmail, + }, + packageName: 'Project_root', + ide_version: null, + engine_version: { + value: '2023.2.1-nightly.2023.9.29', + lifecycle: backend.VersionLifecycle.development, + }, + address: backend.Address('ws://example.com/'), + /* eslint-enable @typescript-eslint/naming-convention */ + } satisfies backend.ProjectRaw, + }) + } + ) + + // === Endpoints returning `void` === + + await page.route( + BASE_URL + remoteBackendPaths.copyAssetPath(GLOB_ASSET_ID), + async (route, request) => { + /** The type for the JSON request payload for this endpoint. */ + interface Body { + parentDirectoryId: backend.DirectoryId + } + const assetId = request.url().match(/[/]assets[/](.+?)[/]copy/)?.[1] + // eslint-disable-next-line no-restricted-syntax + const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null + if (asset == null) { + if (assetId == null) { + await route.fulfill({ + status: HTTP_STATUS_BAD_REQUEST, + json: { error: 'Invalid Asset ID' }, + }) + } else { + await route.fulfill({ + status: HTTP_STATUS_NOT_FOUND, + json: { error: 'Asset does not exist' }, + }) + } + } else { + // The type of the body sent by this app is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: Body = await request.postDataJSON() + const parentId = body.parentDirectoryId + // Can be any asset ID. + const id = backend.DirectoryId(uniqueString.uniqueString()) + const json: backend.CopyAssetResponse = { + asset: { + id, + parentId, + title: asset.title + ' (copy)', + }, + } + const newAsset = { ...asset } + newAsset.id = id + newAsset.parentId = parentId + newAsset.title += ' (copy)' + addAsset(newAsset) + await route.fulfill({ json }) + } + } + ) + await page.route(BASE_URL + remoteBackendPaths.INVITE_USER_PATH + '*', async route => { + await route.fulfill() + }) + await page.route(BASE_URL + remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async route => { + await route.fulfill() + }) + await page.route(BASE_URL + remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async route => { + await route.fulfill() + }) + await page.route( + BASE_URL + remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), + async route => { + await route.fulfill() + } + ) + await page.route( + BASE_URL + remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), + async route => { + await route.fulfill() + } + ) + await page.route(BASE_URL + remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => { + await route.fulfill() + }) + + // === Other endpoints === + + await page.route( + BASE_URL + remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), + async (route, request) => { + if (request.method() === 'PATCH') { + const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '' + // The type of the body sent by this app is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.UpdateAssetRequestBody = request.postDataJSON() + // This could be an id for an arbitrary asset, but pretend it's a + // `DirectoryId` to make TypeScript happy. + const asset = assetMap.get(backend.DirectoryId(assetId)) + if (asset != null) { + if (body.description != null) { + asset.description = body.description + } + } + } else { + await route.fallback() + } + } + ) + await page.route( + BASE_URL + remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), + async (route, request) => { + if (request.method() === 'PATCH') { + const assetId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] ?? '' + /** The type for the JSON request payload for this endpoint. */ + interface Body { + labels: backend.LabelName[] + } + /** The type for the JSON response payload for this endpoint. */ + interface Response { + tags: backend.Label[] + } + // The type of the body sent by this app is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: Body = await request.postDataJSON() + // This could be an id for an arbitrary asset, but pretend it's a + // `DirectoryId` to make TypeScript happy. + setLabels(backend.DirectoryId(assetId), body.labels) + const json: Response = { + tags: body.labels.flatMap(value => { + const label = labelsByValue.get(value) + return label != null ? [label] : [] + }), + } + await route.fulfill({ json }) + } else { + await route.fallback() + } + } + ) + await page.route( + BASE_URL + remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), + async (route, request) => { + if (request.method() === 'PUT') { + const directoryId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] ?? '' + // The type of the body sent by this app is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.UpdateDirectoryRequestBody = request.postDataJSON() + const asset = assetMap.get(backend.DirectoryId(directoryId)) + if (asset == null) { + await route.abort() + } else { + asset.title = body.title + await route.fulfill({ + json: { + id: backend.DirectoryId(directoryId), + parentId: asset.parentId, + title: body.title, + } satisfies backend.UpdatedDirectory, + }) + } + } else { + await route.fallback() + } + } + ) + await page.route( + BASE_URL + remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), + async (route, request) => { + if (request.method() === 'DELETE') { + const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '' + // This could be an id for an arbitrary asset, but pretend it's a + // `DirectoryId` to make TypeScript happy. + deleteAsset(backend.DirectoryId(assetId)) + await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) + } else { + await route.fallback() + } + } + ) + await page.route( + BASE_URL + remoteBackendPaths.UNDO_DELETE_ASSET_PATH, + async (route, request) => { + if (request.method() === 'PATCH') { + /** The type for the JSON request payload for this endpoint. */ + interface Body { + assetId: backend.AssetId + } + // The type of the body sent by this app is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: Body = await request.postDataJSON() + undeleteAsset(body.assetId) + await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) + } else { + await route.fallback() + } + } + ) + await page.route( + BASE_URL + remoteBackendPaths.CREATE_USER_PATH + '*', + async (route, request) => { + if (request.method() === 'POST') { + // The type of the body sent by this app is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.CreateUserRequestBody = await request.postDataJSON() + const id = body.organizationId ?? defaultUser.id + const rootDirectoryId = backend.DirectoryId(id.replace(/^organization-/, 'directory-')) + currentUser = { + email: body.userEmail, + name: body.userName, + id: body.organizationId ?? defaultUser.id, + profilePicture: null, + isEnabled: false, + rootDirectoryId, + } + await route.fulfill({ json: currentUser }) + } else if (request.method() === 'GET') { + if (currentUser != null) { + await route.fulfill({ json: [] }) + } else { + await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) + } + } + } + ) + await page.route(BASE_URL + remoteBackendPaths.USERS_ME_PATH + '*', async route => { + await route.fulfill({ + json: currentUser, + }) + }) + await page.route(BASE_URL + remoteBackendPaths.CREATE_TAG_PATH + '*', async route => { + if (route.request().method() === 'POST') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.CreateTagRequestBody = route.request().postDataJSON() + const json: backend.Label = { + id: backend.TagId(`tag-${uniqueString.uniqueString()}`), + value: backend.LabelName(body.value), + color: body.color, + } + await route.fulfill({ json }) + } else { + await route.fallback() + } + }) + await page.route( + BASE_URL + remoteBackendPaths.CREATE_PROJECT_PATH + '*', + async (route, request) => { + if (request.method() === 'POST') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.CreateProjectRequestBody = request.postDataJSON() + const title = body.projectName + const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`) + const parentId = + body.parentDirectoryId ?? + backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + const json: backend.CreatedProject = { + name: title, + organizationId: defaultOrganizationId, + packageName: 'Project_root', + projectId: id, + // eslint-disable-next-line @typescript-eslint/naming-convention + state: { type: backend.ProjectState.opened, volume_id: '' }, + } + addProject(title, { + description: null, + id, + labels: [], + modifiedAt: dateTime.toRfc3339(new Date()), + parentId, + permissions: [ + { + user: { + pk: backend.Subject(''), + /* eslint-disable @typescript-eslint/naming-convention */ + user_name: defaultUsername, + user_email: defaultEmail, + organization_id: defaultOrganizationId, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + permission: permissions.PermissionAction.own, + }, + ], + projectState: json.state, + }) + await route.fulfill({ json }) + } else { + await route.fallback() + } + } + ) + await page.route( + BASE_URL + remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', + async (route, request) => { + if (request.method() === 'POST') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const body: backend.CreateDirectoryRequestBody = request.postDataJSON() + const title = body.title + const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + const parentId = + body.parentId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) + const json: backend.CreatedDirectory = { title, id, parentId } + addDirectory(title, { + description: null, + id, + labels: [], + modifiedAt: dateTime.toRfc3339(new Date()), + parentId, + permissions: [ + { + user: { + pk: backend.Subject(''), + /* eslint-disable @typescript-eslint/naming-convention */ + user_name: defaultUsername, + user_email: defaultEmail, + organization_id: defaultOrganizationId, + /* eslint-enable @typescript-eslint/naming-convention */ + }, + permission: permissions.PermissionAction.own, + }, + ], + projectState: null, + }) + await route.fulfill({ json }) + } else { + await route.fallback() + } + } + ) + }) + + return { + defaultEmail, + defaultName: defaultUsername, + defaultOrganizationId, + defaultUser, + rootDirectoryId: defaultDirectoryId, + /** Returns the current value of `currentUser`. This is a getter, so its return value + * SHOULD NOT be cached. */ + get currentUser() { + return currentUser + }, + setCurrentUser: (user: backend.UserOrOrganization | null) => { + currentUser = user + }, + addAsset, + deleteAsset, + undeleteAsset, + createDirectory, + createProject, + createFile, + createSecret, + addDirectory, + addProject, + addFile, + addSecret, + createLabel, + addLabel, + setLabels, + } +} diff --git a/app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts new file mode 100644 index 0000000000..7e11fa364a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts @@ -0,0 +1,54 @@ +/** @file Tests for the asset panel. */ +import * as test from '@playwright/test' + +import * as backend from '#/services/Backend' + +import * as permissions from '#/utilities/permissions' + +import * as actions from './actions' + +test.test('open and close asset panel', async ({ page }) => { + await actions.mockAllAndLogin({ page }) + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + await assetRows.nth(0).click() + await test.expect(actions.locateAssetPanel(page)).not.toBeVisible() + await actions.locateAssetPanelIcon(page).click() + await test.expect(actions.locateAssetPanel(page)).toBeVisible() + await actions.locateAssetPanelIcon(page).click() + await test.expect(actions.locateAssetPanel(page)).not.toBeVisible() +}) + +test.test('asset panel contents', async ({ page }) => { + const { api } = await actions.mockAll({ page }) + const { defaultOrganizationId } = api + const assetRows = actions.locateAssetRows(page) + const description = 'foo bar' + const username = 'baz quux' + const email = 'baz.quux@email.com' + api.addProject('project', { + description, + permissions: [ + { + permission: permissions.PermissionAction.own, + user: { + /* eslint-disable @typescript-eslint/naming-convention */ + pk: backend.Subject(''), + organization_id: defaultOrganizationId, + user_name: username, + user_email: backend.EmailAddress(email), + /* eslint-enable @typescript-eslint/naming-convention */ + }, + }, + ], + }) + await page.goto('/') + await actions.login({ page }) + + await assetRows.nth(0).click() + await actions.locateAssetPanelIcon(page).click() + await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(description) + // `getByText` is required so that this assertion works if there are multiple permissions. + await test.expect(actions.locateAssetPanelPermissions(page).getByText(username)).toBeVisible() +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts new file mode 100644 index 0000000000..f14aeba39a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts @@ -0,0 +1,118 @@ +/** @file Test the search bar and its suggestions. */ +import * as test from '@playwright/test' + +import * as backend from '#/services/Backend' + +import * as actions from './actions' + +test.test('tags', async ({ page }) => { + await actions.mockAllAndLogin({ page }) + const searchBarInput = actions.locateSearchBarInput(page) + const tags = actions.locateSearchBarTags(page) + + await searchBarInput.click() + for (const positiveTag of await tags.all()) { + await searchBarInput.selectText() + await searchBarInput.press('Backspace') + const text = (await positiveTag.textContent()) ?? '' + test.expect(text.length).toBeGreaterThan(0) + await positiveTag.click() + await test.expect(searchBarInput).toHaveValue(text) + } + + await page.keyboard.down('Shift') + for (const negativeTag of await tags.all()) { + await searchBarInput.selectText() + await searchBarInput.press('Backspace') + const text = (await negativeTag.textContent()) ?? '' + test.expect(text.length).toBeGreaterThan(0) + await negativeTag.click() + await test.expect(searchBarInput).toHaveValue(text) + } +}) + +test.test('labels', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const searchBarInput = actions.locateSearchBarInput(page) + const labels = actions.locateSearchBarLabels(page) + api.addLabel('aaaa', backend.COLORS[0]) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('bbbb', backend.COLORS[1]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('cccc', backend.COLORS[2]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('dddd', backend.COLORS[3]!) + await actions.login({ page }) + + await searchBarInput.click() + for (const label of await labels.all()) { + const name = (await label.textContent()) ?? '' + test.expect(name.length).toBeGreaterThan(0) + await label.click() + await test.expect(searchBarInput).toHaveValue('label:' + name) + await label.click() + await test.expect(searchBarInput).toHaveValue('-label:' + name) + await label.click() + await test.expect(searchBarInput).toHaveValue('') + } +}) + +test.test('suggestions', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const searchBarInput = actions.locateSearchBarInput(page) + const suggestions = actions.locateSearchBarSuggestions(page) + api.addDirectory('foo') + api.addProject('bar') + api.addSecret('baz') + api.addSecret('quux') + await actions.login({ page }) + + await searchBarInput.click() + for (const suggestion of await suggestions.all()) { + const name = (await suggestion.textContent()) ?? '' + test.expect(name.length).toBeGreaterThan(0) + await suggestion.click() + await test.expect(searchBarInput).toHaveValue('name:' + name) + await searchBarInput.selectText() + await searchBarInput.press('Backspace') + } +}) + +test.test('suggestions (keyboard)', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const searchBarInput = actions.locateSearchBarInput(page) + const suggestions = actions.locateSearchBarSuggestions(page) + api.addDirectory('foo') + api.addProject('bar') + api.addSecret('baz') + api.addSecret('quux') + await actions.login({ page }) + + await searchBarInput.click() + for (const suggestion of await suggestions.all()) { + const name = (await suggestion.textContent()) ?? '' + test.expect(name.length).toBeGreaterThan(0) + await page.press('body', 'Tab') + await test.expect(searchBarInput).toHaveValue('name:' + name) + } +}) + +test.test('complex flows', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const searchBarInput = actions.locateSearchBarInput(page) + const firstName = 'foo' + api.addDirectory(firstName) + api.addProject('bar') + api.addSecret('baz') + api.addSecret('quux') + await actions.login({ page }) + + await searchBarInput.click() + await page.press('body', 'Tab') + await test.expect(searchBarInput).toHaveValue('name:' + firstName) + await searchBarInput.selectText() + await searchBarInput.press('Backspace') + await test.expect(searchBarInput).toHaveValue('') + await page.press('body', 'Tab') + await test.expect(searchBarInput).toHaveValue('name:' + firstName) +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts b/app/ide-desktop/lib/dashboard/e2e/changePasswordModal.spec.ts similarity index 77% rename from app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts rename to app/ide-desktop/lib/dashboard/e2e/changePasswordModal.spec.ts index 5b8e70aacb..3b753bf6be 100644 --- a/app/ide-desktop/lib/dashboard/test-e2e/changePasswordModal.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/changePasswordModal.spec.ts @@ -2,18 +2,16 @@ import * as test from '@playwright/test' import * as actions from './actions' -import * as api from './api' + +test.test.beforeEach(actions.mockAllAndLogin) test.test('change password modal', async ({ page }) => { - await api.mockApi(page) - await actions.login(page) - - // Screenshot #1: Change password modal + // Change password modal await actions.locateUserMenuButton(page).click() await actions.locateChangePasswordButton(page).click() - await test.expect(actions.locateChangePasswordModal(page)).toHaveScreenshot() + await test.expect(actions.locateChangePasswordModal(page)).toBeVisible() - // Screenshot #2: Invalid old password + // Invalid old password await actions.locateOldPasswordInput(page).fill(actions.INVALID_PASSWORD) test .expect( @@ -23,7 +21,7 @@ test.test('change password modal', async ({ page }) => { .toBe(false) await actions.locateResetButton(page).click() - // Screenshot #3: Invalid new password + // Invalid new password await actions.locateOldPasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) test @@ -34,18 +32,18 @@ test.test('change password modal', async ({ page }) => { .toBe(false) await actions.locateResetButton(page).click() - // Screenshot #4: Invalid "confirm new password" + // Invalid new password confirmation await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD) test .expect( await page.evaluate(() => document.querySelector('form')?.checkValidity()), - 'form should reject invalid "confirm new password"' + 'form should reject invalid new password confirmation' ) .toBe(false) await actions.locateResetButton(page).click() - // Screenshot #5: After form submission + // After form submission await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateResetButton(page).click() await test.expect(actions.locateChangePasswordModal(page)).not.toBeAttached() diff --git a/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts b/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts new file mode 100644 index 0000000000..fd75b27d70 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts @@ -0,0 +1,171 @@ +/** @file Test copying, moving, cutting and pasting. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('copy', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 2, 1: Folder 1] + await assetRows.nth(0).click({ button: 'right' }) + await test.expect(actions.locateContextMenus(page)).toBeVisible() + await actions.locateCopyButton(page).click() + // Assets: [0: Folder 2 , 1: Folder 1] + await test.expect(actions.locateContextMenus(page)).not.toBeVisible() + await assetRows.nth(1).click({ button: 'right' }) + await test.expect(actions.locateContextMenus(page)).toBeVisible() + await actions.locatePasteButton(page).click() + // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] + await test.expect(assetRows).toHaveCount(3) + await test.expect(assetRows.nth(2)).toBeVisible() + await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/) + const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) + const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2)) + test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) +}) + +test.test('copy (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 2, 1: Folder 1] + await assetRows.nth(0).click() + await actions.press(page, 'Mod+C') + // Assets: [0: Folder 2 , 1: Folder 1] + await assetRows.nth(1).click() + await actions.press(page, 'Mod+V') + // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] + await test.expect(assetRows).toHaveCount(3) + await test.expect(assetRows.nth(2)).toBeVisible() + await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/) + const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) + const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2)) + test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) +}) + +test.test('move', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 2, 1: Folder 1] + await assetRows.nth(0).click({ button: 'right' }) + await test.expect(actions.locateContextMenus(page)).toBeVisible() + await actions.locateCutButton(page).click() + // Assets: [0: Folder 2 , 1: Folder 1] + await test.expect(actions.locateContextMenus(page)).not.toBeVisible() + await assetRows.nth(1).click({ button: 'right' }) + await test.expect(actions.locateContextMenus(page)).toBeVisible() + await actions.locatePasteButton(page).click() + // Assets: [0: Folder 1, 1: Folder 2 ] + await test.expect(assetRows).toHaveCount(2) + await test.expect(assetRows.nth(1)).toBeVisible() + await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/) + const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0)) + const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) + test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) +}) + +test.test('move (drag)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 2, 1: Folder 1] + await assetRows.nth(0).dragTo(assetRows.nth(1)) + // Assets: [0: Folder 1, 1: Folder 2 ] + await test.expect(assetRows).toHaveCount(2) + await test.expect(assetRows.nth(1)).toBeVisible() + await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/) + const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0)) + const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) + test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) +}) + +test.test('move to trash', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + await actions.locateNewFolderIcon(page).click() + await page.keyboard.down(await actions.modModifier(page)) + await assetRows.nth(0).click() + await assetRows.nth(1).click() + await assetRows.nth(0).dragTo(actions.locateTrashCategory(page)) + await page.keyboard.up(await actions.modModifier(page)) + await actions.expectPlaceholderRow(page) + await actions.locateTrashCategory(page).click() + await test.expect(assetRows).toHaveCount(2) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1/) + await test.expect(assetRows.nth(1)).toBeVisible() + await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/) +}) + +test.test('move (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 2, 1: Folder 1] + await assetRows.nth(0).click() + await actions.press(page, 'Mod+X') + // Assets: [0: Folder 2 , 1: Folder 1] + await assetRows.nth(1).click() + await actions.press(page, 'Mod+V') + // Assets: [0: Folder 1, 1: Folder 2 ] + await test.expect(assetRows).toHaveCount(2) + await test.expect(assetRows.nth(1)).toBeVisible() + await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/) + const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0)) + const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) + test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) +}) + +test.test('cut (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + await assetRows.nth(0).click() + await actions.press(page, 'Mod+X') + test + .expect(await assetRows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity))) + .toBeLessThan(1) +}) + +test.test('duplicate', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await assetRows.nth(0).click({ button: 'right' }) + await test.expect(actions.locateContextMenus(page)).toBeVisible() + await actions.locateDuplicateButton(page).click() + // Assets: [0: Folder 1 (copy), 1: Folder 1] + await test.expect(assetRows).toHaveCount(2) + await test.expect(actions.locateContextMenus(page)).not.toBeVisible() + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/) +}) + +test.test('duplicate (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await assetRows.nth(0).click() + await actions.press(page, 'Mod+D') + // Assets: [0: Folder 1 (copy), 1: Folder 1] + await test.expect(assetRows).toHaveCount(2) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts new file mode 100644 index 0000000000..a5e2cd7ce1 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts @@ -0,0 +1,60 @@ +/** @file Test copying, moving, cutting and pasting. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('create folder', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + // Assets: [0: Folder 1] + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1/) +}) + +test.test('create project', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewProjectButton(page).click() + // Assets: [0: Project 1] + await test.expect(assetRows).toHaveCount(1) + await test.expect(actions.locateEditor(page)).toBeVisible() +}) + +test.test('upload file', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + const fileChooserPromise = page.waitForEvent('filechooser') + await actions.locateUploadFilesIcon(page).click() + const fileChooser = await fileChooserPromise + const name = 'foo.txt' + const content = 'hello world' + await fileChooser.setFiles([ + { + name, + buffer: Buffer.from(content), + mimeType: 'text/plain', + }, + ]) + + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + name)) +}) + +test.test('create secret', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewSecretIcon(page).click() + const name = 'a secret name' + const value = 'a secret value' + await actions.locateSecretNameInput(page).fill(name) + await actions.locateSecretValueInput(page).fill(value) + await actions.locateCreateButton(page).click() + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + name)) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/delete.spec.ts b/app/ide-desktop/lib/dashboard/e2e/delete.spec.ts new file mode 100644 index 0000000000..e401b4c011 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/delete.spec.ts @@ -0,0 +1,50 @@ +/** @file Test copying, moving, cutting and pasting. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('delete and restore', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + const contextMenu = actions.locateContextMenus(page) + + await actions.locateNewFolderIcon(page).click() + await test.expect(assetRows).toHaveCount(1) + + await assetRows.nth(0).click({ button: 'right' }) + await actions.locateMoveToTrashButton(contextMenu).click() + + await actions.expectPlaceholderRow(page) + + await actions.locateTrashButton(page).click() + await test.expect(assetRows).toHaveCount(1) + + await assetRows.nth(0).click({ button: 'right' }) + await actions.locateRestoreFromTrashButton(contextMenu).click() + await actions.expectTrashPlaceholderRow(page) + + await actions.locateHomeButton(page).click() + await test.expect(assetRows).toHaveCount(1) +}) + +test.test('delete and restore (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + await test.expect(assetRows).toHaveCount(1) + + await assetRows.nth(0).click() + await actions.press(page, 'Delete') + await actions.expectPlaceholderRow(page) + + await actions.locateTrashButton(page).click() + await test.expect(assetRows).toHaveCount(1) + + await assetRows.nth(0).click() + await actions.press(page, 'Mod+R') + await actions.expectTrashPlaceholderRow(page) + + await actions.locateHomeButton(page).click() + await test.expect(assetRows).toHaveCount(1) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts b/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts new file mode 100644 index 0000000000..ad613f0bec --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts @@ -0,0 +1,33 @@ +/** @file Test the drive view. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('drive view', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + // Drive view + await test.expect(actions.locateDriveView(page)).toBeVisible() + await actions.expectPlaceholderRow(page) + // Assets table with one asset + await actions.locateNewProjectButton(page).click() + // The placeholder row becomes hidden. + await test.expect(assetRows).toHaveCount(1) + await test.expect(actions.locateAssetsTable(page)).toBeVisible() + await actions.locateDrivePageIcon(page).click() + await actions.locateNewProjectButton(page).click() + await test.expect(assetRows).toHaveCount(2) + await actions.locateDrivePageIcon(page).click() + // The last opened project needs to be stopped, to remove the toast notification notifying the + // user that project creation may take a while. Previously opened projects are stopped when the + // new project is created. + await actions.locateStopProjectButton(assetRows.nth(0)).click() + // Project context menu + await assetRows.nth(0).click({ button: 'right' }) + const contextMenu = actions.locateContextMenus(page) + await test.expect(contextMenu).toBeVisible() + await actions.locateMoveToTrashButton(contextMenu).click() + await test.expect(assetRows).toHaveCount(1) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/editAssetName.spec.ts b/app/ide-desktop/lib/dashboard/e2e/editAssetName.spec.ts new file mode 100644 index 0000000000..f22fd3e48b --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/editAssetName.spec.ts @@ -0,0 +1,92 @@ +/** @file Test copying, moving, cutting and pasting. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('edit name', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + const mod = await actions.modModifier(page) + const newName = 'foo bar baz' + + await actions.locateNewFolderIcon(page).click() + await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] }) + await actions.locateAssetRowName(assetRows.nth(0)).fill(newName) + await actions.locateEditingTick(assetRows.nth(0)).click() + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName)) +}) + +test.test('edit name (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + const newName = 'foo bar baz quux' + + await actions.locateNewFolderIcon(page).click() + await actions.locateAssetRowName(assetRows.nth(0)).click() + await actions.press(page, 'Mod+R') + await actions.locateAssetRowName(assetRows.nth(0)).fill(newName) + await actions.locateAssetRowName(assetRows.nth(0)).press('Enter') + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName)) +}) + +test.test('cancel editing name', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + const mod = await actions.modModifier(page) + const newName = 'foo bar baz' + + await actions.locateNewFolderIcon(page).click() + const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? '' + await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] }) + await actions.locateAssetRowName(assetRows.nth(0)).fill(newName) + await actions.locateEditingCross(assetRows.nth(0)).click() + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName)) +}) + +test.test('cancel editing name (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + const newName = 'foo bar baz quux' + + await actions.locateNewFolderIcon(page).click() + const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? '' + await actions.locateAssetRowName(assetRows.nth(0)).click() + await actions.press(page, 'Mod+R') + await actions.locateAssetRowName(assetRows.nth(0)).fill(newName) + await actions.locateAssetRowName(assetRows.nth(0)).press('Escape') + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName)) +}) + +test.test('change to blank name', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + const mod = await actions.modModifier(page) + + await actions.locateNewFolderIcon(page).click() + const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? '' + await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] }) + await actions.locateAssetRowName(assetRows.nth(0)).fill('') + await actions.locateEditingTick(assetRows.nth(0)).click() + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName)) +}) + +test.test('change to blank name (keyboard)', async ({ page }) => { + const assetRows = actions.locateAssetRows(page) + + await actions.locateNewFolderIcon(page).click() + const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? '' + await actions.locateAssetRowName(assetRows.nth(0)).click() + await actions.press(page, 'Mod+R') + await actions.locateAssetRowName(assetRows.nth(0)).fill('') + await actions.locateAssetRowName(assetRows.nth(0)).press('Enter') + await test.expect(assetRows).toHaveCount(1) + await test.expect(assetRows.nth(0)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName)) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/homePage.spec.ts b/app/ide-desktop/lib/dashboard/e2e/homePage.spec.ts new file mode 100644 index 0000000000..2def99a6fa --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/homePage.spec.ts @@ -0,0 +1,20 @@ +/** @file Test the "change password" modal. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('create empty project', async ({ page }) => { + await actions.locateHomePageIcon(page).click() + // The first "sample" is a button to create a new empty project. + await actions.locateSamples(page).nth(0).click() + await test.expect(actions.locateEditor(page)).toBeVisible() +}) + +test.test('create project from template', async ({ page }) => { + await actions.locateHomePageIcon(page).click() + // The second "sample" is the first template. + await actions.locateSamples(page).nth(1).click() + await test.expect(actions.locateEditor(page)).toBeVisible() +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts b/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts new file mode 100644 index 0000000000..6465ce7cd8 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts @@ -0,0 +1,202 @@ +/** @file Test dragging of labels. */ +import * as test from '@playwright/test' + +import * as backend from '#/services/Backend' + +import * as actions from './actions' + +test.test('drag labels onto single row', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const assetRows = actions.locateAssetRows(page) + const labels = actions.locateLabelsPanelLabels(page) + const label = 'aaaa' + api.addLabel(label, backend.COLORS[0]) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('bbbb', backend.COLORS[1]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('cccc', backend.COLORS[2]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('dddd', backend.COLORS[3]!) + api.addDirectory('foo') + api.addSecret('bar') + api.addFile('baz') + api.addSecret('quux') + await actions.login({ page }) + + await actions.locateLabelsColumnToggle(page).click() + await labels.nth(0).dragTo(assetRows.nth(1)) + await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible() + await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible() + await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible() + await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() +}) + +test.test('drag labels onto multiple rows', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const assetRows = actions.locateAssetRows(page) + const labels = actions.locateLabelsPanelLabels(page) + const label = 'aaaa' + api.addLabel(label, backend.COLORS[0]) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('bbbb', backend.COLORS[1]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('cccc', backend.COLORS[2]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('dddd', backend.COLORS[3]!) + api.addDirectory('foo') + api.addSecret('bar') + api.addFile('baz') + api.addSecret('quux') + await actions.login({ page }) + + await actions.locateLabelsColumnToggle(page).click() + await page.keyboard.down(await actions.modModifier(page)) + await assetRows.nth(0).click() + await assetRows.nth(2).click() + await labels.nth(0).dragTo(assetRows.nth(2)) + await page.keyboard.up(await actions.modModifier(page)) + await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible() + await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible() + await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible() + await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() +}) + +test.test('drag (recursive)', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const assetRows = actions.locateAssetRows(page) + const labels = actions.locateLabelsPanelLabels(page) + const label = 'bbbb' + api.addLabel('aaaa', backend.COLORS[0]) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel(label, backend.COLORS[1]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('cccc', backend.COLORS[2]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('dddd', backend.COLORS[3]!) + const assetsWithLabel = new Set() + const shouldHaveLabel = (asset: T) => { + assetsWithLabel.add(asset.title) + return asset + } + const directory1 = shouldHaveLabel(api.addDirectory('foo')) + const directory2 = shouldHaveLabel(api.addDirectory('bar')) + shouldHaveLabel(api.addFile('baz', { parentId: directory1.id })) + shouldHaveLabel(api.addSecret('quux', { parentId: directory1.id })) + const directory3 = api.addDirectory('directory 3') + const directory5 = shouldHaveLabel(api.addDirectory('directory 5', { parentId: directory3.id })) + api.addFile('file 1', { parentId: directory3.id }) + api.addProject('file 2', { parentId: directory3.id }) + api.addFile('another file') + const directory4 = shouldHaveLabel(api.addDirectory('blargle', { parentId: directory2.id })) + shouldHaveLabel(api.addProject('abcd', { parentId: directory2.id })) + shouldHaveLabel(api.addProject('efgh', { parentId: directory2.id })) + shouldHaveLabel(api.addFile('ijkl', { parentId: directory4.id })) + shouldHaveLabel(api.addProject('mnop', { parentId: directory4.id })) + shouldHaveLabel(api.addSecret('secret 1', { parentId: directory5.id })) + shouldHaveLabel(api.addFile('yet another file', { parentId: directory5.id })) + await actions.login({ page }) + + await actions.locateLabelsColumnToggle(page).click() + let didExpandRows = false + do { + didExpandRows = false + const directories = await actions.locateExpandableDirectories(page).all() + // If going through the directories in forward order, the positions change when + // one directory is expanded, making the double click happend on the wrong row + // for all directories after it. + for (const directory of directories.reverse()) { + didExpandRows = true + await directory.dblclick() + } + } while (didExpandRows) + await page.keyboard.down(await actions.modModifier(page)) + const directory1Row = assetRows.filter({ hasText: directory1.title }) + await directory1Row.click() + await assetRows.filter({ hasText: directory2.title }).click() + await assetRows.filter({ hasText: directory5.title }).click() + await labels.nth(1).dragTo(directory1Row) + await page.keyboard.up(await actions.modModifier(page)) + for (const row of await actions.locateAssetRows(page).all()) { + const name = await actions.locateAssetName(row).innerText() + const labelElement = actions.locateAssetLabels(row).getByText(label) + if (assetsWithLabel.has(name)) { + await test.expect(labelElement).toBeVisible() + } else { + await test.expect(labelElement).not.toBeVisible() + } + } +}) + +test.test('drag (inverted, recursive)', async ({ page }) => { + const { api } = await actions.mockAllAndLogin({ page }) + const assetRows = actions.locateAssetRows(page) + const labels = actions.locateLabelsPanelLabels(page) + const label = 'bbbb' + api.addLabel('aaaa', backend.COLORS[0]) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const backendLabel = api.addLabel(label, backend.COLORS[1]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('cccc', backend.COLORS[2]!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + api.addLabel('dddd', backend.COLORS[3]!) + const assetsWithoutLabel = new Set() + const shouldNotHaveLabel = (asset: T) => { + assetsWithoutLabel.add(asset.title) + return asset + } + const directory1 = shouldNotHaveLabel(api.addDirectory('foo')) + const directory2 = shouldNotHaveLabel(api.addDirectory('bar')) + shouldNotHaveLabel(api.addFile('baz', { parentId: directory1.id })) + shouldNotHaveLabel(api.addSecret('quux', { parentId: directory1.id })) + const directory3 = api.addDirectory('directory 3') + const directory5 = shouldNotHaveLabel( + api.addDirectory('directory 5', { parentId: directory3.id }) + ) + api.addFile('file 1', { parentId: directory3.id }) + api.addProject('file 2', { parentId: directory3.id }) + api.addFile('another file') + const directory4 = shouldNotHaveLabel(api.addDirectory('blargle', { parentId: directory2.id })) + shouldNotHaveLabel(api.addProject('abcd', { parentId: directory2.id })) + shouldNotHaveLabel(api.addProject('efgh', { parentId: directory2.id })) + shouldNotHaveLabel(api.addFile('ijkl', { parentId: directory4.id })) + shouldNotHaveLabel(api.addProject('mnop', { parentId: directory4.id })) + shouldNotHaveLabel(api.addSecret('secret 1', { parentId: directory5.id })) + shouldNotHaveLabel(api.addFile('yet another file', { parentId: directory5.id })) + api.setLabels(api.rootDirectoryId, [backendLabel.value]) + await actions.login({ page }) + + await actions.locateLabelsColumnToggle(page).click() + /** The default position (the center) cannot be clicked on as it lands exactly on a label - + * which has its own mouse action. It also cannot be too far left, otherwise it triggers + * edit mode for the name. */ + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const somewhereInRow = { x: 300, y: 16 } + let didExpandRows = false + do { + didExpandRows = false + const directories = await actions.locateExpandableDirectories(page).all() + // If going through the directories in forward order, the positions change when + // one directory is expanded, making the double click happend on the wrong row + // for all directories after it. + for (const directory of directories.reverse()) { + didExpandRows = true + await directory.dblclick({ position: somewhereInRow }) + } + } while (didExpandRows) + await page.keyboard.down(await actions.modModifier(page)) + const directory1Row = assetRows.filter({ hasText: directory1.title }) + await directory1Row.click({ position: somewhereInRow }) + await assetRows.filter({ hasText: directory2.title }).click({ position: somewhereInRow }) + await assetRows.filter({ hasText: directory5.title }).click({ position: somewhereInRow }) + await labels.nth(1).dragTo(directory1Row) + await page.keyboard.up(await actions.modModifier(page)) + for (const row of await actions.locateAssetRows(page).all()) { + const name = await actions.locateAssetName(row).innerText() + const labelElement = actions.locateAssetLabels(row).getByText(label) + if (assetsWithoutLabel.has(name)) { + await test.expect(labelElement).not.toBeVisible() + } else { + await test.expect(labelElement).toBeVisible() + } + } +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/labelsPanel.spec.ts b/app/ide-desktop/lib/dashboard/e2e/labelsPanel.spec.ts new file mode 100644 index 0000000000..41e0e2ecdd --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/labelsPanel.spec.ts @@ -0,0 +1,51 @@ +/** @file Test the labels sidebar panel. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('labels', async ({ page }) => { + // Empty labels panel + await test.expect(actions.locateLabelsPanel(page)).toBeVisible() + + // "Create label" modal + await actions.locateNewLabelButton(page).click() + await test.expect(actions.locateNewLabelModal(page)).toBeVisible() + await page.press('body', 'Escape') + await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible() + await actions.locateNewLabelButton(page).click() + await actions.locateModalBackground(page).click() + await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible() + await actions.locateNewLabelButton(page).click() + + // "Create label" modal with name set + await actions.locateNewLabelModalNameInput(page).fill('New Label') + await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/) + + await page.press('body', 'Escape') + + // "Create label" modal with color set + // The exact number is allowed to vary; but to click the fourth color, there must be at least + // four colors. + await actions.locateNewLabelButton(page).click() + test.expect(await actions.locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4) + // `force: true` is required because the `label` needs to handle the click event, not the + // `button`. + await actions.locateNewLabelModalColorButtons(page).nth(4).click({ force: true }) + await test.expect(actions.locateNewLabelModal(page)).toBeVisible() + + // "Create label" modal with name and color set + await actions.locateNewLabelModalNameInput(page).fill('New Label') + await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/) + + // Labels panel with one entry + await actions.locateCreateButton(actions.locateNewLabelModal(page)).click() + await test.expect(actions.locateLabelsPanel(page)).toBeVisible() + + // Empty labels panel again, after deleting the only entry + await actions.locateLabelsPanelLabels(page).first().hover() + await actions.locateDeleteIcon(actions.locateLabelsPanel(page)).first().click() + await actions.locateDeleteButton(page).click() + test.expect(await actions.locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts b/app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts new file mode 100644 index 0000000000..b33b7ebb77 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts @@ -0,0 +1,24 @@ +/** @file Test the login flow. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +// Do not login in setup, because this test needs to test login. +test.test.beforeEach(actions.mockAll) + +// ============= +// === Tests === +// ============= + +test.test('login and logout', async ({ page }) => { + // After sign in + await actions.login({ page }) + await test.expect(actions.locateDriveView(page)).toBeVisible() + await test.expect(actions.locateLoginButton(page)).not.toBeVisible() + + // After sign out + await actions.locateUserMenuButton(page).click() + await actions.locateLogoutButton(page).click() + await test.expect(actions.locateDriveView(page)).not.toBeVisible() + await test.expect(actions.locateLoginButton(page)).toBeVisible() +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/loginScreen.spec.ts b/app/ide-desktop/lib/dashboard/e2e/loginScreen.spec.ts similarity index 73% rename from app/ide-desktop/lib/dashboard/test-e2e/loginScreen.spec.ts rename to app/ide-desktop/lib/dashboard/e2e/loginScreen.spec.ts index 685ac0e97d..167beda0ea 100644 --- a/app/ide-desktop/lib/dashboard/test-e2e/loginScreen.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/loginScreen.spec.ts @@ -3,15 +3,16 @@ import * as test from '@playwright/test' import * as actions from './actions' +test.test.beforeEach(actions.mockAll) + // ============= // === Tests === // ============= test.test('login screen', async ({ page }) => { - // Screenshot omitted - it is already taken by `loginLogout.spec.ts`. await page.goto('/') - // Screenshot #2: Invalid email + // Invalid email await actions.locateEmailInput(page).fill('invalid email') test .expect( @@ -21,14 +22,14 @@ test.test('login screen', async ({ page }) => { .toBe(false) await actions.locateLoginButton(page).click() - // Screenshot #3: Invalid password + // Invalid password await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) - await actions.locatePasswordInput(page).type(actions.INVALID_PASSWORD) + await actions.locatePasswordInput(page).fill(actions.INVALID_PASSWORD) test .expect( await page.evaluate(() => document.querySelector('form')?.checkValidity()), - 'form should reject invalid password' + 'form should accept invalid password' ) - .toBe(false) + .toBe(true) await actions.locateLoginButton(page).click() }) diff --git a/app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts b/app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts new file mode 100644 index 0000000000..ac3604a6af --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts @@ -0,0 +1,27 @@ +/** @file Test the login flow. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('page switcher', async ({ page }) => { + // Create a new project so that the editor page can be switched to. + await actions.locateNewProjectButton(page).click() + await actions.locateDrivePageIcon(page).click() + + await actions.locateDrivePageIcon(page).click() + await test.expect(actions.locateDriveView(page)).toBeVisible() + await test.expect(actions.locateSamplesList(page)).not.toBeVisible() + await test.expect(actions.locateEditor(page)).not.toBeVisible() + + await actions.locateHomePageIcon(page).click() + await test.expect(actions.locateDriveView(page)).not.toBeVisible() + await test.expect(actions.locateSamplesList(page)).toBeVisible() + await test.expect(actions.locateEditor(page)).not.toBeVisible() + + await actions.locateEditorPageIcon(page).click() + await test.expect(actions.locateDriveView(page)).not.toBeVisible() + await test.expect(actions.locateSamplesList(page)).not.toBeVisible() + await test.expect(actions.locateEditor(page)).toBeVisible() +}) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUpFlow.spec.ts similarity index 67% rename from app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts rename to app/ide-desktop/lib/dashboard/e2e/signUpFlow.spec.ts index c0dda63449..3f3738829f 100644 --- a/app/ide-desktop/lib/dashboard/test-e2e/signUpFlow.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUpFlow.spec.ts @@ -2,14 +2,9 @@ import * as test from '@playwright/test' import * as actions from './actions' -import * as apiModule from './api' - -// ============= -// === Tests === -// ============= test.test('sign up flow', async ({ page }) => { - const api = await apiModule.mockApi(page) + const api = await actions.mockApi({ page }) api.setCurrentUser(null) await page.goto('/') @@ -20,25 +15,27 @@ test.test('sign up flow', async ({ page }) => { test.expect(email).not.toStrictEqual(api.defaultEmail) test.expect(name).not.toStrictEqual(api.defaultName) - // Screenshot #1: Set username panel + // Set username panel await actions.locateEmailInput(page).fill(email) await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateLoginButton(page).click() - await test.expect(actions.locateSetUsernamePanel(page)).toHaveScreenshot() + await test.expect(actions.locateSetUsernamePanel(page)).toBeVisible() - // Screenshot #2: Logged in, but account disabled + // Logged in, but account disabled await actions.locateUsernameInput(page).fill(name) await actions.locateSetUsernameButton(page).click() - await test.expect(page).toHaveScreenshot() + await test.expect(actions.locateUpgradeButton(page)).toBeVisible() + await test.expect(actions.locateDriveView(page)).not.toBeVisible() - // Screenshot #3: Logged in, and account enabled + // Logged in, and account enabled const currentUser = api.currentUser test.expect(currentUser).toBeDefined() if (currentUser != null) { currentUser.isEnabled = true } - await actions.login(page, email) - await test.expect(page).toHaveScreenshot() + await actions.login({ page }, email) + await test.expect(actions.locateUpgradeButton(page)).not.toBeVisible() + await test.expect(actions.locateDriveView(page)).toBeVisible() test.expect(api.currentUser?.email, 'new user has correct email').toBe(email) test.expect(api.currentUser?.name, 'new user has correct name').toBe(name) diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpWithOrganizationId.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUpWithOrganizationId.spec.ts similarity index 94% rename from app/ide-desktop/lib/dashboard/test-e2e/signUpWithOrganizationId.spec.ts rename to app/ide-desktop/lib/dashboard/e2e/signUpWithOrganizationId.spec.ts index f39b6db780..3d9cfaec59 100644 --- a/app/ide-desktop/lib/dashboard/test-e2e/signUpWithOrganizationId.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUpWithOrganizationId.spec.ts @@ -2,7 +2,6 @@ import * as test from '@playwright/test' import * as actions from './actions' -import * as apiModule from './api' // ============= // === Tests === @@ -17,7 +16,7 @@ test.test('sign up with organization id', async ({ page }) => { await page.goto( '/registration?' + new URLSearchParams([['organization_id', organizationId]]).toString() ) - const api = await apiModule.mockApi(page) + const api = await actions.mockApi({ page }) api.setCurrentUser(null) // Sign up diff --git a/app/ide-desktop/lib/dashboard/test-e2e/signUpWithoutOrganizationId.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUpWithoutOrganizationId.spec.ts similarity index 93% rename from app/ide-desktop/lib/dashboard/test-e2e/signUpWithoutOrganizationId.spec.ts rename to app/ide-desktop/lib/dashboard/e2e/signUpWithoutOrganizationId.spec.ts index 0fd3108e45..ea4cf013d6 100644 --- a/app/ide-desktop/lib/dashboard/test-e2e/signUpWithoutOrganizationId.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUpWithoutOrganizationId.spec.ts @@ -2,7 +2,6 @@ import * as test from '@playwright/test' import * as actions from './actions' -import * as apiModule from './api' // ============= // === Tests === @@ -12,7 +11,7 @@ test.test('sign up without organization id', async ({ page }) => { await page.goto('/') await page.waitForLoadState('domcontentloaded') await page.goto('/registration') - const api = await apiModule.mockApi(page) + const api = await actions.mockApi({ page }) api.setCurrentUser(null) // Sign up diff --git a/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts new file mode 100644 index 0000000000..6bf6d94939 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts @@ -0,0 +1,137 @@ +/** @file Test sorting of assets columns. */ +import * as test from '@playwright/test' + +import * as dateTime from '#/utilities/dateTime' + +import * as actions from './actions' + +/* eslint-disable @typescript-eslint/no-magic-numbers */ + +const START_DATE_EPOCH_MS = 1.7e12 +/** The number of milliseconds in a minute. */ +const MIN_MS = 60_000 + +test.test('sort', async ({ page }) => { + const { api } = await actions.mockAll({ page }) + const assetRows = actions.locateAssetRows(page) + const nameHeading = actions.locateNameColumnHeading(page) + const modifiedHeading = actions.locateModifiedColumnHeading(page) + const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS)) + const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS)) + const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS)) + const date4 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS)) + const date5 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS)) + const date6 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS)) + const date7 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS)) + const date8 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS)) + api.addDirectory('a directory', { modifiedAt: date4 }) + api.addDirectory('G directory', { modifiedAt: date6 }) + api.addProject('C project', { modifiedAt: date7 }) + api.addSecret('H secret', { modifiedAt: date2 }) + api.addProject('b project', { modifiedAt: date1 }) + api.addFile('d file', { modifiedAt: date8 }) + api.addSecret('f secret', { modifiedAt: date3 }) + api.addFile('e file', { modifiedAt: date5 }) + // By date: + // b project + // h secret + // f secret + // a directory + // e file + // g directory + // c project + // d file + await page.goto('/') + await actions.login({ page }) + + // By default, assets should be grouped by type. + // Assets in each group are ordered by insertion order. + await test.expect(actions.locateSortAscendingIcon(nameHeading)).not.toBeVisible() + await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible() + await test.expect(actions.locateSortAscendingIcon(modifiedHeading)).not.toBeVisible() + await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^C project/) + await test.expect(assetRows.nth(3)).toHaveText(/^b project/) + await test.expect(assetRows.nth(4)).toHaveText(/^d file/) + await test.expect(assetRows.nth(5)).toHaveText(/^e file/) + await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^f secret/) + + // Sort by name ascending. + await nameHeading.click() + await test.expect(actions.locateSortAscendingIcon(nameHeading)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^b project/) + await test.expect(assetRows.nth(3)).toHaveText(/^C project/) + await test.expect(assetRows.nth(4)).toHaveText(/^d file/) + await test.expect(assetRows.nth(5)).toHaveText(/^e file/) + await test.expect(assetRows.nth(6)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^H secret/) + + // Sort by name descending. + await nameHeading.click() + await test.expect(actions.locateSortDescendingIcon(nameHeading)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(1)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^C project/) + await test.expect(assetRows.nth(3)).toHaveText(/^b project/) + await test.expect(assetRows.nth(4)).toHaveText(/^e file/) + await test.expect(assetRows.nth(5)).toHaveText(/^d file/) + await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^f secret/) + + // Sorting should be unset. + await nameHeading.click() + await page.mouse.move(0, 0) + await test.expect(actions.locateSortAscendingIcon(nameHeading)).not.toBeVisible() + await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^C project/) + await test.expect(assetRows.nth(3)).toHaveText(/^b project/) + await test.expect(assetRows.nth(4)).toHaveText(/^d file/) + await test.expect(assetRows.nth(5)).toHaveText(/^e file/) + await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^f secret/) + + // Sort by date ascending. + await modifiedHeading.click() + await test.expect(actions.locateSortAscendingIcon(modifiedHeading)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^b project/) + await test.expect(assetRows.nth(3)).toHaveText(/^C project/) + await test.expect(assetRows.nth(4)).toHaveText(/^e file/) + await test.expect(assetRows.nth(5)).toHaveText(/^d file/) + await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^f secret/) + + // Sort by date descending. + await modifiedHeading.click() + await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(1)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^C project/) + await test.expect(assetRows.nth(3)).toHaveText(/^b project/) + await test.expect(assetRows.nth(4)).toHaveText(/^d file/) + await test.expect(assetRows.nth(5)).toHaveText(/^e file/) + await test.expect(assetRows.nth(6)).toHaveText(/^f secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^H secret/) + + // Sorting should be unset. + await modifiedHeading.click() + await page.mouse.move(0, 0) + await test.expect(actions.locateSortAscendingIcon(modifiedHeading)).not.toBeVisible() + await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) + await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) + await test.expect(assetRows.nth(2)).toHaveText(/^C project/) + await test.expect(assetRows.nth(3)).toHaveText(/^b project/) + await test.expect(assetRows.nth(4)).toHaveText(/^d file/) + await test.expect(assetRows.nth(5)).toHaveText(/^e file/) + await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) + await test.expect(assetRows.nth(7)).toHaveText(/^f secret/) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts b/app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts new file mode 100644 index 0000000000..27d3a77f11 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts @@ -0,0 +1,21 @@ +/** @file Test the user menu. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +test.test.beforeEach(actions.mockAllAndLogin) + +test.test('user menu', async ({ page }) => { + // User menu + await actions.locateUserMenuButton(page).click() + await test.expect(actions.locateUserMenu(page)).toBeVisible() +}) + +test.test('download app', async ({ page }) => { + await actions.locateUserMenuButton(page).click() + const downloadPromise = page.waitForEvent('download') + await actions.locateDownloadAppButton(page).click() + const download = await downloadPromise + await download.cancel() + test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/) +}) diff --git a/app/ide-desktop/lib/dashboard/index.html b/app/ide-desktop/lib/dashboard/index.html index baba8a4bf0..70124ad082 100644 --- a/app/ide-desktop/lib/dashboard/index.html +++ b/app/ide-desktop/lib/dashboard/index.html @@ -33,8 +33,6 @@ user-scalable = no" /> Enso - - @@ -44,11 +42,6 @@ - - - diff --git a/app/ide-desktop/lib/dashboard/playwright/index.tsx b/app/ide-desktop/lib/dashboard/playwright/index.tsx deleted file mode 100644 index 1927d20692..0000000000 --- a/app/ide-desktop/lib/dashboard/playwright/index.tsx +++ /dev/null @@ -1 +0,0 @@ -/** @file The file in which the test runner will append the built component code. */ diff --git a/app/ide-desktop/lib/dashboard/src/App.tsx b/app/ide-desktop/lib/dashboard/src/App.tsx index 231cf3c590..ed1d58bd81 100644 --- a/app/ide-desktop/lib/dashboard/src/App.tsx +++ b/app/ide-desktop/lib/dashboard/src/App.tsx @@ -41,8 +41,18 @@ import * as toastify from 'react-toastify' import * as detect from 'enso-common/src/detect' import * as appUtils from '#/appUtils' -import * as authServiceModule from '#/authentication/service' + import * as navigateHooks from '#/hooks/navigateHooks' + +import AuthProvider, * as authProvider from '#/providers/AuthProvider' +import BackendProvider from '#/providers/BackendProvider' +import LocalStorageProvider from '#/providers/LocalStorageProvider' +import LoggerProvider from '#/providers/LoggerProvider' +import type * as loggerProvider from '#/providers/LoggerProvider' +import ModalProvider from '#/providers/ModalProvider' +import SessionProvider from '#/providers/SessionProvider' +import ShortcutManagerProvider from '#/providers/ShortcutManagerProvider' + import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration' import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode' import ForgotPassword from '#/pages/authentication/ForgotPassword' @@ -51,17 +61,13 @@ import Registration from '#/pages/authentication/Registration' import ResetPassword from '#/pages/authentication/ResetPassword' import SetUsername from '#/pages/authentication/SetUsername' import Dashboard from '#/pages/dashboard/Dashboard' -import AuthProvider, * as authProvider from '#/providers/AuthProvider' -import BackendProvider from '#/providers/BackendProvider' -import LocalStorageProvider from '#/providers/LocalStorageProvider' -import LoggerProvider from '#/providers/LoggerProvider' -import type * as loggerProvider from '#/providers/LoggerProvider' -import ModalProvider from '#/providers/ModalProvider' -import SessionProvider from '#/providers/SessionProvider' -import ShortcutsProvider from '#/providers/ShortcutsProvider' -import type * as backend from '#/services/backend' -import * as localBackend from '#/services/localBackend' -import * as shortcutsModule from '#/utilities/shortcuts' + +import type Backend from '#/services/Backend' +import LocalBackend from '#/services/LocalBackend' + +import ShortcutManager, * as shortcutManagerModule from '#/utilities/ShortcutManager' + +import * as authServiceModule from '#/authentication/service' // ====================== // === getMainPageUrl === @@ -143,14 +149,16 @@ function AppRouter(props: AppProps) { // @ts-expect-error This is used exclusively for debugging. window.navigate = navigate } - const [shortcuts] = React.useState(() => shortcutsModule.ShortcutRegistry.createWithDefaults()) + const [shortcutManager] = React.useState(() => ShortcutManager.createWithDefaults()) React.useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const isTargetEditable = event.target instanceof HTMLInputElement || (event.target instanceof HTMLElement && event.target.isContentEditable) - const shouldHandleEvent = isTargetEditable ? !shortcutsModule.isTextInputEvent(event) : true - if (shouldHandleEvent && shortcuts.handleKeyboardEvent(event)) { + const shouldHandleEvent = isTargetEditable + ? !shortcutManagerModule.isTextInputEvent(event) + : true + if (shouldHandleEvent && shortcutManager.handleKeyboardEvent(event)) { event.preventDefault() // This is required to prevent the event from propagating to the event handler // that focuses the search input. @@ -161,7 +169,7 @@ function AppRouter(props: AppProps) { return () => { document.body.removeEventListener('keydown', onKeyDown) } - }, [shortcuts]) + }, [shortcutManager]) const mainPageUrl = getMainPageUrl() const authService = React.useMemo(() => { const authConfig = { navigate, ...props } @@ -169,8 +177,8 @@ function AppRouter(props: AppProps) { }, [props, /* should never change */ navigate]) const userSession = authService.cognito.userSession.bind(authService.cognito) const registerAuthEventListener = authService.registerAuthEventListener - const initialBackend: backend.Backend = isAuthenticationDisabled - ? new localBackend.LocalBackend(projectManagerUrl) + const initialBackend: Backend = isAuthenticationDisabled + ? new LocalBackend(projectManagerUrl) : // This is safe, because the backend is always set by the authentication flow. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion null! @@ -231,7 +239,9 @@ function AppRouter(props: AppProps) { ) let result = routes - result = {result} + result = ( + {result} + ) result = {result} result = ( ({ isValid: () => true, @@ -150,7 +142,7 @@ export class Cognito { }) } }) - const amplifySession = currentSession.mapErr(original.intoCurrentSessionErrorKind) + const amplifySession = currentSession.mapErr(original.intoCurrentSessionErrorType) return amplifySession.map(parseUserSession).unwrapOr(null) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts index daaead9bf0..0b994c0b6b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/cognito.ts @@ -35,9 +35,10 @@ import * as results from 'ts-results' import * as detect from 'enso-common/src/detect' -import * as config from '#/authentication/config' import type * as loggerProvider from '#/providers/LoggerProvider' +import * as config from '#/authentication/config' + // ================= // === Constants === // ================= @@ -48,19 +49,6 @@ import type * as loggerProvider from '#/providers/LoggerProvider' * constant defined in the AWS Amplify library. */ const GITHUB_PROVIDER = 'Github' -const MESSAGES = { - signInWithPassword: { - userNotFound: 'User not found. Please sign up first.', - userNotConfirmed: 'User is not confirmed. Please check your email for a confirmation link.', - incorrectUsernameOrPassword: 'Incorrect username or password.', - }, - forgotPassword: { - userNotFound: 'Cannot reset password as user not found.', - userNotConfirmed: `Cannot reset password for user with an unverified email. \ -Please verify your email first.`, - }, -} - // ================ // === UserInfo === // ================ @@ -151,10 +139,24 @@ function isAuthError(error: unknown): error is AuthError { // === CognitoError === // ==================== +/** Internal IDs of Cognito errors that may occur when requesting a password reset. */ +export enum CognitoErrorType { + userAlreadyConfirmed = 'UserAlreadyConfirmed', + usernameExists = 'UsernameExists', + invalidParameter = 'InvalidParameter', + invalidPassword = 'InvalidPassword', + notAuthorized = 'NotAuthorized', + userNotConfirmed = 'UserNotConfirmed', + userNotFound = 'UserNotFound', + amplifyError = 'AmplifyError', + authError = 'AuthError', + noCurrentUser = 'NoCurrentUser', +} + /** Base interface for all errors output from this module. * Every user-facing error MUST extend this interface. */ interface CognitoError { - kind: string + type: CognitoErrorType message: string } @@ -190,7 +192,7 @@ export class Cognito { * Will refresh the {@link UserSession} if it has expired. */ async userSession() { const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession()) - const amplifySession = currentSession.mapErr(intoCurrentSessionErrorKind) + const amplifySession = currentSession.mapErr(intoCurrentSessionErrorType) return amplifySession.map(parseUserSession).unwrapOr(null) } @@ -380,24 +382,12 @@ function parseUserSession(session: cognito.CognitoUserSession): UserSession { } } -/** Internal IDs of errors that may occur when getting the current session. */ -export enum CurrentSessionErrorKind { - noCurrentUser = 'NoCurrentUser', -} - -export const CURRENT_SESSION_NO_CURRENT_USER_ERROR = { - internalMessage: 'No current user', - kind: CurrentSessionErrorKind.noCurrentUser, -} - -/** - * Convert an {@link AmplifyError} into a {@link CurrentSessionErrorKind} if it is a known error, +/** Convert an {@link AmplifyError} into a {@link CognitoErrorType} if it is a known error, * else re-throws the error. - * @throws {Error} If the error is not recognized. - */ -export function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind { - if (error === CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage) { - return CURRENT_SESSION_NO_CURRENT_USER_ERROR.kind + * @throws {Error} If the error is not recognized. */ +export function intoCurrentSessionErrorType(error: unknown): CognitoErrorType.noCurrentUser { + if (error === 'No current user') { + return CognitoErrorType.noCurrentUser } else { throw error } @@ -436,31 +426,12 @@ function intoSignUpParams( } } -/** Internal IDs of errors that may occur when signing up. */ -export enum SignUpErrorKind { - usernameExists = 'UsernameExists', - invalidParameter = 'InvalidParameter', - invalidPassword = 'InvalidPassword', -} - -const SIGN_UP_USERNAME_EXISTS_ERROR = { - internalCode: 'UsernameExistsException', - kind: SignUpErrorKind.usernameExists, -} - -const SIGN_UP_INVALID_PARAMETER_ERROR = { - internalCode: 'InvalidParameterException', - kind: SignUpErrorKind.invalidParameter, -} - -const SIGN_UP_INVALID_PASSWORD_ERROR = { - internalCode: 'InvalidPasswordException', - kind: SignUpErrorKind.invalidPassword, -} - /** An error that may occur when signing up. */ export interface SignUpError extends CognitoError { - kind: SignUpErrorKind + type: + | CognitoErrorType.invalidParameter + | CognitoErrorType.invalidPassword + | CognitoErrorType.usernameExists message: string } @@ -470,19 +441,19 @@ export interface SignUpError extends CognitoError { * @throws {Error} If the error is not recognized. */ export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError { - if (error.code === SIGN_UP_USERNAME_EXISTS_ERROR.internalCode) { + if (error.code === 'UsernameExistsException') { return { - kind: SIGN_UP_USERNAME_EXISTS_ERROR.kind, + type: CognitoErrorType.usernameExists, message: error.message, } - } else if (error.code === SIGN_UP_INVALID_PARAMETER_ERROR.internalCode) { + } else if (error.code === 'InvalidParameterException') { return { - kind: SIGN_UP_INVALID_PARAMETER_ERROR.kind, + type: CognitoErrorType.invalidParameter, message: error.message, } - } else if (error.code === SIGN_UP_INVALID_PASSWORD_ERROR.internalCode) { + } else if (error.code === 'InvalidPasswordException') { return { - kind: SIGN_UP_INVALID_PASSWORD_ERROR.kind, + type: CognitoErrorType.invalidPassword, message: error.message, } } else { @@ -494,28 +465,9 @@ export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError { // === ConfirmSignUp === // ===================== -/** Internal IDs of errors that may occur when confirming registration. */ -export enum ConfirmSignUpErrorKind { - userAlreadyConfirmed = 'UserAlreadyConfirmed', - userNotFound = 'UserNotFound', -} - -const CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR = { - internalCode: 'NotAuthorizedException', - internalMessage: 'User cannot be confirmed. Current status is CONFIRMED', - kind: ConfirmSignUpErrorKind.userAlreadyConfirmed, -} - -const CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR = { - internalCode: 'UserNotFoundException', - internalMessage: 'Username/client id combination not found.', - kind: ConfirmSignUpErrorKind.userNotFound, - message: 'Incorrect email or confirmation code.', -} - /** An error that may occur when confirming registration. */ export interface ConfirmSignUpError extends CognitoError { - kind: ConfirmSignUpErrorKind + type: CognitoErrorType.userAlreadyConfirmed | CognitoErrorType.userNotFound message: string } @@ -524,26 +476,26 @@ export interface ConfirmSignUpError extends CognitoError { * @throws {Error} If the error is not recognized. */ export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError { if ( - error.code === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalCode && - error.message === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalMessage + error.code === 'NotAuthorizedException' && + error.message === 'User cannot be confirmed. Current status is CONFIRMED' ) { return { /** Don't re-use the original `error.code` here because Amplify overloads the same code * for multiple kinds of errors. We replace it with a custom code that has no * ambiguity. */ - kind: CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.kind, + type: CognitoErrorType.userAlreadyConfirmed, message: error.message, } } else if ( - error.code === CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.internalCode && - error.message === CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.internalMessage + error.code === 'UserNotFoundException' && + error.message === 'Username/client id combination not found.' ) { return { /** Don't re-use the original `error.code` here because Amplify overloads the same code * for multiple kinds of errors. We replace it with a custom code that has no * ambiguity. */ - kind: CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.kind, - message: CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.message, + type: CognitoErrorType.userNotFound, + message: 'Incorrect email or confirmation code.', } } else { throw error @@ -554,16 +506,12 @@ export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignU // === SignInWithPassword === // ========================== -/** Internal IDs of errors that may occur when signing in with a password. */ -export enum SignInWithPasswordErrorKind { - notAuthorized = 'NotAuthorized', - userNotConfirmed = 'UserNotConfirmed', - userNotFound = 'UserNotFound', -} - /** An error that may occur when signing in with a password. */ export interface SignInWithPasswordError extends CognitoError { - kind: SignInWithPasswordErrorKind + type: + | CognitoErrorType.notAuthorized + | CognitoErrorType.userNotConfirmed + | CognitoErrorType.userNotFound message: string } @@ -574,18 +522,18 @@ export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInW switch (error.code) { case 'UserNotFoundException': return { - kind: SignInWithPasswordErrorKind.userNotFound, - message: MESSAGES.signInWithPassword.userNotFound, + type: CognitoErrorType.userNotFound, + message: 'User not found. Please sign up first.', } case 'UserNotConfirmedException': return { - kind: SignInWithPasswordErrorKind.userNotConfirmed, - message: MESSAGES.signInWithPassword.userNotConfirmed, + type: CognitoErrorType.userNotConfirmed, + message: 'User not confirmed. Please check your email for a confirmation link.', } case 'NotAuthorizedException': return { - kind: SignInWithPasswordErrorKind.notAuthorized, - message: MESSAGES.signInWithPassword.incorrectUsernameOrPassword, + type: CognitoErrorType.notAuthorized, + message: 'Incorrect username or password.', } default: throw error @@ -596,27 +544,9 @@ export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInW // === ForgotPassword === // ====================== -/** Internal IDs of errors that may occur when requesting a password reset. */ -export enum ForgotPasswordErrorKind { - userNotConfirmed = 'UserNotConfirmed', - userNotFound = 'UserNotFound', -} - -const FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR = { - internalCode: 'InvalidParameterException', - kind: ForgotPasswordErrorKind.userNotConfirmed, - message: `Cannot reset password for the user as there is no registered/verified email or \ -phone_number`, -} - -const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = { - internalCode: 'UserNotFoundException', - kind: ForgotPasswordErrorKind.userNotFound, -} - /** An error that may occur when requesting a password reset. */ export interface ForgotPasswordError extends CognitoError { - kind: ForgotPasswordErrorKind + type: CognitoErrorType.userNotConfirmed | CognitoErrorType.userNotFound message: string } @@ -624,18 +554,22 @@ export interface ForgotPasswordError extends CognitoError { * else re-throws the error. * @throws {Error} If the error is not recognized. */ export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError { - if (error.code === FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.internalCode) { + if (error.code === 'UserNotFoundException') { return { - kind: FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.kind, - message: MESSAGES.forgotPassword.userNotFound, + type: CognitoErrorType.userNotFound, + message: 'Cannot reset password as user not found.', } } else if ( - error.code === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.internalCode && - error.message === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.message + error.code === 'InvalidParameterException' && + error.message === + 'Cannot reset password for the user as there is no registered/verified email or ' + + 'phone_number' ) { return { - kind: FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.kind, - message: MESSAGES.forgotPassword.userNotConfirmed, + type: CognitoErrorType.userNotConfirmed, + message: + 'Cannot reset password for user with an unverified email. ' + + 'Please verify your email first.', } } else { throw error @@ -646,15 +580,9 @@ export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPassw // === ForgotPasswordSubmit === // ============================ -/** Internal IDs of errors that may occur when resetting a password. */ -export enum ForgotPasswordSubmitErrorKind { - amplifyError = 'AmplifyError', - authError = 'AuthError', -} - /** An error that may occur when resetting a password. */ export interface ForgotPasswordSubmitError extends CognitoError { - kind: ForgotPasswordSubmitErrorKind + type: CognitoErrorType.amplifyError | CognitoErrorType.authError message: string } @@ -664,12 +592,12 @@ export interface ForgotPasswordSubmitError extends CognitoError { export function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError { if (isAuthError(error)) { return { - kind: ForgotPasswordSubmitErrorKind.authError, + type: CognitoErrorType.authError, message: error.log, } } else if (isAmplifyError(error)) { return { - kind: ForgotPasswordSubmitErrorKind.amplifyError, + type: CognitoErrorType.amplifyError, message: error.message, } } else { diff --git a/app/ide-desktop/lib/dashboard/mock/authentication/listen.tsx b/app/ide-desktop/lib/dashboard/src/authentication/listen.mock.ts similarity index 100% rename from app/ide-desktop/lib/dashboard/mock/authentication/listen.tsx rename to app/ide-desktop/lib/dashboard/src/authentication/listen.mock.ts diff --git a/app/ide-desktop/lib/dashboard/src/authentication/service.ts b/app/ide-desktop/lib/dashboard/src/authentication/service.ts index 3ead8edd55..bfea096912 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/service.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/service.ts @@ -7,11 +7,14 @@ import * as common from 'enso-common' import * as detect from 'enso-common/src/detect' import * as appUtils from '#/appUtils' + +import type * as loggerProvider from '#/providers/LoggerProvider' + +import * as config from '#/utilities/config' + import * as cognito from '#/authentication/cognito' import * as auth from '#/authentication/config' import * as listen from '#/authentication/listen' -import type * as loggerProvider from '#/providers/LoggerProvider' -import * as config from '#/utilities/config' // ============= // === Types === diff --git a/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx b/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx index 91eb7d5425..0d08cd02f7 100644 --- a/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/ColorPicker.tsx @@ -1,7 +1,7 @@ /** @file A color picker to select from a predetermined list of colors. */ import * as React from 'react' -import * as backend from '#/services/backend' +import * as backend from '#/services/Backend' /** Props for a {@link ColorPicker}. */ export interface ColorPickerProps { diff --git a/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx b/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx index b7deeef3e7..08b38e2061 100644 --- a/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx @@ -4,18 +4,20 @@ import * as React from 'react' import CrossIcon from 'enso-assets/cross.svg' import TickIcon from 'enso-assets/tick.svg' -import * as shortcutsProvider from '#/providers/ShortcutsProvider' -import * as shortcutsModule from '#/utilities/shortcuts' +import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' + +import * as shortcutManagerModule from '#/utilities/ShortcutManager' // ==================== // === EditableSpan === // ==================== -/** Props of an {@link EditableSpan} that are passed through to the base element. */ -type EditableSpanPassthroughProps = JSX.IntrinsicElements['input'] & JSX.IntrinsicElements['span'] - /** Props for an {@link EditableSpan}. */ -export interface EditableSpanProps extends Omit { +export interface EditableSpanProps { + // This matches the capitalization of `data-` attributes in React. + // eslint-disable-next-line @typescript-eslint/naming-convention + 'data-testid'?: string + className?: string editable?: boolean checkSubmittable?: (value: string) => boolean onSubmit: (value: string) => void @@ -27,17 +29,9 @@ export interface EditableSpanProps extends Omit` that can turn into an ``. */ export default function EditableSpan(props: EditableSpanProps) { - const { - editable = false, - checkSubmittable, - children, - onSubmit, - onCancel, - inputPattern, - inputTitle, - ...passthrough - } = props - const { shortcuts } = shortcutsProvider.useShortcuts() + const { 'data-testid': dataTestId, className, editable = false, children } = props + const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props + const { shortcutManager } = shortcutManagerProvider.useShortcutManager() const [isSubmittable, setIsSubmittable] = React.useState(true) const inputRef = React.useRef(null) const cancelled = React.useRef(false) @@ -50,8 +44,8 @@ export default function EditableSpan(props: EditableSpanProps) { React.useEffect(() => { if (editable) { - return shortcuts.registerKeyboardHandlers({ - [shortcutsModule.KeyboardAction.cancelEditName]: () => { + return shortcutManager.registerKeyboardHandlers({ + [shortcutManagerModule.KeyboardAction.cancelEditName]: () => { onCancel() cancelled.current = true inputRef.current?.blur() @@ -60,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) { } else { return } - }, [editable, shortcuts, onCancel]) + }, [editable, shortcutManager, onCancel]) React.useEffect(() => { cancelled.current = false @@ -80,19 +74,19 @@ export default function EditableSpan(props: EditableSpanProps) { }} > { - passthrough.onBlur?.(event) if (!cancelled.current) { event.currentTarget.form?.requestSubmit() } }} onKeyDown={event => { - passthrough.onKeyDown?.(event) if ( !event.isPropagationStopped() && ((event.ctrlKey && @@ -119,26 +113,35 @@ export default function EditableSpan(props: EditableSpanProps) { setIsSubmittable(checkSubmittable(event.currentTarget.value)) }, })} - {...passthrough} /> {isSubmittable && ( )} ) } else { - return {children} + return ( + + {children} + + ) } } diff --git a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx index 83e370a559..c157180e06 100644 --- a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx @@ -1,12 +1,13 @@ /** @file An entry in a menu. */ import * as React from 'react' -import * as shortcutsProvider from '#/providers/ShortcutsProvider' -import * as shortcutsModule from '#/utilities/shortcuts' +import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' import KeyboardShortcut from '#/components/dashboard/keyboardShortcut' import SvgMask from '#/components/SvgMask' +import * as shortcutManagerModule from '#/utilities/ShortcutManager' + // ================= // === MenuEntry === // ================= @@ -14,7 +15,7 @@ import SvgMask from '#/components/SvgMask' /** Props for a {@link MenuEntry}. */ export interface MenuEntryProps { hidden?: boolean - action: shortcutsModule.KeyboardAction + action: shortcutManagerModule.KeyboardAction /** When true, the button is not clickable. */ disabled?: boolean title?: string @@ -25,18 +26,18 @@ export interface MenuEntryProps { /** An item in a menu. */ export default function MenuEntry(props: MenuEntryProps) { const { hidden = false, action, disabled = false, title, paddingClassName, doAction } = props - const { shortcuts } = shortcutsProvider.useShortcuts() - const info = shortcuts.keyboardShortcutInfo[action] + const { shortcutManager } = shortcutManagerProvider.useShortcutManager() + const info = shortcutManager.keyboardShortcutInfo[action] React.useEffect(() => { // This is slower than registering every shortcut in the context menu at once. if (!disabled) { - return shortcuts.registerKeyboardHandlers({ + return shortcutManager.registerKeyboardHandlers({ [action]: doAction, }) } else { return } - }, [disabled, shortcuts, action, doAction]) + }, [disabled, shortcutManager, action, doAction]) return hidden ? null : ( ) } case permissionsModule.Permission.read: case permissionsModule.Permission.view: { return ( -
{children}
- + ) } } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx index e12d8aedfc..115ecc191d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionSelector.tsx @@ -1,13 +1,14 @@ /** @file A selector for all possible permissions. */ import * as React from 'react' -import type * as backend from '#/services/backend' -import type * as permissions from '#/utilities/permissions' -import * as permissionsModule from '#/utilities/permissions' - import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector' import Modal from '#/components/Modal' +import type * as backend from '#/services/Backend' + +import type * as permissions from '#/utilities/permissions' +import * as permissionsModule from '#/utilities/permissions' + // ================= // === Constants === // ================= diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx index c0b8d3290d..d979135faf 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/PermissionTypeSelector.tsx @@ -1,7 +1,8 @@ /** @file A selector for all possible permission types. */ import * as React from 'react' -import * as backend from '#/services/backend' +import * as backend from '#/services/Backend' + import * as permissions from '#/utilities/permissions' // ================= diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx index 2fd10fc1ca..566c255a5b 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx @@ -7,23 +7,27 @@ import ArrowUpIcon from 'enso-assets/arrow_up.svg' import PlayIcon from 'enso-assets/play.svg' import StopIcon from 'enso-assets/stop.svg' -import type * as assetEvent from '#/events/assetEvent' -import AssetEventType from '#/events/AssetEventType' import * as eventHooks from '#/hooks/eventHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' + import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' import * as localStorageProvider from '#/providers/LocalStorageProvider' import * as modalProvider from '#/providers/ModalProvider' -import * as backendModule from '#/services/backend' -import * as remoteBackend from '#/services/remoteBackend' -import * as errorModule from '#/utilities/error' -import * as localStorageModule from '#/utilities/localStorage' -import * as object from '#/utilities/object' + +import type * as assetEvent from '#/events/assetEvent' +import AssetEventType from '#/events/AssetEventType' import Spinner, * as spinner from '#/components/Spinner' import SvgMask from '#/components/SvgMask' +import * as backendModule from '#/services/Backend' +import * as remoteBackend from '#/services/RemoteBackend' + +import * as errorModule from '#/utilities/error' +import * as localStorageModule from '#/utilities/LocalStorage' +import * as object from '#/utilities/object' + // ================= // === Constants === // ================= diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx index ef3a6f57f1..92b9875e8a 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -3,29 +3,33 @@ import * as React from 'react' import NetworkIcon from 'enso-assets/network.svg' -import AssetEventType from '#/events/AssetEventType' -import AssetListEventType from '#/events/AssetListEventType' import * as eventHooks from '#/hooks/eventHooks' +import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' + import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' -import * as shortcutsProvider from '#/providers/ShortcutsProvider' -import * as backendModule from '#/services/backend' -import * as assetTreeNode from '#/utilities/assetTreeNode' -import * as eventModule from '#/utilities/event' -import * as indent from '#/utilities/indent' -import * as object from '#/utilities/object' -import * as permissions from '#/utilities/permissions' -import * as shortcutsModule from '#/utilities/shortcuts' -import * as string from '#/utilities/string' -import * as validation from '#/utilities/validation' -import Visibility from '#/utilities/visibility' +import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' + +import AssetEventType from '#/events/AssetEventType' +import AssetListEventType from '#/events/AssetListEventType' import type * as column from '#/components/dashboard/column' import ProjectIcon from '#/components/dashboard/ProjectIcon' import EditableSpan from '#/components/EditableSpan' import SvgMask from '#/components/SvgMask' +import * as backendModule from '#/services/Backend' + +import * as eventModule from '#/utilities/event' +import * as indent from '#/utilities/indent' +import * as object from '#/utilities/object' +import * as permissions from '#/utilities/permissions' +import * as shortcutManagerModule from '#/utilities/ShortcutManager' +import * as string from '#/utilities/string' +import * as validation from '#/utilities/validation' +import Visibility from '#/utilities/visibility' + // =================== // === ProjectName === // =================== @@ -43,13 +47,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { const toastAndLog = toastAndLogHooks.useToastAndLog() const { backend } = backendProvider.useBackend() const { organization } = authProvider.useNonPartialUserSession() - const { shortcuts } = shortcutsProvider.useShortcuts() + const { shortcutManager } = shortcutManagerProvider.useShortcutManager() const asset = item.item if (asset.type !== backendModule.AssetType.project) { // eslint-disable-next-line no-restricted-syntax - throw new Error('`ProjectNameColumn` can only display project assets.') + throw new Error('`ProjectNameColumn` can only display projects.') } - const setAsset = assetTreeNode.useSetAsset(asset, setItem) + const setAsset = setAssetHooks.useSetAsset(asset, setItem) const ownPermission = asset.permissions?.find(permission => permission.user.user_email === organization?.email) ?? null @@ -251,7 +255,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { onClick={event => { if (rowState.isEditingName || isOtherUserUsingProject) { // The project should neither be edited nor opened in these cases. - } else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.open, event)) { + } else if ( + shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.open, event) + ) { // It is a double click; open the project. dispatchAssetEvent({ type: AssetEventType.openProject, @@ -259,7 +265,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { shouldAutomaticallySwitchPage: true, runInBackground: false, }) - } else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.run, event)) { + } else if ( + shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.run, event) + ) { dispatchAssetEvent({ type: AssetEventType.openProject, id: asset.id, @@ -270,7 +278,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { !isRunning && eventModule.isSingleClick(event) && ((selected && numberOfSelectedItems === 1) || - shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event)) + shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) ) { setRowState(object.merger({ isEditingName: true })) } @@ -296,7 +304,15 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { /> )} (nodeMap.current.get(item.directoryKey)?.children ?? []).every( child => @@ -318,13 +334,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { inputTitle: validation.LOCAL_PROJECT_NAME_TITLE, } : {})} - className={`bg-transparent grow leading-170 h-6 py-px ${ - rowState.isEditingName - ? 'cursor-text' - : canExecute && !isOtherUserUsingProject - ? 'cursor-pointer' - : '' - }`} > {asset.title} diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx index 80c378a2f4..8c4d4cc474 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx @@ -3,25 +3,29 @@ import * as React from 'react' import ConnectorIcon from 'enso-assets/connector.svg' -import AssetEventType from '#/events/AssetEventType' -import AssetListEventType from '#/events/AssetListEventType' import * as eventHooks from '#/hooks/eventHooks' +import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' -import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal' + import * as backendProvider from '#/providers/BackendProvider' import * as modalProvider from '#/providers/ModalProvider' -import * as shortcutsProvider from '#/providers/ShortcutsProvider' -import * as backendModule from '#/services/backend' -import * as assetTreeNode from '#/utilities/assetTreeNode' +import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' + +import AssetEventType from '#/events/AssetEventType' +import AssetListEventType from '#/events/AssetListEventType' + +import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal' + +import type * as column from '#/components/dashboard/column' + +import * as backendModule from '#/services/Backend' + import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' -import * as shortcutsModule from '#/utilities/shortcuts' +import * as shortcutManagerModule from '#/utilities/ShortcutManager' import Visibility from '#/utilities/visibility' -import type * as column from '#/components/dashboard/column' -import EditableSpan from '#/components/EditableSpan' - // ===================== // === ConnectorName === // ===================== @@ -38,20 +42,13 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { const toastAndLog = toastAndLogHooks.useToastAndLog() const { setModal } = modalProvider.useSetModal() const { backend } = backendProvider.useBackend() - const { shortcuts } = shortcutsProvider.useShortcuts() + const { shortcutManager } = shortcutManagerProvider.useShortcutManager() const asset = item.item if (asset.type !== backendModule.AssetType.secret) { // eslint-disable-next-line no-restricted-syntax throw new Error('`SecretNameColumn` can only display secrets.') } - const setAsset = assetTreeNode.useSetAsset(asset, setItem) - - // TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the - // context menu entry should be re-added. - // Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505. - const doRename = async () => { - await Promise.resolve(null) - } + const setAsset = setAssetHooks.useSetAsset(asset, setItem) eventHooks.useEventHandler(assetEvents, async event => { switch (event.type) { @@ -122,7 +119,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { onClick={event => { if ( eventModule.isSingleClick(event) && - (selected || shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event)) + (selected || + shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) ) { setRowState(object.merger({ isEditingName: true })) } else if (eventModule.isDoubleClick(event)) { @@ -144,27 +142,10 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { }} > - { - setRowState(object.merger({ isEditingName: false })) - if (newTitle !== asset.title) { - const oldTitle = asset.title - setAsset(object.merger({ title: newTitle })) - try { - await doRename() - } catch { - setAsset(object.merger({ title: oldTitle })) - } - } - }} - onCancel={() => { - setRowState(object.merger({ isEditingName: false })) - }} - className="bg-transparent grow leading-170 h-6 py-px" - > + {/* Secrets cannot be renamed. */} + {asset.title} - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermissions.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermissions.tsx index b424b7e44f..23a4115dbf 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermissions.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/UserPermissions.tsx @@ -2,12 +2,15 @@ import * as React from 'react' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' + import * as backendProvider from '#/providers/BackendProvider' -import * as backendModule from '#/services/backend' -import * as object from '#/utilities/object' import PermissionSelector from '#/components/dashboard/PermissionSelector' +import * as backendModule from '#/services/Backend' + +import * as object from '#/utilities/object' + /** Props for a {@link UserPermissions}. */ export interface UserPermissionsProps { asset: backendModule.Asset diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts b/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts index a9209aa518..fb61c366b0 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts @@ -1,7 +1,5 @@ /** @file Column types and column display modes. */ import type * as assetsTable from '#/layouts/dashboard/AssetsTable' -import type * as backendModule from '#/services/backend' -import type * as assetTreeNode from '#/utilities/assetTreeNode' import * as columnUtils from '#/components/dashboard/column/columnUtils' import DocsColumn from '#/components/dashboard/column/DocsColumn' @@ -11,6 +9,10 @@ import NameColumn from '#/components/dashboard/column/NameColumn' import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn' import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn' +import type * as backendModule from '#/services/Backend' + +import type AssetTreeNode from '#/utilities/AssetTreeNode' + // =================== // === AssetColumn === // =================== @@ -18,8 +20,8 @@ import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn' /** Props for an arbitrary variant of {@link backendModule.Asset}. */ export interface AssetColumnProps { keyProp: backendModule.AssetId - item: assetTreeNode.AssetTreeNode - setItem: React.Dispatch> + item: AssetTreeNode + setItem: React.Dispatch> selected: boolean setSelected: (selected: boolean) => void isSoleSelectedItem: boolean diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx index 00d0c1c829..17ecd394ba 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx @@ -4,17 +4,13 @@ import * as React from 'react' import Plus2Icon from 'enso-assets/plus2.svg' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' -import Category from '#/layouts/dashboard/CategorySwitcher/Category' -import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal' + import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' import * as modalProvider from '#/providers/ModalProvider' -import type * as backendModule from '#/services/backend' -import * as assetQuery from '#/utilities/assetQuery' -import * as object from '#/utilities/object' -import * as permissions from '#/utilities/permissions' -import * as shortcuts from '#/utilities/shortcuts' -import * as uniqueString from '#/utilities/uniqueString' + +import Category from '#/layouts/dashboard/CategorySwitcher/Category' +import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal' import ContextMenu from '#/components/ContextMenu' import ContextMenus from '#/components/ContextMenus' @@ -23,6 +19,14 @@ import Label from '#/components/dashboard/Label' import * as labelUtils from '#/components/dashboard/Label/labelUtils' import MenuEntry from '#/components/MenuEntry' +import type * as backendModule from '#/services/Backend' + +import * as assetQuery from '#/utilities/AssetQuery' +import * as object from '#/utilities/object' +import * as permissions from '#/utilities/permissions' +import * as shortcutManager from '#/utilities/ShortcutManager' +import * as uniqueString from '#/utilities/uniqueString' + // ==================== // === LabelsColumn === // ==================== @@ -71,6 +75,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { .map(label => (