From 1a644560e0010136c7ef530193f8d5f7c24635d3 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 28 Nov 2024 20:10:49 +1000 Subject: [PATCH 01/27] Remove section headers from integration tests --- .../dashboard/actions/BaseActions.ts | 12 ------------ .../dashboard/actions/DrivePageActions.ts | 12 ------------ .../dashboard/actions/EditorPageActions.ts | 4 ---- .../dashboard/actions/ForgotPasswordPageActions.ts | 4 ---- .../dashboard/actions/LoginPageActions.ts | 4 ---- .../dashboard/actions/NewDataLinkModalActions.ts | 8 -------- .../dashboard/actions/PageActions.ts | 4 ---- .../dashboard/actions/RegisterPageActions.ts | 4 ---- .../dashboard/actions/SettingsPageActions.ts | 4 ---- .../dashboard/actions/SetupDonePageActions.ts | 4 ---- .../dashboard/actions/SetupInvitePageActions.ts | 4 ---- .../actions/SetupOrganizationPageActions.ts | 4 ---- .../dashboard/actions/SetupPlanPageActions.ts | 4 ---- .../dashboard/actions/SetupTeamPageActions.ts | 4 ---- .../dashboard/actions/SetupUsernamePageActions.ts | 4 ---- .../dashboard/actions/StartModalActions.ts | 4 ---- .../dashboard/actions/contextMenuActions.ts | 8 -------- .../dashboard/actions/goToPageActions.ts | 8 -------- app/gui/integration-test/dashboard/actions/index.ts | 8 -------- .../dashboard/actions/openUserMenuAction.ts | 4 ---- .../dashboard/actions/userMenuActions.ts | 8 -------- app/gui/integration-test/dashboard/api.ts | 8 -------- .../integration-test/dashboard/assetPanel.spec.ts | 8 -------- app/gui/integration-test/dashboard/copy.spec.ts | 4 ---- .../integration-test/dashboard/createAsset.spec.ts | 8 -------- .../integration-test/dashboard/loginLogout.spec.ts | 4 ---- .../integration-test/dashboard/loginScreen.spec.ts | 4 ---- app/gui/integration-test/dashboard/signUp.spec.ts | 4 ---- app/gui/integration-test/dashboard/sort.spec.ts | 8 -------- 29 files changed, 168 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 1e22ca9a1817..95f69f6a2fc0 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -5,28 +5,16 @@ import type * as inputBindings from '#/utilities/inputBindings' import { modModifier } from '.' -// ==================== -// === PageCallback === -// ==================== - /** A callback that performs actions on a {@link test.Page}. */ export interface PageCallback { (input: test.Page): Promise | void } -// ======================= -// === LocatorCallback === -// ======================= - /** A callback that performs actions on a {@link test.Locator}. */ export interface LocatorCallback { (input: test.Locator): Promise | void } -// =================== -// === BaseActions === -// =================== - /** * The base class from which all `Actions` classes are derived. * It contains method common to all `Actions` subclasses. diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 805aa4945604..4558819c0d11 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -21,25 +21,13 @@ import NewDataLinkModalActions from './NewDataLinkModalActions' import PageActions from './PageActions' import StartModalActions from './StartModalActions' -// ================= -// === Constants === -// ================= - const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } -// ======================= -// === locateAssetRows === -// ======================= - /** Find all assets table rows (if any). */ function locateAssetRows(page: test.Page) { return locateAssetsTable(page).getByTestId('asset-row') } -// ======================== -// === DrivePageActions === -// ======================== - /** Actions for the "drive" page. */ export default class DrivePageActions extends PageActions { /** Actions for navigating to another page. */ diff --git a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts index 4df9a30fb59e..c75d30b5b968 100644 --- a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts @@ -2,10 +2,6 @@ import * as goToPageActions from './goToPageActions' import PageActions from './PageActions' -// ========================= -// === EditorPageActions === -// ========================= - /** Actions for the "editor" page. */ export default class EditorPageActions extends PageActions { /** Actions for navigating to another page. */ diff --git a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts index 738975c79fe0..c980264df5cc 100644 --- a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts @@ -5,10 +5,6 @@ import { TEXT, VALID_EMAIL } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' -// ================================= -// === ForgotPasswordPageActions === -// ================================= - /** Available actions for the login page. */ export default class ForgotPasswordPageActions extends BaseActions { /** Actions for navigating to another page. */ diff --git a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts index 9d2ca08fa6b8..8b4df390ac29 100644 --- a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts @@ -8,10 +8,6 @@ import ForgotPasswordPageActions from './ForgotPasswordPageActions' import RegisterPageActions from './RegisterPageActions' import SetupUsernamePageActions from './SetupUsernamePageActions' -// ======================== -// === LoginPageActions === -// ======================== - /** Available actions for the login page. */ export default class LoginPageActions extends BaseActions { /** Actions for navigating to another page. */ diff --git a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts index 9a5835743345..680431739251 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -6,19 +6,11 @@ import type * as baseActions from './BaseActions' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' -// ============================== -// === locateNewDataLinkModal === -// ============================== - /** Locate the "new data link" modal. */ function locateNewDataLinkModal(page: test.Page) { return page.getByRole('dialog').filter({ has: page.getByText('Create Datalink') }) } -// =============================== -// === NewDataLinkModalActions === -// =============================== - /** Actions for a "new Data Link" modal. */ export default class NewDataLinkModalActions extends BaseActions { /** Cancel creating the new Data Link (don't submit the form). */ diff --git a/app/gui/integration-test/dashboard/actions/PageActions.ts b/app/gui/integration-test/dashboard/actions/PageActions.ts index 614c15eeec5e..37b98398e2f9 100644 --- a/app/gui/integration-test/dashboard/actions/PageActions.ts +++ b/app/gui/integration-test/dashboard/actions/PageActions.ts @@ -3,10 +3,6 @@ import BaseActions from './BaseActions' import * as openUserMenuAction from './openUserMenuAction' import * as userMenuActions from './userMenuActions' -// =================== -// === PageActions === -// =================== - /** Actions common to all pages. */ export default class PageActions extends BaseActions { /** Actions related to the User Menu. */ diff --git a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts index dcdd3d8fc46e..9b99c773008c 100644 --- a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts @@ -5,10 +5,6 @@ import { TEXT, VALID_EMAIL, VALID_PASSWORD } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' -// ======================== -// === LoginPageActions === -// ======================== - /** Available actions for the login page. */ export default class RegisterPageActions extends BaseActions { /** Actions for navigating to another page. */ diff --git a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts index 25a250fc4a57..eff18edd4214 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts @@ -2,10 +2,6 @@ import * as goToPageActions from './goToPageActions' import PageActions from './PageActions' -// =========================== -// === SettingsPageActions === -// =========================== - // TODO: split settings page actions into different classes for each settings tab. /** Actions for the "settings" page. */ export default class SettingsPageActions extends PageActions { diff --git a/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts index ca417a883af4..b969df8ff8a0 100644 --- a/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts @@ -3,10 +3,6 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' -// ============================ -// === SetupDonePageActions === -// ============================ - /** Actions for the fourth step of the "setup" page. */ export default class SetupDonePageActions extends BaseActions { /** Go to the drive page. */ diff --git a/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts index 062dce8c5fff..cbeedcfeefa5 100644 --- a/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts @@ -3,10 +3,6 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupTeamPageActions from './SetupTeamPageActions' -// ============================== -// === SetupInvitePageActions === -// ============================== - /** Actions for the "invite users" step of the "setup" page. */ export default class SetupInvitePageActions extends BaseActions { /** Invite users by email. */ diff --git a/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts index 6f1a5eca6864..1298f6a6b980 100644 --- a/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts @@ -3,10 +3,6 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupInvitePageActions from './SetupInvitePageActions' -// ==================================== -// === SetupOrganizationPageActions === -// ==================================== - /** Actions for the third step of the "setup" page. */ export default class SetupOrganizationPageActions extends BaseActions { /** Set the organization name for this organization. */ diff --git a/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts index ecd0208a8b64..65dc4d555f9d 100644 --- a/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts @@ -6,10 +6,6 @@ import BaseActions from './BaseActions' import SetupDonePageActions from './SetupDonePageActions' import SetupOrganizationPageActions from './SetupOrganizationPageActions' -// ============================ -// === SetupPlanPageActions === -// ============================ - /** Actions for the "select plan" step of the "setup" page. */ export default class SetupPlanPageActions extends BaseActions { /** Select a plan. */ diff --git a/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts index fe2010d9b100..81c16637b78e 100644 --- a/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts @@ -3,10 +3,6 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupDonePageActions from './SetupDonePageActions' -// ================================ -// === SetupTeamNamePageActions === -// ================================ - /** Actions for the "setup team name" page. */ export default class SetupTeamNamePagePageActions extends BaseActions { /** Set the username for a new user that does not yet have a username. */ diff --git a/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts index 0a91f27837b7..68756ea2f196 100644 --- a/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts @@ -3,10 +3,6 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupPlanPageActions from './SetupPlanPageActions' -// ================================ -// === SetupUsernamePageActions === -// ================================ - /** Actions for the "setup" page. */ export default class SetupUsernamePageActions extends BaseActions { /** Set the username for a new user that does not yet have a username. */ diff --git a/app/gui/integration-test/dashboard/actions/StartModalActions.ts b/app/gui/integration-test/dashboard/actions/StartModalActions.ts index 9202fe4b8b2e..fae1c1e8c33d 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -4,10 +4,6 @@ import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' -// ========================= -// === StartModalActions === -// ========================= - /** Actions for the "start" modal. */ export default class StartModalActions extends BaseActions { /** Close this modal and go back to the Drive page. */ diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index a9e443b36ead..efe4144edd7e 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -4,10 +4,6 @@ import type * as baseActions from './BaseActions' import type BaseActions from './BaseActions' import EditorPageActions from './EditorPageActions' -// ========================== -// === ContextMenuActions === -// ========================== - /** Actions for the context menu. */ export interface ContextMenuActions { readonly open: () => T @@ -34,10 +30,6 @@ export interface ContextMenuActions { readonly newDataLink: () => T } -// ========================== -// === contextMenuActions === -// ========================== - /** Generate actions for the context menu. */ export function contextMenuActions( step: (name: string, callback: baseActions.PageCallback) => T, diff --git a/app/gui/integration-test/dashboard/actions/goToPageActions.ts b/app/gui/integration-test/dashboard/actions/goToPageActions.ts index ff054a1a4b20..aede5ccf642d 100644 --- a/app/gui/integration-test/dashboard/actions/goToPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/goToPageActions.ts @@ -5,10 +5,6 @@ import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' import SettingsPageActions from './SettingsPageActions' -// ======================= -// === GoToPageActions === -// ======================= - /** Actions for going to a different page. */ export interface GoToPageActions { readonly drive: () => DrivePageActions @@ -16,10 +12,6 @@ export interface GoToPageActions { readonly settings: () => SettingsPageActions } -// ======================= -// === goToPageActions === -// ======================= - /** Generate actions for going to a different page. */ export function goToPageActions( step: (name: string, callback: baseActions.PageCallback) => BaseActions, diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 6581a4bb1445..82e795598c9f 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -7,10 +7,6 @@ import * as apiModule from '../api' import DrivePageActions from './DrivePageActions' import LoginPageActions from './LoginPageActions' -// ================= -// === Constants === -// ================= - /** An example password that does not meet validation requirements. */ export const INVALID_PASSWORD = 'password' /** An example password that meets validation requirements. */ @@ -19,10 +15,6 @@ export const VALID_PASSWORD = 'Password0!' export const VALID_EMAIL = 'email@example.com' export const TEXT = TEXTS.english -// ================ -// === Locators === -// ================ - // === Input locators === /** Find an email input (if any) on the current page. */ diff --git a/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts index 554a4f42251b..08a314378925 100644 --- a/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts +++ b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts @@ -3,10 +3,6 @@ import { TEXT } from '.' import type BaseActions from './BaseActions' import type { PageCallback } from './BaseActions' -// ========================== -// === openUserMenuAction === -// ========================== - /** An action to open the User Menu. */ export function openUserMenuAction( step: (name: string, callback: PageCallback) => T, diff --git a/app/gui/integration-test/dashboard/actions/userMenuActions.ts b/app/gui/integration-test/dashboard/actions/userMenuActions.ts index ec6f9d0d973d..3c7f429c260c 100644 --- a/app/gui/integration-test/dashboard/actions/userMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/userMenuActions.ts @@ -6,10 +6,6 @@ import type BaseActions from './BaseActions' import LoginPageActions from './LoginPageActions' import SettingsPageActions from './SettingsPageActions' -// ======================= -// === UserMenuActions === -// ======================= - /** Actions for the user menu. */ export interface UserMenuActions { readonly downloadApp: (callback: (download: test.Download) => Promise | void) => T @@ -18,10 +14,6 @@ export interface UserMenuActions { readonly goToLoginPage: () => LoginPageActions } -// ======================= -// === userMenuActions === -// ======================= - /** Generate actions for the user menu. */ export function userMenuActions( step: (name: string, callback: baseActions.PageCallback) => T, diff --git a/app/gui/integration-test/dashboard/api.ts b/app/gui/integration-test/dashboard/api.ts index aa052fd1611f..a3afed067754 100644 --- a/app/gui/integration-test/dashboard/api.ts +++ b/app/gui/integration-test/dashboard/api.ts @@ -17,10 +17,6 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } -// ================= -// === Constants === -// ================= - const __dirname = dirname(fileURLToPath(import.meta.url)) const MOCK_SVG = ` @@ -58,10 +54,6 @@ const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*') const BASE_URL = 'https://mock/' const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/' -// =============== -// === mockApi === -// =============== - /** Parameters for {@link mockApi}. */ export interface MockParams { readonly page: test.Page diff --git a/app/gui/integration-test/dashboard/assetPanel.spec.ts b/app/gui/integration-test/dashboard/assetPanel.spec.ts index 6126441d2472..b0315d375419 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -7,10 +7,6 @@ import * as permissions from '#/utilities/permissions' import * as actions from './actions' -// ================= -// === Constants === -// ================= - /** An example description for the asset selected in the asset panel. */ const DESCRIPTION = 'foo bar' /** An example owner username for the asset selected in the asset panel. */ @@ -18,10 +14,6 @@ const USERNAME = 'baz quux' /** An example owner email for the asset selected in the asset panel. */ const EMAIL = 'baz.quux@email.com' -// ============= -// === Tests === -// ============= - test('open and close asset panel', ({ page }) => actions .mockAllAndLogin({ page }) diff --git a/app/gui/integration-test/dashboard/copy.spec.ts b/app/gui/integration-test/dashboard/copy.spec.ts index feb5f700dc96..160f22838a10 100644 --- a/app/gui/integration-test/dashboard/copy.spec.ts +++ b/app/gui/integration-test/dashboard/copy.spec.ts @@ -3,10 +3,6 @@ import * as test from '@playwright/test' import * as actions from './actions' -// ============= -// === Tests === -// ============= - test.test('copy', ({ page }) => actions .mockAllAndLogin({ page }) diff --git a/app/gui/integration-test/dashboard/createAsset.spec.ts b/app/gui/integration-test/dashboard/createAsset.spec.ts index ccc59dede642..ecff3640a016 100644 --- a/app/gui/integration-test/dashboard/createAsset.spec.ts +++ b/app/gui/integration-test/dashboard/createAsset.spec.ts @@ -3,10 +3,6 @@ import * as test from '@playwright/test' import * as actions from './actions' -// ================= -// === Constants === -// ================= - /** The name of the uploaded file. */ const FILE_NAME = 'foo.txt' /** The contents of the uploaded file. */ @@ -16,10 +12,6 @@ const SECRET_NAME = 'a secret name' /** The value of the created secret. */ const SECRET_VALUE = 'a secret value' -// ============= -// === Tests === -// ============= - test.test('create folder', ({ page }) => actions .mockAllAndLogin({ page }) diff --git a/app/gui/integration-test/dashboard/loginLogout.spec.ts b/app/gui/integration-test/dashboard/loginLogout.spec.ts index a11ae467eee3..3ee7793579e3 100644 --- a/app/gui/integration-test/dashboard/loginLogout.spec.ts +++ b/app/gui/integration-test/dashboard/loginLogout.spec.ts @@ -3,10 +3,6 @@ import * as test from '@playwright/test' import * as actions from './actions' -// ============= -// === Tests === -// ============= - // Reset storage state for this file to avoid being authenticated test.test.use({ storageState: { cookies: [], origins: [] } }) diff --git a/app/gui/integration-test/dashboard/loginScreen.spec.ts b/app/gui/integration-test/dashboard/loginScreen.spec.ts index ca0a5fb23940..c5a4af7fb5df 100644 --- a/app/gui/integration-test/dashboard/loginScreen.spec.ts +++ b/app/gui/integration-test/dashboard/loginScreen.spec.ts @@ -3,10 +3,6 @@ import * as test from '@playwright/test' import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions' -// ============= -// === Tests === -// ============= - // Reset storage state for this file to avoid being authenticated test.test.use({ storageState: { cookies: [], origins: [] } }) diff --git a/app/gui/integration-test/dashboard/signUp.spec.ts b/app/gui/integration-test/dashboard/signUp.spec.ts index 6d9d6b556325..3892e7f61f15 100644 --- a/app/gui/integration-test/dashboard/signUp.spec.ts +++ b/app/gui/integration-test/dashboard/signUp.spec.ts @@ -3,10 +3,6 @@ import * as test from '@playwright/test' import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions' -// ============= -// === Tests === -// ============= - // Reset storage state for this file to avoid being authenticated test.test.use({ storageState: { cookies: [], origins: [] } }) diff --git a/app/gui/integration-test/dashboard/sort.spec.ts b/app/gui/integration-test/dashboard/sort.spec.ts index f707b745b32f..b40d0101a940 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -5,18 +5,10 @@ import * as dateTime from '#/utilities/dateTime' import * as actions from './actions' -// ================= -// === Constants === -// ================= - const START_DATE_EPOCH_MS = 1.7e12 /** The number of milliseconds in a minute. */ const MIN_MS = 60_000 -// ============= -// === Tests === -// ============= - test.test('sort', async ({ page }) => { await actions.mockAll({ page, From d5eee57ecaf33563e39343cf575a43dd2e142e72 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 29 Nov 2024 21:05:40 +1000 Subject: [PATCH 02/27] Add `calls` to E2E tests --- .../dashboard/actions/BaseActions.ts | 26 +- .../dashboard/actions/DrivePageActions.ts | 22 +- .../dashboard/actions/EditorPageActions.ts | 6 +- .../actions/ForgotPasswordPageActions.ts | 8 +- .../dashboard/actions/LoginPageActions.ts | 14 +- .../actions/NewDataLinkModalActions.ts | 2 +- .../dashboard/actions/PageActions.ts | 2 +- .../dashboard/actions/RegisterPageActions.ts | 8 +- .../dashboard/actions/SettingsPageActions.ts | 4 +- .../dashboard/actions/SetupDonePageActions.ts | 4 +- .../actions/SetupInvitePageActions.ts | 6 +- .../actions/SetupOrganizationPageActions.ts | 4 +- .../dashboard/actions/SetupPlanPageActions.ts | 10 +- .../dashboard/actions/SetupTeamPageActions.ts | 4 +- .../actions/SetupUsernamePageActions.ts | 4 +- .../dashboard/actions/StartModalActions.ts | 6 +- .../dashboard/actions/contextMenuActions.ts | 12 +- .../dashboard/actions/goToPageActions.ts | 20 +- .../dashboard/actions/index.ts | 17 +- .../dashboard/actions/openUserMenuAction.ts | 4 +- app/gui/integration-test/dashboard/api.ts | 254 ++++++++++++++---- .../dashboard/renameAsset.spec.ts | 69 +++++ 22 files changed, 371 insertions(+), 135 deletions(-) create mode 100644 app/gui/integration-test/dashboard/renameAsset.spec.ts diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 95f69f6a2fc0..05a956ef31db 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -6,8 +6,8 @@ import type * as inputBindings from '#/utilities/inputBindings' import { modModifier } from '.' /** A callback that performs actions on a {@link test.Page}. */ -export interface PageCallback { - (input: test.Page): Promise | void +export interface PageCallback { + (input: test.Page, context: Context): Promise | void } /** A callback that performs actions on a {@link test.Locator}. */ @@ -22,10 +22,11 @@ export interface LocatorCallback { * * [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables */ -export default class BaseActions implements Promise { +export default class BaseActions implements Promise { /** Create a {@link BaseActions}. */ constructor( protected readonly page: test.Page, + protected readonly context: Context, private readonly promise = Promise.resolve(), ) {} @@ -87,10 +88,15 @@ export default class BaseActions implements Promise { /** Return a {@link BaseActions} with the same {@link Promise} but a different type. */ into< - T extends new (page: test.Page, promise: Promise, ...args: Args) => InstanceType, + T extends new ( + page: test.Page, + context: Context, + promise: Promise, + ...args: Args + ) => InstanceType, Args extends readonly unknown[], >(clazz: T, ...args: Args): InstanceType { - return new clazz(this.page, this.promise, ...args) + return new clazz(this.page, this.context, this.promise, ...args) } /** @@ -98,18 +104,18 @@ export default class BaseActions implements Promise { * specific methods; this is more or less an escape hatch used ONLY when the methods do not * support desired functionality. */ - do(callback: PageCallback): this { + do(callback: PageCallback): this { // @ts-expect-error This is SAFE, but only when the constructor of this class has the exact // same parameters as `BaseActions`. return new this.constructor( this.page, - this.then(() => callback(this.page)), + this.then(() => callback(this.page, this.context)), ) } /** Perform an action on the current page. */ - step(name: string, callback: PageCallback) { - return this.do(() => test.test.step(name, () => callback(this.page))) + step(name: string, callback: PageCallback) { + return this.do(() => test.test.step(name, () => callback(this.page, this.context))) } /** @@ -140,7 +146,7 @@ export default class BaseActions implements Promise { } /** Perform actions with the "Mod" modifier key pressed. */ - withModPressed(callback: (actions: this) => R) { + withModPressed>(callback: (actions: this) => R) { return callback( this.step('Press "Mod"', async (page) => { await page.keyboard.down(await modModifier(page)) diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 4558819c0d11..aa35db0b0669 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -29,9 +29,9 @@ function locateAssetRows(page: test.Page) { } /** Actions for the "drive" page. */ -export default class DrivePageActions extends PageActions { +export default class DrivePageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit { + get goToPage(): Omit, 'drive'> { return goToPageActions.goToPageActions(this.step.bind(this)) } @@ -43,7 +43,7 @@ export default class DrivePageActions extends PageActions { /** Switch to a different category. */ get goToCategory() { // eslint-disable-next-line @typescript-eslint/no-this-alias - const self: DrivePageActions = this + const self: DrivePageActions = this return { /** Switch to the "cloud" category. */ cloud() { @@ -84,7 +84,7 @@ export default class DrivePageActions extends PageActions { /** Actions specific to the Drive table. */ get driveTable() { // eslint-disable-next-line @typescript-eslint/no-this-alias - const self: DrivePageActions = this + const self: DrivePageActions = this return { /** Click the column heading for the "name" column to change its sort order. */ clickNameColumnHeading() { @@ -126,10 +126,14 @@ export default class DrivePageActions extends PageActions { }, /** Interact with the set of all rows in the Drive table. */ withRows( - callback: (assetRows: test.Locator, nonAssetRows: test.Locator) => Promise | void, + callback: ( + assetRows: test.Locator, + nonAssetRows: test.Locator, + context: Context, + ) => Promise | void, ) { return self.step('Interact with drive table rows', async (page) => { - await callback(locateAssetRows(page), locateNonAssetRows(page)) + await callback(locateAssetRows(page), locateNonAssetRows(page), self.context) }) }, /** Drag a row onto another row. */ @@ -225,14 +229,14 @@ export default class DrivePageActions extends PageActions { openStartModal() { return this.step('Open "start" modal', (page) => page.getByText(TEXT.startWithATemplate).click(), - ).into(StartModalActions) + ).into(StartModalActions) } /** Create a new empty project. */ newEmptyProject() { return this.step('Create empty project', (page) => page.getByText(TEXT.newEmptyProject, { exact: true }).click(), - ).into(EditorPageActions) + ).into(EditorPageActions) } /** Interact with the drive view (the main container of this page). */ @@ -357,7 +361,7 @@ export default class DrivePageActions extends PageActions { openDataLinkModal() { return this.step('Open "new data link" modal', (page) => page.getByRole('button', { name: TEXT.newDatalink }).click(), - ).into(NewDataLinkModalActions) + ).into(NewDataLinkModalActions) } /** Interact with the context menus (the context menus MUST be visible). */ diff --git a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts index c75d30b5b968..1836df4587e2 100644 --- a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts @@ -3,13 +3,13 @@ import * as goToPageActions from './goToPageActions' import PageActions from './PageActions' /** Actions for the "editor" page. */ -export default class EditorPageActions extends PageActions { +export default class EditorPageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit { + get goToPage(): Omit, 'editor'> { return goToPageActions.goToPageActions(this.step.bind(this)) } /** Waits for the editor to load. */ - waitForEditorToLoad(): EditorPageActions { + waitForEditorToLoad(): EditorPageActions { return this.step('wait for the editor to load', async () => { await this.page.waitForSelector('[data-testid=editor]', { state: 'visible' }) }) diff --git a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts index c980264df5cc..0cf0e58243e9 100644 --- a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts @@ -6,21 +6,21 @@ import BaseActions, { type LocatorCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' /** Available actions for the login page. */ -export default class ForgotPasswordPageActions extends BaseActions { +export default class ForgotPasswordPageActions extends BaseActions { /** Actions for navigating to another page. */ get goToPage() { return { - login: (): LoginPageActions => + login: (): LoginPageActions => this.step("Go to 'login' page", async (page) => page.getByRole('link', { name: TEXT.goBackToLogin, exact: true }).click(), - ).into(LoginPageActions), + ).into(LoginPageActions), } } /** Perform a successful login. */ forgotPassword(email = VALID_EMAIL) { return this.step('Forgot password', () => this.forgotPasswordInternal(email)).into( - LoginPageActions, + LoginPageActions, ) } diff --git a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts index 8b4df390ac29..526a86a3812b 100644 --- a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts @@ -9,18 +9,18 @@ import RegisterPageActions from './RegisterPageActions' import SetupUsernamePageActions from './SetupUsernamePageActions' /** Available actions for the login page. */ -export default class LoginPageActions extends BaseActions { +export default class LoginPageActions extends BaseActions { /** Actions for navigating to another page. */ get goToPage() { return { - register: (): RegisterPageActions => + register: (): RegisterPageActions => this.step("Go to 'register' page", async (page) => page.getByRole('link', { name: TEXT.dontHaveAnAccount, exact: true }).click(), - ).into(RegisterPageActions), - forgotPassword: (): ForgotPasswordPageActions => + ).into(RegisterPageActions), + forgotPassword: (): ForgotPasswordPageActions => this.step("Go to 'forgot password' page", async (page) => page.getByRole('link', { name: TEXT.forgotYourPassword, exact: true }).click(), - ).into(ForgotPasswordPageActions), + ).into(ForgotPasswordPageActions), } } @@ -29,7 +29,7 @@ export default class LoginPageActions extends BaseActions { return this.step('Login', async (page) => { await this.loginInternal(email, password) await passAgreementsDialog({ page }) - }).into(DrivePageActions) + }).into(DrivePageActions) } /** Perform a login as a new user (a user that does not yet have a username). */ @@ -37,7 +37,7 @@ export default class LoginPageActions extends BaseActions { return this.step('Login (as new user)', async (page) => { await this.loginInternal(email, password) await passAgreementsDialog({ page }) - }).into(SetupUsernamePageActions) + }).into(SetupUsernamePageActions) } /** Perform a failing login. */ diff --git a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts index 680431739251..1996da448dcf 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -12,7 +12,7 @@ function locateNewDataLinkModal(page: test.Page) { } /** Actions for a "new Data Link" modal. */ -export default class NewDataLinkModalActions extends BaseActions { +export default class NewDataLinkModalActions extends BaseActions { /** Cancel creating the new Data Link (don't submit the form). */ cancel() { return this.step('Cancel out of "new data link" modal', async () => { diff --git a/app/gui/integration-test/dashboard/actions/PageActions.ts b/app/gui/integration-test/dashboard/actions/PageActions.ts index 37b98398e2f9..64758cd7d447 100644 --- a/app/gui/integration-test/dashboard/actions/PageActions.ts +++ b/app/gui/integration-test/dashboard/actions/PageActions.ts @@ -4,7 +4,7 @@ import * as openUserMenuAction from './openUserMenuAction' import * as userMenuActions from './userMenuActions' /** Actions common to all pages. */ -export default class PageActions extends BaseActions { +export default class PageActions extends BaseActions { /** Actions related to the User Menu. */ get userMenu() { return userMenuActions.userMenuActions(this.step.bind(this)) diff --git a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts index 9b99c773008c..be79cbbd5aed 100644 --- a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts @@ -6,14 +6,14 @@ import BaseActions, { type LocatorCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' /** Available actions for the login page. */ -export default class RegisterPageActions extends BaseActions { +export default class RegisterPageActions extends BaseActions { /** Actions for navigating to another page. */ get goToPage() { return { - login: (): LoginPageActions => + login: (): LoginPageActions => this.step("Go to 'login' page", async (page) => page.getByRole('link', { name: TEXT.alreadyHaveAnAccount, exact: true }).click(), - ).into(LoginPageActions), + ).into(LoginPageActions), } } @@ -21,7 +21,7 @@ export default class RegisterPageActions extends BaseActions { register(email = VALID_EMAIL, password = VALID_PASSWORD, confirmPassword = password) { return this.step('Reegister', () => this.registerInternal(email, password, confirmPassword), - ).into(LoginPageActions) + ).into(LoginPageActions) } /** Perform a failing login. */ diff --git a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts index eff18edd4214..24f1936b9651 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts @@ -4,9 +4,9 @@ import PageActions from './PageActions' // TODO: split settings page actions into different classes for each settings tab. /** Actions for the "settings" page. */ -export default class SettingsPageActions extends PageActions { +export default class SettingsPageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit { + get goToPage(): Omit, 'drive'> { return goToPageActions.goToPageActions(this.step.bind(this)) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts index b969df8ff8a0..22c8949bb2a5 100644 --- a/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts @@ -4,14 +4,14 @@ import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' /** Actions for the fourth step of the "setup" page. */ -export default class SetupDonePageActions extends BaseActions { +export default class SetupDonePageActions extends BaseActions { /** Go to the drive page. */ get goToPage() { return { drive: () => this.step("Finish setup and go to 'drive' page", async (page) => { await page.getByText(TEXT.goToDashboard).click() - }).into(DrivePageActions), + }).into(DrivePageActions), } } } diff --git a/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts index cbeedcfeefa5..f62361aae329 100644 --- a/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts @@ -4,19 +4,19 @@ import BaseActions from './BaseActions' import SetupTeamPageActions from './SetupTeamPageActions' /** Actions for the "invite users" step of the "setup" page. */ -export default class SetupInvitePageActions extends BaseActions { +export default class SetupInvitePageActions extends BaseActions { /** Invite users by email. */ inviteUsers(emails: string) { return this.step(`Invite users '${emails.split(/[ ;,]+/).join("', '")}'`, async (page) => { await page.getByLabel(TEXT.inviteEmailFieldLabel).getByRole('textbox').fill(emails) await page.getByText(TEXT.inviteSubmit).click() - }).into(SetupTeamPageActions) + }).into(SetupTeamPageActions) } /** Continue to the next step without inviting users. */ skipInvitingUsers() { return this.step('Skip inviting users in setup', async (page) => { await page.getByText(TEXT.skip).click() - }).into(SetupTeamPageActions) + }).into(SetupTeamPageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts index 1298f6a6b980..b3ab2bd6380e 100644 --- a/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts @@ -4,7 +4,7 @@ import BaseActions from './BaseActions' import SetupInvitePageActions from './SetupInvitePageActions' /** Actions for the third step of the "setup" page. */ -export default class SetupOrganizationPageActions extends BaseActions { +export default class SetupOrganizationPageActions extends BaseActions { /** Set the organization name for this organization. */ setOrganizationName(organizationName: string) { return this.step(`Set organization name to '${organizationName}'`, async (page) => { @@ -13,6 +13,6 @@ export default class SetupOrganizationPageActions extends BaseActions { .and(page.getByRole('textbox')) .fill(organizationName) await page.getByText(TEXT.next).click() - }).into(SetupInvitePageActions) + }).into(SetupInvitePageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts index 65dc4d555f9d..cb6d9f9e6325 100644 --- a/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts @@ -7,7 +7,7 @@ import SetupDonePageActions from './SetupDonePageActions' import SetupOrganizationPageActions from './SetupOrganizationPageActions' /** Actions for the "select plan" step of the "setup" page. */ -export default class SetupPlanPageActions extends BaseActions { +export default class SetupPlanPageActions extends BaseActions { /** Select a plan. */ selectSoloPlan() { return this.step(`Select 'solo' plan`, async (page) => { @@ -17,7 +17,7 @@ export default class SetupPlanPageActions extends BaseActions { .getByText(TEXT.licenseAgreementCheckbox) .click() await page.getByText(TEXT.startTrial).click() - }).into(SetupDonePageActions) + }).into(SetupDonePageActions) } /** Select a plan that has teams. */ @@ -34,20 +34,20 @@ export default class SetupPlanPageActions extends BaseActions { .getByText(duration === 12 ? TEXT.billingPeriodOneYear : TEXT.billingPeriodThreeYears) .click() await page.getByText(TEXT.startTrial).click() - }).into(SetupOrganizationPageActions) + }).into(SetupOrganizationPageActions) } /** Stay on the current (free) plan. */ stayOnFreePlan() { return this.step(`Stay on current plan`, async (page) => { await page.getByText(TEXT.skip).click() - }).into(SetupDonePageActions) + }).into(SetupDonePageActions) } /** Stay on the current (paid) plan. */ stayOnPaidPlan() { return this.step(`Stay on current plan`, async (page) => { await page.getByText(TEXT.skip).click() - }).into(SetupOrganizationPageActions) + }).into(SetupOrganizationPageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts b/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts index 81c16637b78e..e51c60a74f1b 100644 --- a/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts @@ -4,7 +4,7 @@ import BaseActions from './BaseActions' import SetupDonePageActions from './SetupDonePageActions' /** Actions for the "setup team name" page. */ -export default class SetupTeamNamePagePageActions extends BaseActions { +export default class SetupTeamNamePagePageActions extends BaseActions { /** Set the username for a new user that does not yet have a username. */ setTeamName(teamName: string) { return this.step(`Set team name to '${teamName}'`, async (page) => { @@ -13,6 +13,6 @@ export default class SetupTeamNamePagePageActions extends BaseActions { .and(page.getByRole('textbox')) .fill(teamName) await page.getByText(TEXT.next).click() - }).into(SetupDonePageActions) + }).into(SetupDonePageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts b/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts index 68756ea2f196..bdf608370e6f 100644 --- a/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts @@ -4,12 +4,12 @@ import BaseActions from './BaseActions' import SetupPlanPageActions from './SetupPlanPageActions' /** Actions for the "setup" page. */ -export default class SetupUsernamePageActions extends BaseActions { +export default class SetupUsernamePageActions extends BaseActions { /** Set the username for a new user that does not yet have a username. */ setUsername(username: string) { return this.step(`Set username to '${username}'`, async (page) => { await page.getByPlaceholder(TEXT.usernamePlaceholder).fill(username) await page.getByText(TEXT.next).click() - }).into(SetupPlanPageActions) + }).into(SetupPlanPageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/StartModalActions.ts b/app/gui/integration-test/dashboard/actions/StartModalActions.ts index fae1c1e8c33d..dcf3b6d6effb 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -5,11 +5,11 @@ import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' /** Actions for the "start" modal. */ -export default class StartModalActions extends BaseActions { +export default class StartModalActions extends BaseActions { /** Close this modal and go back to the Drive page. */ close() { return this.step('Close "start" modal', (page) => page.getByLabel('Close').click()).into( - DrivePageActions, + DrivePageActions, ) } @@ -20,6 +20,6 @@ export default class StartModalActions extends BaseActions { .locateSamples(page) .nth(index + 1) .click(), - ).into(EditorPageActions) + ).into(EditorPageActions) } } diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index efe4144edd7e..b81c3a47b897 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -5,7 +5,7 @@ import type BaseActions from './BaseActions' import EditorPageActions from './EditorPageActions' /** Actions for the context menu. */ -export interface ContextMenuActions { +export interface ContextMenuActions, Context> { readonly open: () => T readonly uploadToCloud: () => T readonly rename: () => T @@ -18,7 +18,7 @@ export interface ContextMenuActions { readonly share: () => T readonly label: () => T readonly duplicate: () => T - readonly duplicateProject: () => EditorPageActions + readonly duplicateProject: () => EditorPageActions readonly copy: () => T readonly cut: () => T readonly paste: () => T @@ -31,9 +31,9 @@ export interface ContextMenuActions { } /** Generate actions for the context menu. */ -export function contextMenuActions( - step: (name: string, callback: baseActions.PageCallback) => T, -): ContextMenuActions { +export function contextMenuActions, Context>( + step: (name: string, callback: baseActions.PageCallback) => T, +): ContextMenuActions { return { open: () => step('Open (context menu)', (page) => @@ -123,7 +123,7 @@ export function contextMenuActions( .getByRole('button', { name: TEXT.duplicateShortcut }) .getByText(TEXT.duplicateShortcut) .click(), - ).into(EditorPageActions), + ).into(EditorPageActions), copy: () => step('Copy (context menu)', (page) => page diff --git a/app/gui/integration-test/dashboard/actions/goToPageActions.ts b/app/gui/integration-test/dashboard/actions/goToPageActions.ts index aede5ccf642d..9940f02d5437 100644 --- a/app/gui/integration-test/dashboard/actions/goToPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/goToPageActions.ts @@ -6,16 +6,16 @@ import EditorPageActions from './EditorPageActions' import SettingsPageActions from './SettingsPageActions' /** Actions for going to a different page. */ -export interface GoToPageActions { - readonly drive: () => DrivePageActions - readonly editor: () => EditorPageActions - readonly settings: () => SettingsPageActions +export interface GoToPageActions { + readonly drive: () => DrivePageActions + readonly editor: () => EditorPageActions + readonly settings: () => SettingsPageActions } /** Generate actions for going to a different page. */ -export function goToPageActions( - step: (name: string, callback: baseActions.PageCallback) => BaseActions, -): GoToPageActions { +export function goToPageActions( + step: (name: string, callback: baseActions.PageCallback) => BaseActions, +): GoToPageActions { return { drive: () => step('Go to "Data Catalog" page', (page) => @@ -23,14 +23,14 @@ export function goToPageActions( .getByRole('tab') .filter({ has: page.getByText('Data Catalog') }) .click(), - ).into(DrivePageActions), + ).into(DrivePageActions), editor: () => step('Go to "Spatial Analysis" page', (page) => page.getByTestId('editor-tab-button').click(), - ).into(EditorPageActions), + ).into(EditorPageActions), settings: () => step('Go to "settings" page', (page) => BaseActions.press(page, 'Mod+,')).into( - SettingsPageActions, + SettingsPageActions, ), } } diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 82e795598c9f..af224b98d08e 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -764,15 +764,22 @@ export async function passAgreementsDialog({ page }: MockParams) { }) } +interface Context { + readonly api: apiModule.MockApi +} + export const mockApi = apiModule.mockApi /** Set up all mocks, without logging in. */ export function mockAll({ page, setupAPI }: MockParams) { - const actions = new LoginPageActions(page) + let api!: apiModule.MockApi + const actions = new LoginPageActions(page, { api }) actions.step('Execute all mocks', async () => { await Promise.all([ - mockApi({ page, setupAPI }), + mockApi({ page, setupAPI }).then((theApi) => { + api = theApi + }), mockDate({ page, setupAPI }), mockAllAnimations({ page }), mockUnneededUrls({ page }), @@ -785,10 +792,8 @@ export function mockAll({ page, setupAPI }: MockParams) { } /** Set up all mocks, and log in with dummy credentials. */ -export function mockAllAndLogin({ page, setupAPI }: MockParams): DrivePageActions { - mockAll({ page, setupAPI }) - - const actions = new DrivePageActions(page) +export function mockAllAndLogin({ page, setupAPI }: MockParams) { + const actions = mockAll({ page, setupAPI }).into(DrivePageActions) actions.step('Login', async () => { await login({ page, setupAPI }) diff --git a/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts index 08a314378925..02e4c73aec3c 100644 --- a/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts +++ b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts @@ -4,8 +4,8 @@ import type BaseActions from './BaseActions' import type { PageCallback } from './BaseActions' /** An action to open the User Menu. */ -export function openUserMenuAction( - step: (name: string, callback: PageCallback) => T, +export function openUserMenuAction, Context>( + step: (name: string, callback: PageCallback) => T, ) { return step('Open user menu', (page) => page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click(), diff --git a/app/gui/integration-test/dashboard/api.ts b/app/gui/integration-test/dashboard/api.ts index a3afed067754..e2566b0d0b98 100644 --- a/app/gui/integration-test/dashboard/api.ts +++ b/app/gui/integration-test/dashboard/api.ts @@ -54,6 +54,66 @@ const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*') const BASE_URL = 'https://mock/' const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/' +function array(): Readonly[] { + return [] +} + +const INITIAL_CALLS_OBJECT = { + changePassword: array<{ oldPassword: string; newPassword: string }>(), + listDirectory: array<{ + parent_id?: string + filter_by?: backend.FilterBy + labels?: backend.LabelName[] + recent_projects?: boolean + }>(), + listFiles: array(), + listProjects: array(), + listSecrets: array(), + listTags: array(), + listUsers: array(), + listUserGroups: array(), + listVersions: array(), + getProjectDetails: array<{ projectId: backend.ProjectId }>(), + copyAsset: array<{ assetId: backend.AssetId; parentId: backend.DirectoryId }>(), + listInvitations: array(), + inviteUser: array(), + createPermission: array(), + closeProject: array<{ projectId: backend.ProjectId }>(), + openProject: array<{ projectId: backend.ProjectId }>(), + deleteTag: array<{ tagId: backend.TagId }>(), + postLogEvent: array(), + uploadUserPicture: array<{ content: string }>(), + uploadOrganizationPicture: array<{ content: string }>(), + s3Put: array(), + uploadFileStart: array<{ uploadId: backend.FileId }>(), + uploadFileEnd: array(), + createSecret: array(), + createCheckoutSession: array(), + getCheckoutSession: array<{ + body: backend.CreateCheckoutSessionRequestBody + status: backend.CheckoutSessionStatus + }>(), + updateAsset: array<{ assetId: backend.AssetId } & backend.UpdateAssetRequestBody>(), + associateTag: array<{ assetId: backend.AssetId; labels: readonly backend.LabelName[] }>(), + updateDirectory: array< + { directoryId: backend.DirectoryId } & backend.UpdateDirectoryRequestBody + >(), + deleteAsset: array<{ assetId: backend.AssetId }>(), + undoDeleteAsset: array<{ assetId: backend.AssetId }>(), + createUser: array(), + createUserGroup: array(), + changeUserGroup: array<{ userId: backend.UserId } & backend.ChangeUserGroupRequestBody>(), + updateCurrentUser: array(), + usersMe: array(), + updateOrganization: array(), + getOrganization: array(), + createTag: array(), + createProject: array(), + createDirectory: array(), + getProjectContent: array<{ projectId: backend.ProjectId }>(), + getProjectAsset: array<{ projectId: backend.ProjectId }>(), +} + /** Parameters for {@link mockApi}. */ export interface MockParams { readonly page: test.Page @@ -69,7 +129,7 @@ export interface SetupAPI { } /** The return type of {@link mockApi}. */ -export type MockApi = Awaited> +export interface MockApi extends Awaited> {} export const mockApi: (params: MockParams) => Promise = mockApiInternal @@ -116,6 +176,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { website: null, subscription: {}, } + const callsObjects = new Set() let totalSeats = 1 // eslint-disable-next-line @typescript-eslint/no-unused-vars let subscriptionDuration = 0 @@ -152,6 +213,29 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { >() usersMap.set(defaultUser.userId, defaultUser) + function trackCalls() { + const calls = { ...INITIAL_CALLS_OBJECT } + callsObjects.add(calls) + return calls + } + + function pushToKey, Key extends keyof Object>( + object: Object, + key: Key, + item: Object[Key][number], + ) { + object[key].push(item) + } + + function called( + key: Key, + args: (typeof INITIAL_CALLS_OBJECT)[Key][number], + ) { + for (const callsObject of callsObjects) { + pushToKey(callsObject, key, args) + } + } + const addAsset = (asset: T) => { assets.push(asset) assetMap.set(asset.id, asset) @@ -299,7 +383,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return label } - const setLabels = (id: backend.AssetId, newLabels: backend.LabelName[]) => { + const setLabels = (id: backend.AssetId, newLabels: readonly backend.LabelName[]) => { const ids = new Set([id]) for (const [innerId, asset] of assetMap) { if (ids.has(asset.parentId)) { @@ -502,6 +586,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly newPassword: string } const body: Body = await request.postDataJSON() + called('changePassword', body) if (body.oldPassword === currentPassword) { currentPassword = body.newPassword await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) @@ -521,14 +606,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly labels?: backend.LabelName[] readonly recent_projects?: boolean } - const body = Object.fromEntries( + const query = Object.fromEntries( new URL(request.url()).searchParams.entries(), ) as unknown as Query - const parentId = body.parent_id ?? defaultDirectoryId + called('listDirectory', query) + const parentId = query.parent_id ?? defaultDirectoryId let filteredAssets = assets.filter((asset) => asset.parentId === parentId) // This lint rule is broken; there is clearly a case for `undefined` below. - switch (body.filter_by) { + switch (query.filter_by) { case backend.FilterBy.active: { filteredAssets = filteredAssets.filter((asset) => !deletedAssets.has(asset.id)) break @@ -559,18 +645,23 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return json }) await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => { + called('listFiles', {}) return { files: [] } satisfies remoteBackend.ListFilesResponseBody }) await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => { + called('listProjects', {}) return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody }) await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => { + called('listSecrets', {}) return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody }) await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => { + called('listTags', {}) return { tags: labels } satisfies remoteBackend.ListTagsResponseBody }) await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => { + called('listUsers', {}) if (currentUser != null) { return { users } satisfies remoteBackend.ListUsersResponseBody } else { @@ -579,28 +670,35 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => { + called('listUserGroups', {}) await route.fulfill({ json: userGroups }) }) - await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => ({ - versions: [ - { - ami: null, - created: dateTime.toRfc3339(new Date()), - number: { - lifecycle: - 'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development, - value: '2023.2.1-dev', - }, - // eslint-disable-next-line camelcase - version_type: (new URL(request.url()).searchParams.get('version_type') ?? - '') as backend.VersionType, - } satisfies backend.Version, - ], - })) + await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => { + called('listVersions', {}) + return { + versions: [ + { + ami: null, + created: dateTime.toRfc3339(new Date()), + number: { + lifecycle: + 'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development, + value: '2023.2.1-dev', + }, + // eslint-disable-next-line camelcase + version_type: (new URL(request.url()).searchParams.get('version_type') ?? + '') as backend.VersionType, + } satisfies backend.Version, + ], + } + }) // === Endpoints with dummy implementations === await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => { - const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('getProjectDetails', { projectId }) const project = assetMap.get(projectId) if (!project) { @@ -644,11 +742,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly parentDirectoryId: backend.DirectoryId } - const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1] + const maybeId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1] + if (!maybeId) return + const assetId = maybeId != null ? backend.DirectoryId(decodeURIComponent(maybeId)) : null // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - const asset = - assetId != null ? assetMap.get(backend.DirectoryId(decodeURIComponent(assetId))) : null + const asset = assetId != null ? assetMap.get(assetId) : null if (asset == null) { if (assetId == null) { await route.fulfill({ @@ -664,6 +763,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } else { const body: Body = request.postDataJSON() const parentId = body.parentDirectoryId + called('copyAsset', { assetId: assetId!, parentId }) // Can be any asset ID. const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`) const json: backend.CopyAssetResponse = { @@ -684,22 +784,25 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await get(remoteBackendPaths.INVITATION_PATH + '*', (): backend.ListInvitationsResponseBody => { + called('listInvitations', {}) return { invitations: [], availableLicenses: totalSeats - usersMap.size, } }) await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => { + called('inviteUser', {}) await route.fulfill() }) await post(remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async (route) => { - await route.fulfill() - }) - await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route) => { + called('createPermission', {}) await route.fulfill() }) await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => { - const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('closeProject', { projectId }) const project = assetMap.get(projectId) if (project?.projectState) { object.unsafeMutable(project.projectState).type = backend.ProjectState.closed @@ -707,7 +810,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await route.fulfill() }) await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => { - const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('openProject', { projectId }) const project = assetMap.get(projectId) @@ -723,10 +829,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { route.fulfill() }) - await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route) => { + await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const tagId = backend.TagId(maybeId) + called('deleteTag', { tagId }) await route.fulfill() }) await post(remoteBackendPaths.POST_LOG_EVENT_PATH, async (route) => { + called('postLogEvent', {}) await route.fulfill() }) @@ -735,6 +846,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await put(remoteBackendPaths.UPLOAD_USER_PICTURE_PATH + '*', async (route, request) => { const content = request.postData() if (content != null) { + called('uploadUserPicture', { content }) currentProfilePicture = content return null } else { @@ -745,6 +857,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await put(remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH + '*', async (route, request) => { const content = request.postData() if (content != null) { + called('uploadOrganizationPicture', { content }) currentOrganizationProfilePicture = content return null } else { @@ -754,6 +867,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await page.route(MOCK_S3_BUCKET_URL + '**', async (route, request) => { if (request.method() !== 'PUT') { + called('s3Put', {}) await route.fallback() } else { await route.fulfill({ @@ -765,9 +879,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await post(remoteBackendPaths.UPLOAD_FILE_START_PATH + '*', () => { + const uploadId = backend.FileId('file-' + uniqueString.uniqueString()) + called('uploadFileStart', { uploadId }) return { sourcePath: backend.S3FilePath(''), - uploadId: 'file-' + uniqueString.uniqueString(), + uploadId, presignedUrls: Array.from({ length: 10 }, () => backend.HttpsUrl(`${MOCK_S3_BUCKET_URL}${uniqueString.uniqueString()}`), ), @@ -775,6 +891,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await post(remoteBackendPaths.UPLOAD_FILE_END_PATH + '*', (_route, request) => { const body: backend.UploadFileEndRequestBody = request.postDataJSON() + called('uploadFileEnd', body) const file = addFile(body.fileName, { id: backend.FileId(body.uploadId), @@ -787,6 +904,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => { const body: backend.CreateSecretRequestBody = await request.postDataJSON() + called('createSecret', body) const secret = addSecret(body.name) return secret.id }) @@ -795,6 +913,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH + '*', async (_route, request) => { const body: backend.CreateCheckoutSessionRequestBody = await request.postDataJSON() + called('createCheckoutSession', body) return createCheckoutSession(body) }) await get( @@ -806,6 +925,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } else { const result = checkoutSessionsMap.get(backend.CheckoutSessionId(checkoutSessionId)) if (result) { + called('getCheckoutSession', result) if (currentUser) { object.unsafeMutable(currentUser).plan = result.body.plan } @@ -819,11 +939,14 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }, ) await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => { - const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '' - const body: backend.UpdateAssetRequestBody = request.postDataJSON() + const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] + if (!maybeId) return // 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)) + const assetId = backend.DirectoryId(maybeId) + const body: backend.UpdateAssetRequestBody = request.postDataJSON() + called('updateAsset', { ...body, assetId }) + const asset = assetMap.get(assetId) if (asset != null) { if (body.description != null) { object.unsafeMutable(asset).description = body.description @@ -835,19 +958,22 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => { - const assetId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] ?? '' + const maybeId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] + if (!maybeId) return + // This could be an id for an arbitrary asset, but pretend it's a + // `DirectoryId` to make TypeScript happy. + const assetId = backend.DirectoryId(maybeId) /** The type for the JSON request payload for this endpoint. */ interface Body { - readonly labels: backend.LabelName[] + readonly labels: readonly backend.LabelName[] } /** The type for the JSON response payload for this endpoint. */ interface Response { - readonly tags: backend.Label[] + readonly tags: readonly backend.Label[] } 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) + called('associateTag', { ...body, assetId }) + setLabels(assetId, body.labels) const json: Response = { tags: body.labels.flatMap((value) => { const label = labelsByValue.get(value) @@ -857,16 +983,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return json }) await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => { - const directoryId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] ?? '' + const maybeId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] + if (!maybeId) return + const directoryId = backend.DirectoryId(maybeId) const body: backend.UpdateDirectoryRequestBody = request.postDataJSON() - const asset = assetMap.get(backend.DirectoryId(directoryId)) + called('updateDirectory', { ...body, directoryId }) + const asset = assetMap.get(directoryId) if (asset == null) { await route.abort() } else { object.unsafeMutable(asset).title = body.title await route.fulfill({ json: { - id: backend.DirectoryId(directoryId), + id: directoryId, parentId: asset.parentId, title: body.title, } satisfies backend.UpdatedDirectory, @@ -874,10 +1003,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { - const assetId = decodeURIComponent(request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '') + const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] + if (!maybeId) return // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. - deleteAsset(backend.DirectoryId(assetId)) + const assetId = backend.DirectoryId(decodeURIComponent(maybeId)) + called('deleteAsset', { assetId }) + deleteAsset(assetId) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => { @@ -886,6 +1018,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { readonly assetId: backend.AssetId } const body: Body = await request.postDataJSON() + called('undoDeleteAsset', body) undeleteAsset(body.assetId) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) @@ -895,6 +1028,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const rootDirectoryId = backend.DirectoryId( organizationId.replace(/^organization-/, 'directory-'), ) + called('createUser', body) currentUser = { email: body.userEmail, name: body.userName, @@ -909,17 +1043,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => { const body: backend.CreateUserGroupRequestBody = await request.postDataJSON() + called('createUserGroup', body) const userGroup = addUserGroup(body.name) return userGroup }) await put( remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*', async (route, request) => { - const userId = backend.UserId( - decodeURIComponent(request.url().match(/[/]users[/]([^?/]+)/)?.[1] ?? ''), - ) + const maybeId = request.url().match(/[/]users[/]([^?/]+)/)?.[1] + if (!maybeId) return + const userId = backend.UserId(decodeURIComponent(maybeId)) // The type of the body sent by this app is statically known. const body: backend.ChangeUserGroupRequestBody = await request.postDataJSON() + called('changeUserGroup', { userId, ...body }) const user = usersMap.get(userId) if (!user) { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) @@ -931,11 +1067,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { ) await put(remoteBackendPaths.UPDATE_CURRENT_USER_PATH + '*', async (_route, request) => { const body: backend.UpdateUserRequestBody = await request.postDataJSON() + called('updateCurrentUser', body) if (currentUser && body.username != null) { currentUser = { ...currentUser, name: body.username } } }) await get(remoteBackendPaths.USERS_ME_PATH + '*', (route) => { + called('usersMe', {}) if (currentUser == null) { return route.fulfill({ status: HTTP_STATUS_NOT_FOUND }) } else { @@ -944,6 +1082,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => { const body: backend.UpdateOrganizationRequestBody = await request.postDataJSON() + called('updateOrganization', body) if (body.name === '') { await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST, @@ -959,6 +1098,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) await get(remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async (route) => { + called('getOrganization', {}) await route.fulfill({ json: currentOrganization, status: currentOrganization == null ? 404 : 200, @@ -966,10 +1106,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => { const body: backend.CreateTagRequestBody = route.request().postDataJSON() + called('createTag', body) return addLabel(body.value, body.color) }) await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => { const body: backend.CreateProjectRequestBody = request.postDataJSON() + called('createProject', body) const title = body.projectName const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`) const parentId = @@ -1007,6 +1149,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => { const body: backend.CreateDirectoryRequestBody = request.postDataJSON() + called('createDirectory', body) const title = body.title const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) const parentId = body.parentId ?? defaultDirectoryId @@ -1033,7 +1176,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return json }) - await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route) => { + await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('getProjectContent', { projectId }) const content = readFileSync(join(__dirname, './mock/enso-demo.main'), 'utf8') return route.fulfill({ @@ -1042,7 +1189,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) }) - await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route) => { + await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + if (!maybeId) return + const projectId = backend.ProjectId(maybeId) + called('getProjectAsset', { projectId }) return route.fulfill({ // This is a mock SVG image. Just a square with a black background. body: '/mock/svg.svg', @@ -1116,6 +1267,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // deletePermission, addUserGroupToUser, removeUserGroupFromUser, + trackCalls, } as const if (setupAPI) { diff --git a/app/gui/integration-test/dashboard/renameAsset.spec.ts b/app/gui/integration-test/dashboard/renameAsset.spec.ts new file mode 100644 index 000000000000..05e3e0f2164d --- /dev/null +++ b/app/gui/integration-test/dashboard/renameAsset.spec.ts @@ -0,0 +1,69 @@ +/** @file Test copying, moving, cutting and pasting. */ +import * as test from '@playwright/test' + +import * as actions from './actions' + +/** The name of the uploaded file. */ +const FILE_NAME = 'foo.txt' +/** The contents of the uploaded file. */ +const FILE_CONTENTS = 'hello world' +/** The name of the created secret. */ +const SECRET_NAME = 'a secret name' +/** The value of the created secret. */ +const SECRET_VALUE = 'a secret value' +const NEW_NAME = 'some new name' + +test.test('rename folder', ({ page }) => + actions + .mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory('a directory') + }, + }) + .createFolder() + .driveTable.withRows(async (rows, _, { api }) => { + await test.expect(rows).toHaveCount(1) + const row = rows.nth(0) + await test.expect(row).toBeVisible() + await test.expect(row).toHaveText(/^a directory/) + await actions.locateAssetRowName(row).click() + await actions.locateAssetRowName(row).click() + const calls = api.trackCalls() + await actions.locateAssetRowName(row).fill(NEW_NAME) + await actions.locateEditingTick(row).click() + await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + test.expect(calls.updateDirectory).toBeGreaterThan(0) + }), +) + +test.test('create project', ({ page }) => + actions + .mockAllAndLogin({ page }) + .newEmptyProject() + .do((thePage) => test.expect(actions.locateEditor(thePage)).toBeAttached()) + .goToPage.drive() + .driveTable.withRows((rows) => test.expect(rows).toHaveCount(1)), +) + +test.test('upload file', ({ page }) => + actions + .mockAllAndLogin({ page }) + .uploadFile(FILE_NAME, FILE_CONTENTS) + .driveTable.withRows(async (rows) => { + await test.expect(rows).toHaveCount(1) + await test.expect(rows.nth(0)).toBeVisible() + await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) + }), +) + +test.test('create secret', ({ page }) => + actions + .mockAllAndLogin({ page }) + .createSecret(SECRET_NAME, SECRET_VALUE) + .driveTable.withRows(async (rows) => { + await test.expect(rows).toHaveCount(1) + await test.expect(rows.nth(0)).toBeVisible() + await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) + }), +) From 3a229905004638711bfab0dc6768f4b048036707 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 2 Dec 2024 16:42:46 +1000 Subject: [PATCH 03/27] Adjust tests --- .../dashboard/actions/index.ts | 83 +++++++++++++++---- app/gui/integration-test/dashboard/api.ts | 51 ------------ 2 files changed, 65 insertions(+), 69 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index af224b98d08e..bc14130359d1 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -4,6 +4,7 @@ import * as test from '@playwright/test' import { TEXTS } from 'enso-common/src/text' import * as apiModule from '../api' +import LATEST_GITHUB_RELEASES from '../latestGithubReleases.json' with { type: 'json' } import DrivePageActions from './DrivePageActions' import LoginPageActions from './LoginPageActions' @@ -773,9 +774,7 @@ export const mockApi = apiModule.mockApi /** Set up all mocks, without logging in. */ export function mockAll({ page, setupAPI }: MockParams) { let api!: apiModule.MockApi - const actions = new LoginPageActions(page, { api }) - - actions.step('Execute all mocks', async () => { + return new LoginPageActions(page, { api }).step('Execute all mocks', async () => { await Promise.all([ mockApi({ page, setupAPI }).then((theApi) => { api = theApi @@ -787,24 +786,18 @@ export function mockAll({ page, setupAPI }: MockParams) { await page.goto('/') }) - - return actions } /** Set up all mocks, and log in with dummy credentials. */ export function mockAllAndLogin({ page, setupAPI }: MockParams) { - const actions = mockAll({ page, setupAPI }).into(DrivePageActions) - - actions.step('Login', async () => { - await login({ page, setupAPI }) - }) - - return actions + return mockAll({ page, setupAPI }) + .into(DrivePageActions) + .step('Login', async () => { + await login({ page, setupAPI }) + }) } -/** - * Mock all animations. - */ +/** Mock all animations. */ export async function mockAllAnimations({ page }: MockParams) { await page.addInitScript({ content: ` @@ -816,14 +809,24 @@ export async function mockAllAnimations({ page }: MockParams) { }) } -/** - * Mock unneeded URLs. - */ +/** Mock unneeded URLs. */ export async function mockUnneededUrls({ page }: MockParams) { const EULA_JSON = JSON.stringify(apiModule.EULA_JSON) const PRIVACY_JSON = JSON.stringify(apiModule.PRIVACY_JSON) return Promise.all([ + page.route('https://cdn.enso.org/**', async (route) => { + await route.fulfill() + }), + + page.route('https://www.google-analytics.com/**', async (route) => { + await route.fulfill() + }), + + page.route('https://www.googletagmanager.com/gtag/js*', async (route) => { + await route.fulfill({ contentType: 'text/javascript', body: 'export {};' }) + }), + page.route('https://*.ingest.sentry.io/api/*/envelope/*', async (route) => { await route.fulfill() }), @@ -843,6 +846,50 @@ export async function mockUnneededUrls({ page }: MockParams) { page.route('https://fonts.googleapis.com/css2*', async (route) => { await route.fulfill({ contentType: 'text/css', body: '' }) }), + + ...(process.env.MOCK_ALL_URLS === 'true' ? + [] + : [ + page.route('https://api.github.com/repos/enso-org/enso/releases/latest', async (route) => { + await route.fulfill({ json: LATEST_GITHUB_RELEASES }) + }), + + page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => { + await route.fulfill({ + status: 302, + headers: { location: 'https://objects.githubusercontent.com/foo/bar' }, + }) + }), + + page.route('https://objects.githubusercontent.com/**', async (route) => { + await route.fulfill({ + status: 200, + headers: { + 'content-type': 'application/octet-stream', + 'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT', + etag: '"0x8DCAC053D058EA5"', + server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000', + 'x-ms-version': '2020-10-02', + 'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT', + 'x-ms-lease-status': 'unlocked', + 'x-ms-lease-state': 'available', + 'x-ms-blob-type': 'BlockBlob', + 'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage', + 'x-ms-server-encrypted': 'true', + via: '1.1 varnish, 1.1 varnish', + 'accept-ranges': 'bytes', + age: '1217', + date: 'Mon, 29 Jul 2024 09:40:09 GMT', + 'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE', + 'x-cache': 'HIT, HIT', + 'x-cache-hits': '48, 0', + 'x-timer': 'S1722246008.269342,VS0,VE895', + 'content-length': '1030383958', + }, + }) + }), + ]), ]) } diff --git a/app/gui/integration-test/dashboard/api.ts b/app/gui/integration-test/dashboard/api.ts index e2566b0d0b98..b0f851036b06 100644 --- a/app/gui/integration-test/dashboard/api.ts +++ b/app/gui/integration-test/dashboard/api.ts @@ -15,7 +15,6 @@ import * as actions from './actions' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -518,56 +517,6 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const patch = method('PATCH') const delete_ = method('DELETE') - await page.route('https://cdn.enso.org/**', (route) => route.fulfill()) - await page.route('https://www.google-analytics.com/**', (route) => route.fulfill()) - await page.route('https://www.googletagmanager.com/gtag/js*', (route) => - route.fulfill({ contentType: 'text/javascript', body: 'export {};' }), - ) - - if (process.env.MOCK_ALL_URLS === 'true') { - await page.route( - 'https://api.github.com/repos/enso-org/enso/releases/latest', - async (route) => { - await route.fulfill({ json: LATEST_GITHUB_RELEASES }) - }, - ) - await page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => { - await route.fulfill({ - status: 302, - headers: { location: 'https://objects.githubusercontent.com/foo/bar' }, - }) - }) - - await page.route('https://objects.githubusercontent.com/**', async (route) => { - await route.fulfill({ - status: 200, - headers: { - 'content-type': 'application/octet-stream', - 'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT', - etag: '"0x8DCAC053D058EA5"', - server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', - 'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000', - 'x-ms-version': '2020-10-02', - 'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT', - 'x-ms-lease-status': 'unlocked', - 'x-ms-lease-state': 'available', - 'x-ms-blob-type': 'BlockBlob', - 'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage', - 'x-ms-server-encrypted': 'true', - via: '1.1 varnish, 1.1 varnish', - 'accept-ranges': 'bytes', - age: '1217', - date: 'Mon, 29 Jul 2024 09:40:09 GMT', - 'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE', - 'x-cache': 'HIT, HIT', - 'x-cache-hits': '48, 0', - 'x-timer': 'S1722246008.269342,VS0,VE895', - 'content-length': '1030383958', - }, - }) - }) - } - await page.route(BASE_URL + '**', (_route, request) => { throw new Error( `Missing route handler for '${request.method()} ${request.url().replace(BASE_URL, '')}'.`, From 7f9e042f511c0201490598e9223ac1245e9d2aae Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 2 Dec 2024 16:45:37 +1000 Subject: [PATCH 04/27] Fix type errors --- .../actions/NewDataLinkModalActions.ts | 4 ++-- .../dashboard/actions/userMenuActions.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts index 1996da448dcf..91991411f4c3 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -14,10 +14,10 @@ function locateNewDataLinkModal(page: test.Page) { /** Actions for a "new Data Link" modal. */ export default class NewDataLinkModalActions extends BaseActions { /** Cancel creating the new Data Link (don't submit the form). */ - cancel() { + cancel(): DrivePageActions { return this.step('Cancel out of "new data link" modal', async () => { await this.press('Escape') - }).into(DrivePageActions) + }).into(DrivePageActions) } /** Interact with the "name" input - for example, to set the name using `.fill("")`. */ diff --git a/app/gui/integration-test/dashboard/actions/userMenuActions.ts b/app/gui/integration-test/dashboard/actions/userMenuActions.ts index 3c7f429c260c..9db4c4b297c9 100644 --- a/app/gui/integration-test/dashboard/actions/userMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/userMenuActions.ts @@ -7,17 +7,17 @@ import LoginPageActions from './LoginPageActions' import SettingsPageActions from './SettingsPageActions' /** Actions for the user menu. */ -export interface UserMenuActions { +export interface UserMenuActions, Context> { readonly downloadApp: (callback: (download: test.Download) => Promise | void) => T - readonly settings: () => SettingsPageActions - readonly logout: () => LoginPageActions - readonly goToLoginPage: () => LoginPageActions + readonly settings: () => SettingsPageActions + readonly logout: () => LoginPageActions + readonly goToLoginPage: () => LoginPageActions } /** Generate actions for the user menu. */ -export function userMenuActions( - step: (name: string, callback: baseActions.PageCallback) => T, -): UserMenuActions { +export function userMenuActions, Context>( + step: (name: string, callback: baseActions.PageCallback) => T, +): UserMenuActions { return { downloadApp: (callback: (download: test.Download) => Promise | void) => step('Download app (user menu)', async (page) => { @@ -28,14 +28,14 @@ export function userMenuActions( settings: () => step('Go to Settings (user menu)', async (page) => { await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }).into(SettingsPageActions), + }).into(SettingsPageActions), logout: () => step('Logout (user menu)', (page) => page.getByRole('button', { name: 'Logout' }).getByText('Logout').click(), - ).into(LoginPageActions), + ).into(LoginPageActions), goToLoginPage: () => step('Login (user menu)', (page) => page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click(), - ).into(LoginPageActions), + ).into(LoginPageActions), } } From 0072570c050d4ce56a89a2936c74f49b271f8a73 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 2 Dec 2024 16:56:28 +1000 Subject: [PATCH 05/27] Use named imports in Dashboard integration tests --- .../dashboard/actions/BaseActions.ts | 32 +- .../dashboard/actions/DrivePageActions.ts | 48 +-- .../dashboard/actions/EditorPageActions.ts | 6 +- .../actions/ForgotPasswordPageActions.ts | 4 +- .../dashboard/actions/LoginPageActions.ts | 8 +- .../actions/NewDataLinkModalActions.ts | 9 +- .../dashboard/actions/PageActions.ts | 8 +- .../dashboard/actions/RegisterPageActions.ts | 8 +- .../dashboard/actions/SettingsPageActions.ts | 10 +- .../dashboard/actions/StartModalActions.ts | 5 +- .../dashboard/actions/contextMenuActions.ts | 4 +- .../dashboard/actions/goToPageActions.ts | 4 +- .../dashboard/actions/index.ts | 280 +++++++++--------- .../dashboard/actions/userMenuActions.ts | 10 +- 14 files changed, 215 insertions(+), 221 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 05a956ef31db..5aa5ce49c2d2 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -1,18 +1,18 @@ /** @file The base class from which all `Actions` classes are derived. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import type * as inputBindings from '#/utilities/inputBindings' +import type { AutocompleteKeybind } from '#/utilities/inputBindings' import { modModifier } from '.' -/** A callback that performs actions on a {@link test.Page}. */ +/** A callback that performs actions on a {@link Page}. */ export interface PageCallback { - (input: test.Page, context: Context): Promise | void + (input: Page, context: Context): Promise | void } -/** A callback that performs actions on a {@link test.Locator}. */ +/** A callback that performs actions on a {@link Locator}. */ export interface LocatorCallback { - (input: test.Locator): Promise | void + (input: Locator): Promise | void } /** @@ -25,7 +25,7 @@ export interface LocatorCallback { export default class BaseActions implements Promise { /** Create a {@link BaseActions}. */ constructor( - protected readonly page: test.Page, + protected readonly page: Page, protected readonly context: Context, private readonly promise = Promise.resolve(), ) {} @@ -42,11 +42,11 @@ export default class BaseActions implements Promise { * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` * on all other platforms. */ - static press(page: test.Page, keyOrShortcut: string): Promise { - return test.test.step(`Press '${keyOrShortcut}'`, async () => { + static press(page: Page, keyOrShortcut: string): Promise { + return test.step(`Press '${keyOrShortcut}'`, async () => { if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { let userAgent = '' - await test.test.step('Detect browser OS', async () => { + await test.step('Detect browser OS', async () => { userAgent = await page.evaluate(() => navigator.userAgent) }) const isMacOS = /\bMac OS\b/i.test(userAgent) @@ -89,7 +89,7 @@ export default class BaseActions implements Promise { /** Return a {@link BaseActions} with the same {@link Promise} but a different type. */ into< T extends new ( - page: test.Page, + page: Page, context: Context, promise: Promise, ...args: Args @@ -115,21 +115,21 @@ export default class BaseActions implements Promise { /** Perform an action on the current page. */ step(name: string, callback: PageCallback) { - return this.do(() => test.test.step(name, () => callback(this.page, this.context))) + return this.do(() => test.step(name, () => callback(this.page, this.context))) } /** * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` * on all other platforms. */ - press(keyOrShortcut: inputBindings.AutocompleteKeybind) { + press(keyOrShortcut: AutocompleteKeybind) { return this.do((page) => BaseActions.press(page, keyOrShortcut)) } /** Perform actions until a predicate passes. */ retry( callback: (actions: this) => this, - predicate: (page: test.Page) => Promise, + predicate: (page: Page) => Promise, options: { retries?: number; delay?: number } = {}, ) { const { retries = 3, delay = 1_000 } = options @@ -165,11 +165,11 @@ export default class BaseActions implements Promise { return this } else if (expected != null) { return this.step(`Expect ${description} error to be '${expected}'`, async (page) => { - await test.expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected) + await expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected) }) } else { return this.step(`Expect no ${description} error`, async (page) => { - await test.expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible() + await expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible() }) } } diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index aa35db0b0669..5545a26871ec 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -1,5 +1,5 @@ /** @file Actions for the "drive" page. */ -import * as test from 'playwright/test' +import { expect, type Locator, type Page } from 'playwright/test' import { locateAssetPanel, @@ -13,10 +13,10 @@ import { locateSecretValueInput, TEXT, } from '.' -import type * as baseActions from './BaseActions' -import * as contextMenuActions from './contextMenuActions' +import type { LocatorCallback } from './BaseActions' +import { contextMenuActions } from './contextMenuActions' import EditorPageActions from './EditorPageActions' -import * as goToPageActions from './goToPageActions' +import { goToPageActions, type GoToPageActions } from './goToPageActions' import NewDataLinkModalActions from './NewDataLinkModalActions' import PageActions from './PageActions' import StartModalActions from './StartModalActions' @@ -24,20 +24,20 @@ import StartModalActions from './StartModalActions' const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } /** Find all assets table rows (if any). */ -function locateAssetRows(page: test.Page) { +function locateAssetRows(page: Page) { return locateAssetsTable(page).getByTestId('asset-row') } /** Actions for the "drive" page. */ export default class DrivePageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit, 'drive'> { - return goToPageActions.goToPageActions(this.step.bind(this)) + get goToPage(): Omit, 'drive'> { + return goToPageActions(this.step.bind(this)) } /** Actions related to context menus. */ get contextMenu() { - return contextMenuActions.contextMenuActions(this.step.bind(this)) + return contextMenuActions(this.step.bind(this)) } /** Switch to a different category. */ @@ -127,8 +127,8 @@ export default class DrivePageActions extends PageActions { /** Interact with the set of all rows in the Drive table. */ withRows( callback: ( - assetRows: test.Locator, - nonAssetRows: test.Locator, + assetRows: Locator, + nonAssetRows: Locator, context: Context, ) => Promise | void, ) { @@ -147,7 +147,7 @@ export default class DrivePageActions extends PageActions { }) }, /** Drag a row onto another row. */ - dragRow(from: number, to: test.Locator, force?: boolean) { + dragRow(from: number, to: Locator, force?: boolean) { return self.step(`Drag drive table row #${from} to custom locator`, (page) => locateAssetRows(page) .nth(from) @@ -163,10 +163,10 @@ export default class DrivePageActions extends PageActions { */ expectPlaceholderRow() { return self.step('Expect placeholder row', async (page) => { - await test.expect(locateAssetRows(page)).toHaveCount(0) + await expect(locateAssetRows(page)).toHaveCount(0) const nonAssetRows = locateNonAssetRows(page) - await test.expect(nonAssetRows).toHaveCount(1) - await test.expect(nonAssetRows).toHaveText(/This folder is empty/) + await expect(nonAssetRows).toHaveCount(1) + await expect(nonAssetRows).toHaveText(/This folder is empty/) }) }, /** @@ -175,10 +175,10 @@ export default class DrivePageActions extends PageActions { */ expectTrashPlaceholderRow() { return self.step('Expect trash placeholder row', async (page) => { - await test.expect(locateAssetRows(page)).toHaveCount(0) + await expect(locateAssetRows(page)).toHaveCount(0) const nonAssetRows = locateNonAssetRows(page) - await test.expect(nonAssetRows).toHaveCount(1) - await test.expect(nonAssetRows).toHaveText(/Your trash is empty/) + await expect(nonAssetRows).toHaveCount(1) + await expect(nonAssetRows).toHaveText(/Your trash is empty/) }) }, /** Toggle a column's visibility. */ @@ -240,7 +240,7 @@ export default class DrivePageActions extends PageActions { } /** Interact with the drive view (the main container of this page). */ - withDriveView(callback: baseActions.LocatorCallback) { + withDriveView(callback: LocatorCallback) { return this.step('Interact with drive view', (page) => callback(locateDriveView(page))) } @@ -248,7 +248,7 @@ export default class DrivePageActions extends PageActions { createFolder() { return this.step('Create folder', async (page) => { await page.getByRole('button', { name: TEXT.newFolder, exact: true }).click() - await test.expect(page.locator('input:focus')).toBeVisible() + await expect(page.locator('input:focus')).toBeVisible() await page.keyboard.press('Escape') }) } @@ -310,7 +310,7 @@ export default class DrivePageActions extends PageActions { /** * Check if the Asset Panel is shown. */ - async isAssetPanelShown(page: test.Page) { + async isAssetPanelShown(page: Page) { return await page .getByTestId('asset-panel') .isVisible({ timeout: 0 }) @@ -323,7 +323,7 @@ export default class DrivePageActions extends PageActions { /** * Wait for the Asset Panel to be shown and visually stable */ - async waitForAssetPanelShown(page: test.Page) { + async waitForAssetPanelShown(page: Page) { await page.getByTestId('asset-panel').waitFor({ state: 'visible' }) } @@ -344,14 +344,14 @@ export default class DrivePageActions extends PageActions { } /** Interact with the container element of the assets table. */ - withAssetsTable(callback: baseActions.LocatorCallback) { + withAssetsTable(callback: LocatorCallback) { return this.step('Interact with drive table', async (page) => { await callback(locateAssetsTable(page)) }) } /** Interact with the Asset Panel. */ - withAssetPanel(callback: baseActions.LocatorCallback) { + withAssetPanel(callback: LocatorCallback) { return this.step('Interact with asset panel', async (page) => { await callback(locateAssetPanel(page)) }) @@ -365,7 +365,7 @@ export default class DrivePageActions extends PageActions { } /** Interact with the context menus (the context menus MUST be visible). */ - withContextMenus(callback: baseActions.LocatorCallback) { + withContextMenus(callback: LocatorCallback) { return this.step('Interact with context menus', async (page) => { await callback(locateContextMenus(page)) }) diff --git a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts index 1836df4587e2..3bba639a5819 100644 --- a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts @@ -1,12 +1,12 @@ /** @file Actions for the "editor" page. */ -import * as goToPageActions from './goToPageActions' +import { goToPageActions, type GoToPageActions } from './goToPageActions' import PageActions from './PageActions' /** Actions for the "editor" page. */ export default class EditorPageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit, 'editor'> { - return goToPageActions.goToPageActions(this.step.bind(this)) + get goToPage(): Omit, 'editor'> { + return goToPageActions(this.step.bind(this)) } /** Waits for the editor to load. */ waitForEditorToLoad(): EditorPageActions { diff --git a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts index 0cf0e58243e9..293a93c4a060 100644 --- a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts @@ -1,5 +1,5 @@ /** @file Available actions for the login page. */ -import * as test from '@playwright/test' +import { expect } from '@playwright/test' import { TEXT, VALID_EMAIL } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' @@ -45,6 +45,6 @@ export default class ForgotPasswordPageActions extends BaseActions extends BaseActions { return next } else if (formError != null) { return next.step(`Expect form error to be '${formError}'`, async (page) => { - await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError) + await expect(page.getByTestId('form-submit-error')).toHaveText(formError) }) } else { return next.step('Expect no form error', async (page) => { - await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible() + await expect(page.getByTestId('form-submit-error')).not.toBeVisible() }) } } @@ -93,6 +93,6 @@ export default class LoginPageActions extends BaseActions { .getByRole('button', { name: TEXT.login, exact: true }) .getByText(TEXT.login) .click() - await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() } } diff --git a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts index 91991411f4c3..6d2aed9a02f5 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -1,13 +1,12 @@ /** @file Actions for a "new Data Link" modal. */ -import type * as test from 'playwright/test' +import type { Page } from 'playwright/test' import { TEXT } from '.' -import type * as baseActions from './BaseActions' -import BaseActions from './BaseActions' +import BaseActions, { type LocatorCallback } from './BaseActions' import DrivePageActions from './DrivePageActions' /** Locate the "new data link" modal. */ -function locateNewDataLinkModal(page: test.Page) { +function locateNewDataLinkModal(page: Page) { return page.getByRole('dialog').filter({ has: page.getByText('Create Datalink') }) } @@ -21,7 +20,7 @@ export default class NewDataLinkModalActions extends BaseActions { const locator = locateNewDataLinkModal(page).getByPlaceholder(TEXT.datalinkNamePlaceholder) await callback(locator) diff --git a/app/gui/integration-test/dashboard/actions/PageActions.ts b/app/gui/integration-test/dashboard/actions/PageActions.ts index 64758cd7d447..a7e55a31f440 100644 --- a/app/gui/integration-test/dashboard/actions/PageActions.ts +++ b/app/gui/integration-test/dashboard/actions/PageActions.ts @@ -1,17 +1,17 @@ /** @file Actions common to all pages. */ import BaseActions from './BaseActions' -import * as openUserMenuAction from './openUserMenuAction' -import * as userMenuActions from './userMenuActions' +import { openUserMenuAction } from './openUserMenuAction' +import { userMenuActions } from './userMenuActions' /** Actions common to all pages. */ export default class PageActions extends BaseActions { /** Actions related to the User Menu. */ get userMenu() { - return userMenuActions.userMenuActions(this.step.bind(this)) + return userMenuActions(this.step.bind(this)) } /** Open the User Menu. */ openUserMenu() { - return openUserMenuAction.openUserMenuAction(this.step.bind(this)) + return openUserMenuAction(this.step.bind(this)) } } diff --git a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts index be79cbbd5aed..2945305f799c 100644 --- a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts @@ -1,5 +1,5 @@ /** @file Available actions for the login page. */ -import * as test from '@playwright/test' +import { expect } from '@playwright/test' import { TEXT, VALID_EMAIL, VALID_PASSWORD } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' @@ -51,11 +51,11 @@ export default class RegisterPageActions extends BaseActions { return next } else if (formError != null) { return next.step(`Expect form error to be '${formError}'`, async (page) => { - await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError) + await expect(page.getByTestId('form-submit-error')).toHaveText(formError) }) } else { return next.step('Expect no form error', async (page) => { - await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible() + await expect(page.getByTestId('form-submit-error')).not.toBeVisible() }) } } @@ -91,6 +91,6 @@ export default class RegisterPageActions extends BaseActions { .getByRole('button', { name: TEXT.register, exact: true }) .getByText(TEXT.register) .click() - await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() } } diff --git a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts index 24f1936b9651..57a5f6333d04 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts @@ -1,12 +1,12 @@ -/** @file Actions for the "settings" page. */ -import * as goToPageActions from './goToPageActions' +/** @file Actions for the "user" tab of the "settings" page. */ +import { goToPageActions, type GoToPageActions } from './goToPageActions' import PageActions from './PageActions' // TODO: split settings page actions into different classes for each settings tab. -/** Actions for the "settings" page. */ +/** Actions for the "user" tab of the "settings" page. */ export default class SettingsPageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit, 'drive'> { - return goToPageActions.goToPageActions(this.step.bind(this)) + get goToPage(): Omit, 'settings'> { + return goToPageActions(this.step.bind(this)) } } diff --git a/app/gui/integration-test/dashboard/actions/StartModalActions.ts b/app/gui/integration-test/dashboard/actions/StartModalActions.ts index dcf3b6d6effb..16d7f0eb1bdf 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -1,5 +1,5 @@ /** @file Actions for the "home" page. */ -import * as actions from '.' +import { locateSamples } from '.' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' @@ -16,8 +16,7 @@ export default class StartModalActions extends BaseActions { /** Create a project from the template at the given index. */ createProjectFromTemplate(index: number) { return this.step(`Create project from template #${index}`, (page) => - actions - .locateSamples(page) + locateSamples(page) .nth(index + 1) .click(), ).into(EditorPageActions) diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index b81c3a47b897..b8c1ef7c25cf 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -1,7 +1,7 @@ /** @file Actions for the context menu. */ import { TEXT } from '.' -import type * as baseActions from './BaseActions' import type BaseActions from './BaseActions' +import type { PageCallback } from './BaseActions' import EditorPageActions from './EditorPageActions' /** Actions for the context menu. */ @@ -32,7 +32,7 @@ export interface ContextMenuActions, Context> { /** Generate actions for the context menu. */ export function contextMenuActions, Context>( - step: (name: string, callback: baseActions.PageCallback) => T, + step: (name: string, callback: PageCallback) => T, ): ContextMenuActions { return { open: () => diff --git a/app/gui/integration-test/dashboard/actions/goToPageActions.ts b/app/gui/integration-test/dashboard/actions/goToPageActions.ts index 9940f02d5437..2101676935f7 100644 --- a/app/gui/integration-test/dashboard/actions/goToPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/goToPageActions.ts @@ -1,5 +1,5 @@ /** @file Actions for going to a different page. */ -import type * as baseActions from './BaseActions' +import type { PageCallback } from './BaseActions' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' @@ -14,7 +14,7 @@ export interface GoToPageActions { /** Generate actions for going to a different page. */ export function goToPageActions( - step: (name: string, callback: baseActions.PageCallback) => BaseActions, + step: (name: string, callback: PageCallback) => BaseActions, ): GoToPageActions { return { drive: () => diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index bc14130359d1..8acbd458c49c 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -1,13 +1,15 @@ /** @file Various actions, locators, and constants used in end-to-end tests. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { TEXTS } from 'enso-common/src/text' -import * as apiModule from '../api' +import { EULA_JSON, PRIVACY_JSON, mockApi, type MockApi, type SetupAPI } from '../api' import LATEST_GITHUB_RELEASES from '../latestGithubReleases.json' with { type: 'json' } import DrivePageActions from './DrivePageActions' import LoginPageActions from './LoginPageActions' +export { mockApi } from '../api' + /** An example password that does not meet validation requirements. */ export const INVALID_PASSWORD = 'password' /** An example password that meets validation requirements. */ @@ -19,27 +21,27 @@ export const TEXT = TEXTS.english // === Input locators === /** Find an email input (if any) on the current page. */ -export function locateEmailInput(page: test.Locator | test.Page) { +export function locateEmailInput(page: Locator | Page) { return page.getByPlaceholder('Enter your email') } /** Find a password input (if any) on the current page. */ -export function locatePasswordInput(page: test.Locator | test.Page) { +export function locatePasswordInput(page: Locator | 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) { +export function locateConfirmPasswordInput(page: Locator | Page) { return page.getByPlaceholder('Confirm your password') } /** Find a "name" input for a "new label" modal (if any) on the current page. */ -export function locateNewLabelModalNameInput(page: test.Page) { +export function locateNewLabelModalNameInput(page: Page) { return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox')) } /** Find all color radio button inputs for a "new label" modal (if any) on the current page. */ -export function locateNewLabelModalColorButtons(page: test.Page) { +export function locateNewLabelModalColorButtons(page: Page) { return ( locateNewLabelModal(page) .filter({ has: page.getByText('Color') }) @@ -49,61 +51,61 @@ export function locateNewLabelModalColorButtons(page: test.Page) { } /** Find a "name" input for an "upsert secret" modal (if any) on the current page. */ -export function locateSecretNameInput(page: test.Page) { +export function locateSecretNameInput(page: Page) { return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder) } /** Find a "value" input for an "upsert secret" modal (if any) on the current page. */ -export function locateSecretValueInput(page: test.Page) { +export function locateSecretValueInput(page: Page) { return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder) } /** Find a search bar input (if any) on the current page. */ -export function locateSearchBarInput(page: test.Page) { +export function locateSearchBarInput(page: Page) { return locateSearchBar(page).getByPlaceholder(/(?:)/) } /** Find the name column of the given assets table row. */ -export function locateAssetRowName(locator: test.Locator) { +export function locateAssetRowName(locator: Locator) { return locator.getByTestId('asset-row-name') } // === Button locators === /** Find a "login" button (if any) on the current locator. */ -export function locateLoginButton(page: test.Locator | test.Page) { +export function locateLoginButton(page: Locator | Page) { return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') } /** Find a "register" button (if any) on the current locator. */ -export function locateRegisterButton(page: test.Locator | test.Page) { +export function locateRegisterButton(page: Locator | Page) { return page.getByRole('button', { name: 'Register' }).getByText('Register') } /** Find a "create" button (if any) on the current page. */ -export function locateCreateButton(page: test.Locator | test.Page) { +export function locateCreateButton(page: Locator | 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) { +export function locatePlayOrOpenProjectButton(page: Locator | Page) { return page.getByLabel('Open in editor') } /** Find a button to close the project (if any) on the current page. */ -export function locateStopProjectButton(page: test.Locator | test.Page) { +export function locateStopProjectButton(page: Locator | Page) { return page.getByLabel('Stop execution') } /** Close a modal. */ -export function closeModal(page: test.Page) { - return test.test.step('Close modal', async () => { +export function closeModal(page: Page) { + return test.step('Close modal', async () => { await page.getByLabel('Close').click() }) } /** Find all labels in the labels panel (if any) on the current page. */ -export function locateLabelsPanelLabels(page: test.Page, name?: string) { +export function locateLabelsPanelLabels(page: Page, name?: string) { return ( locateLabelsPanel(page) .getByRole('button') @@ -114,114 +116,114 @@ export function locateLabelsPanelLabels(page: test.Page, name?: string) { } /** Find a tick button (if any) on the current page. */ -export function locateEditingTick(page: test.Locator | test.Page) { +export function locateEditingTick(page: Locator | Page) { return page.getByLabel('Confirm Edit') } /** Find a cross button (if any) on the current page. */ -export function locateEditingCross(page: test.Locator | test.Page) { +export function locateEditingCross(page: Locator | Page) { return page.getByLabel('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) { +export function locateAssetLabels(page: Locator | Page) { return page.getByTestId('asset-label') } /** Find a toggle for the "Name" column (if any) on the current page. */ -export function locateNameColumnToggle(page: test.Locator | test.Page) { +export function locateNameColumnToggle(page: Locator | Page) { return page.getByLabel('Name') } /** Find a toggle for the "Modified" column (if any) on the current page. */ -export function locateModifiedColumnToggle(page: test.Locator | test.Page) { +export function locateModifiedColumnToggle(page: Locator | Page) { return page.getByLabel('Modified') } /** Find a toggle for the "Shared with" column (if any) on the current page. */ -export function locateSharedWithColumnToggle(page: test.Locator | test.Page) { +export function locateSharedWithColumnToggle(page: Locator | Page) { return page.getByLabel('Shared With') } /** Find a toggle for the "Labels" column (if any) on the current page. */ -export function locateLabelsColumnToggle(page: test.Locator | test.Page) { +export function locateLabelsColumnToggle(page: Locator | Page) { return page.getByLabel('Labels') } /** Find a toggle for the "Accessed by projects" column (if any) on the current page. */ -export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) { +export function locateAccessedByProjectsColumnToggle(page: Locator | Page) { return page.getByLabel('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) { +export function locateAccessedDataColumnToggle(page: Locator | Page) { return page.getByLabel('Accessed Data') } /** Find a toggle for the "Docs" column (if any) on the current page. */ -export function locateDocsColumnToggle(page: test.Locator | test.Page) { +export function locateDocsColumnToggle(page: Locator | Page) { return page.getByLabel('Docs') } /** Find a button for the "Recent" category (if any) on the current page. */ -export function locateRecentCategory(page: test.Locator | test.Page) { +export function locateRecentCategory(page: Locator | Page) { return page.getByLabel('Recent').locator('visible=true') } /** Find a button for the "Home" category (if any) on the current page. */ -export function locateHomeCategory(page: test.Locator | test.Page) { +export function locateHomeCategory(page: Locator | Page) { return page.getByLabel('Home').locator('visible=true') } /** Find a button for the "Trash" category (if any) on the current page. */ -export function locateTrashCategory(page: test.Locator | test.Page) { +export function locateTrashCategory(page: Locator | Page) { return page.getByLabel('Trash').locator('visible=true') } // === Other buttons === /** Find a "new label" button (if any) on the current page. */ -export function locateNewLabelButton(page: test.Locator | test.Page) { +export function locateNewLabelButton(page: Locator | 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) { +export function locateUpgradeButton(page: Locator | Page) { return page.getByRole('link', { name: 'Upgrade', exact: true }).getByText('Upgrade').first() } /** Find a not enabled stub view (if any) on the current page. */ -export function locateNotEnabledStub(page: test.Locator | test.Page) { +export function locateNotEnabledStub(page: Locator | Page) { return page.getByTestId('not-enabled-stub') } /** Find a "new folder" icon (if any) on the current page. */ -export function locateNewFolderIcon(page: test.Locator | test.Page) { +export function locateNewFolderIcon(page: Locator | Page) { return page.getByRole('button', { name: 'New Folder', exact: true }) } /** Find a "new secret" icon (if any) on the current page. */ -export function locateNewSecretIcon(page: test.Locator | test.Page) { +export function locateNewSecretIcon(page: Locator | Page) { return page.getByRole('button', { name: 'New Secret' }) } /** Find a "download files" icon (if any) on the current page. */ -export function locateDownloadFilesIcon(page: test.Locator | test.Page) { +export function locateDownloadFilesIcon(page: Locator | Page) { return page.getByRole('button', { name: 'Export' }) } /** Find a list of tags in the search bar (if any) on the current page. */ -export function locateSearchBarTags(page: test.Page) { +export function locateSearchBarTags(page: 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.Page) { +export function locateSearchBarLabels(page: 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.Page) { +export function locateSearchBarSuggestions(page: Page) { return locateSearchBar(page).getByTestId('asset-search-suggestion') } @@ -231,19 +233,19 @@ export function locateSearchBarSuggestions(page: test.Page) { // 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) { +export function locateSortAscendingIcon(page: Locator | 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) { +export function locateSortDescendingIcon(page: Locator | Page) { return page.getByAltText('Sort Descending') } // === Heading locators === /** Find a "name" column heading (if any) on the current page. */ -export function locateNameColumnHeading(page: test.Locator | test.Page) { +export function locateNameColumnHeading(page: Locator | Page) { return page .getByLabel('Sort by name') .or(page.getByLabel('Stop sorting by name')) @@ -251,7 +253,7 @@ export function locateNameColumnHeading(page: test.Locator | test.Page) { } /** Find a "modified" column heading (if any) on the current page. */ -export function locateModifiedColumnHeading(page: test.Locator | test.Page) { +export function locateModifiedColumnHeading(page: Locator | Page) { return page .getByLabel('Sort by modification date') .or(page.getByLabel('Stop sorting by modification date')) @@ -261,46 +263,46 @@ export function locateModifiedColumnHeading(page: test.Locator | test.Page) { // === Container locators === /** Find a drive view (if any) on the current page. */ -export function locateDriveView(page: test.Locator | test.Page) { +export function locateDriveView(page: Locator | 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) { +export function locateSamplesList(page: Locator | 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) { +export function locateSamples(page: Locator | Page) { // This has no identifying features. return locateSamplesList(page).getByRole('button') } /** Find an editor container (if any) on the current page. */ -export function locateEditor(page: test.Page) { +export function locateEditor(page: Page) { // Test ID of a placeholder editor component used during testing. return page.locator('.App') } /** Find an assets table (if any) on the current page. */ -export function locateAssetsTable(page: test.Page) { +export function locateAssetsTable(page: Page) { return locateDriveView(page).getByRole('table') } /** Find assets table rows (if any) on the current page. */ -export function locateAssetRows(page: test.Page) { +export function locateAssetRows(page: Page) { return locateAssetsTable(page).getByTestId('asset-row') } /** Find assets table placeholder rows (if any) on the current page. */ -export function locateNonAssetRows(page: test.Page) { +export function locateNonAssetRows(page: Page) { return locateAssetsTable(page).locator('tbody tr:not([data-testid="asset-row"])') } /** Find the name column of the given asset row. */ -export function locateAssetName(locator: test.Locator) { +export function locateAssetName(locator: Locator) { return locator.locator('> :nth-child(1)') } @@ -308,7 +310,7 @@ export function locateAssetName(locator: test.Locator) { * Find assets table rows that represent directories that can be expanded (if any) * on the current page. */ -export function locateExpandableDirectories(page: test.Page) { +export function locateExpandableDirectories(page: Page) { // The icon is hidden when not hovered so `getByLabel` will not work. return locateAssetRows(page).filter({ has: page.locator('[aria-label=Expand]') }) } @@ -317,66 +319,66 @@ export function locateExpandableDirectories(page: test.Page) { * Find assets table rows that represent directories that can be collapsed (if any) * on the current page. */ -export function locateCollapsibleDirectories(page: test.Page) { +export function locateCollapsibleDirectories(page: Page) { // The icon is hidden when not hovered so `getByLabel` will not work. return locateAssetRows(page).filter({ has: page.locator('[aria-label=Collapse]') }) } /** Find a "new label" modal (if any) on the current page. */ -export function locateNewLabelModal(page: test.Page) { +export function locateNewLabelModal(page: 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.Page) { +export function locateUpsertSecretModal(page: 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.Page) { +export function locateUserMenu(page: Page) { return page.getByLabel(TEXT.userMenuLabel).and(page.getByRole('button')).locator('visible=true') } /** Find a "set username" panel (if any) on the current page. */ -export function locateSetUsernamePanel(page: test.Page) { +export function locateSetUsernamePanel(page: 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.Page) { +export function locateContextMenus(page: 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.Page) { +export function locateLabelsPanel(page: 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.Page) { +export function locateLabelsList(page: 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.Page) { +export function locateAssetPanel(page: Page) { // This has no identifying features. return page.getByTestId('asset-panel').locator('visible=true') } /** Find a search bar (if any) on the current page. */ -export function locateSearchBar(page: test.Page) { +export function locateSearchBar(page: Page) { // This has no identifying features. return page.getByTestId('asset-search-bar') } /** Find an extra columns button panel (if any) on the current page. */ -export function locateExtraColumns(page: test.Page) { +export function locateExtraColumns(page: Page) { // This has no identifying features. return page.getByTestId('extra-columns') } @@ -386,7 +388,7 @@ export function locateExtraColumns(page: test.Page) { * This is the empty space below the assets table, if it doesn't take up the whole screen * vertically. */ -export function locateRootDirectoryDropzone(page: test.Page) { +export function locateRootDirectoryDropzone(page: Page) { // This has no identifying features. return page.getByTestId('root-directory-dropzone') } @@ -394,13 +396,13 @@ export function locateRootDirectoryDropzone(page: test.Page) { // === Content locators === /** Find an asset description in an asset panel (if any) on the current page. */ -export function locateAssetPanelDescription(page: test.Page) { +export function locateAssetPanelDescription(page: 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.Page) { +export function locateAssetPanelPermissions(page: Page) { // This has no identifying features. return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button') } @@ -409,13 +411,13 @@ export namespace settings { export namespace tab { export namespace organization { /** Find an "organization" tab button. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('button', { name: 'Organization' }).getByText('Organization') } } export namespace members { /** Find a "members" tab button. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('button', { name: 'Members', exact: true }).getByText('Members') } } @@ -423,87 +425,87 @@ export namespace settings { export namespace userAccount { /** Navigate so that the "user account" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "user account" settings section', async () => { + export async function go(page: Page) { + await test.step('Go to "user account" settings section', async () => { await locateUserMenu(page).click() await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() }) } /** Find a "user account" settings section. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('heading').and(page.getByText('User Account')).locator('..') } /** Find a "name" input in the "user account" settings section. */ - export function locateNameInput(page: test.Page) { + export function locateNameInput(page: Page) { return locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox') } } export namespace changePassword { /** Navigate so that the "change password" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "change password" settings section', async () => { + export async function go(page: Page) { + await test.step('Go to "change password" settings section', async () => { await locateUserMenu(page).click() await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() }) } /** Find a "change password" settings section. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('heading').and(page.getByText('Change Password')).locator('..') } /** Find a "current password" input in the "user account" settings section. */ - export function locateCurrentPasswordInput(page: test.Page) { + export function locateCurrentPasswordInput(page: Page) { return locate(page).getByRole('group', { name: 'Current password' }).getByRole('textbox') } /** Find a "new password" input in the "user account" settings section. */ - export function locateNewPasswordInput(page: test.Page) { + export function locateNewPasswordInput(page: Page) { return locate(page) .getByRole('group', { name: /^New password/, exact: true }) .getByRole('textbox') } /** Find a "confirm new password" input in the "user account" settings section. */ - export function locateConfirmNewPasswordInput(page: test.Page) { + export function locateConfirmNewPasswordInput(page: Page) { return locate(page) .getByRole('group', { name: /^Confirm new password/, exact: true }) .getByRole('textbox') } /** Find a "save" button. */ - export function locateSaveButton(page: test.Page) { + export function locateSaveButton(page: Page) { return locate(page).getByRole('button', { name: 'Save' }).getByText('Save') } } export namespace profilePicture { /** Navigate so that the "profile picture" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "profile picture" settings section', async () => { + export async function go(page: Page) { + await test.step('Go to "profile picture" settings section', async () => { await locateUserMenu(page).click() await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() }) } /** Find a "profile picture" settings section. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') } /** Find a "profile picture" input. */ - export function locateInput(page: test.Page) { + export function locateInput(page: Page) { return locate(page).locator('label') } } export namespace organization { /** Navigate so that the "organization" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "organization" settings section', async () => { + export async function go(page: Page) { + await test.step('Go to "organization" settings section', async () => { await locateUserMenu(page).click() await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() await settings.tab.organization.locate(page).click() @@ -511,35 +513,35 @@ export namespace settings { } /** Find an "organization" settings section. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('heading').and(page.getByText('Organization')).locator('..') } /** Find a "name" input in the "organization" settings section. */ - export function locateNameInput(page: test.Page) { + export function locateNameInput(page: Page) { return locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox') } /** Find an "email" input in the "organization" settings section. */ - export function locateEmailInput(page: test.Page) { + export function locateEmailInput(page: Page) { return locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox') } /** Find an "website" input in the "organization" settings section. */ - export function locateWebsiteInput(page: test.Page) { + export function locateWebsiteInput(page: Page) { return locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox') } /** Find an "location" input in the "organization" settings section. */ - export function locateLocationInput(page: test.Page) { + export function locateLocationInput(page: Page) { return locate(page).getByLabel(TEXT.organizationLocationSettingsInput).getByRole('textbox') } } export namespace organizationProfilePicture { /** Navigate so that the "organization profile picture" settings section is visible. */ - export async function go(page: test.Page) { - await test.test.step('Go to "organization profile picture" settings section', async () => { + export async function go(page: Page) { + await test.step('Go to "organization profile picture" settings section', async () => { await locateUserMenu(page).click() await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() await settings.tab.organization.locate(page).click() @@ -547,20 +549,20 @@ export namespace settings { } /** Find an "organization profile picture" settings section. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') } /** Find a "profile picture" input. */ - export function locateInput(page: test.Page) { + export function locateInput(page: Page) { return locate(page).locator('label') } } export namespace members { /** Navigate so that the "members" settings section is visible. */ - export async function go(page: test.Page, force = false) { - await test.test.step('Go to "members" settings section', async () => { + export async function go(page: Page, force = false) { + await test.step('Go to "members" settings section', async () => { await locateUserMenu(page).click() await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() await settings.tab.members.locate(page).click({ force }) @@ -568,12 +570,12 @@ export namespace settings { } /** Find a "members" settings section. */ - export function locate(page: test.Page) { + export function locate(page: Page) { return page.getByRole('heading').and(page.getByText('Members')).locator('..') } /** Find all rows representing members of the current organization. */ - export function locateMembersRows(page: test.Page) { + export function locateMembersRows(page: Page) { return locate(page).locator('tbody').getByRole('row') } } @@ -588,7 +590,7 @@ export namespace settings { * 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) { +export function getAssetRowLeftPx(locator: Locator) { return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) } @@ -597,9 +599,9 @@ export function getAssetRowLeftPx(locator: test.Locator) { // =================================== /** A test assertion to confirm that the element has the class `selected`. */ -export async function expectClassSelected(locator: test.Locator) { - await test.test.step('Expect `selected`', async () => { - await test.expect(locator).toHaveClass(/(?:^| )selected(?: |$)/) +export async function expectClassSelected(locator: Locator) { + await test.step('Expect `selected`', async () => { + await expect(locator).toHaveClass(/(?:^| )selected(?: |$)/) }) } @@ -608,24 +610,20 @@ export async function expectClassSelected(locator: test.Locator) { // ============================== /** A test assertion to confirm that the element is fully transparent. */ -export async function expectOpacity0(locator: test.Locator) { - await test.test.step('Expect `opacity: 0`', async () => { - await test - .expect(async () => { - test.expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).toBe('0') - }) - .toPass() +export async function expectOpacity0(locator: Locator) { + await test.step('Expect `opacity: 0`', async () => { + await expect(async () => { + expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).toBe('0') + }).toPass() }) } /** A test assertion to confirm that the element is not fully transparent. */ -export async function expectNotOpacity0(locator: test.Locator) { - await test.test.step('Expect not `opacity: 0`', async () => { - await test - .expect(async () => { - test.expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0') - }) - .toPass() +export async function expectNotOpacity0(locator: Locator) { + await test.step('Expect not `opacity: 0`', async () => { + await expect(async () => { + expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0') + }).toPass() }) } @@ -634,9 +632,9 @@ export async function expectNotOpacity0(locator: test.Locator) { // ========================== /** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ -export async function modModifier(page: test.Page) { +export async function modModifier(page: Page) { let userAgent = '' - await test.test.step('Detect browser OS', async () => { + await test.step('Detect browser OS', async () => { userAgent = await page.evaluate(() => navigator.userAgent) }) return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' @@ -646,11 +644,11 @@ export async function modModifier(page: test.Page) { * 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) { - await test.test.step(`Press '${keyOrShortcut}'`, async () => { +export async function press(page: Page, keyOrShortcut: string) { + await test.step(`Press '${keyOrShortcut}'`, async () => { if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { let userAgent = '' - await test.test.step('Detect browser OS', async () => { + await test.step('Detect browser OS', async () => { userAgent = await page.evaluate(() => navigator.userAgent) }) const isMacOS = /\bMac OS\b/i.test(userAgent) @@ -675,7 +673,7 @@ export async function login( password = VALID_PASSWORD, first = true, ) { - await test.test.step('Login', async () => { + await test.step('Login', async () => { const url = new URL(page.url()) if (url.pathname !== '/login') { @@ -686,20 +684,20 @@ export async function login( await locatePasswordInput(page).fill(password) await locateLoginButton(page).click() - await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() if (first) { await passAgreementsDialog({ page, setupAPI }) - await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() } }) } /** Reload. */ export async function reload({ page }: MockParams) { - await test.test.step('Reload', async () => { + await test.step('Reload', async () => { await page.reload() - await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + await expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() }) } @@ -709,7 +707,7 @@ export async function relog( email = 'email@example.com', password = VALID_PASSWORD, ) { - await test.test.step('Relog', async () => { + await test.step('Relog', async () => { await page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click() await page .getByRole('button', { name: TEXT.signOutShortcut }) @@ -724,14 +722,14 @@ const MOCK_DATE = Number(new Date('01/23/45 01:23:45')) /** Parameters for {@link mockDate}. */ interface MockParams { - readonly page: test.Page - readonly setupAPI?: apiModule.SetupAPI | undefined + readonly page: Page + readonly setupAPI?: SetupAPI | undefined } /** Replace `Date` with a version that returns a fixed time. */ async function mockDate({ page }: MockParams) { // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 - await test.test.step('Mock Date', async () => { + await test.step('Mock Date', async () => { await page.addInitScript(`{ Date = class extends Date { constructor(...args) { @@ -751,7 +749,7 @@ async function mockDate({ page }: MockParams) { /** Pass the Agreements dialog. */ export async function passAgreementsDialog({ page }: MockParams) { - await test.test.step('Accept Terms and Conditions', async () => { + await test.step('Accept Terms and Conditions', async () => { await page.waitForSelector('#agreements-modal') await page .getByRole('group', { name: TEXT.licenseAgreementCheckbox }) @@ -766,14 +764,12 @@ export async function passAgreementsDialog({ page }: MockParams) { } interface Context { - readonly api: apiModule.MockApi + readonly api: MockApi } -export const mockApi = apiModule.mockApi - /** Set up all mocks, without logging in. */ export function mockAll({ page, setupAPI }: MockParams) { - let api!: apiModule.MockApi + let api!: MockApi return new LoginPageActions(page, { api }).step('Execute all mocks', async () => { await Promise.all([ mockApi({ page, setupAPI }).then((theApi) => { @@ -811,8 +807,8 @@ export async function mockAllAnimations({ page }: MockParams) { /** Mock unneeded URLs. */ export async function mockUnneededUrls({ page }: MockParams) { - const EULA_JSON = JSON.stringify(apiModule.EULA_JSON) - const PRIVACY_JSON = JSON.stringify(apiModule.PRIVACY_JSON) + const eulaJsonBody = JSON.stringify(EULA_JSON) + const privacyJsonBody = JSON.stringify(PRIVACY_JSON) return Promise.all([ page.route('https://cdn.enso.org/**', async (route) => { @@ -836,11 +832,11 @@ export async function mockUnneededUrls({ page }: MockParams) { }), page.route('https://ensoanalytics.com/eula.json', async (route) => { - await route.fulfill({ contentType: 'text/json', body: EULA_JSON }) + await route.fulfill({ contentType: 'text/json', body: eulaJsonBody }) }), page.route('https://ensoanalytics.com/privacy.json', async (route) => { - await route.fulfill({ contentType: 'text/json', body: PRIVACY_JSON }) + await route.fulfill({ contentType: 'text/json', body: privacyJsonBody }) }), page.route('https://fonts.googleapis.com/css2*', async (route) => { @@ -898,7 +894,7 @@ export async function mockUnneededUrls({ page }: MockParams) { * @deprecated Prefer {@link mockAllAndLogin}. */ export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams) { - return await test.test.step('Execute all mocks and login', async () => { + return await test.step('Execute all mocks and login', async () => { const api = await mockApi({ page, setupAPI }) await mockDate({ page, setupAPI }) await page.goto('/') diff --git a/app/gui/integration-test/dashboard/actions/userMenuActions.ts b/app/gui/integration-test/dashboard/actions/userMenuActions.ts index 9db4c4b297c9..3e2b099ba223 100644 --- a/app/gui/integration-test/dashboard/actions/userMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/userMenuActions.ts @@ -1,14 +1,14 @@ /** @file Actions for the user menu. */ -import type * as test from 'playwright/test' +import type { Download } from 'playwright/test' -import type * as baseActions from './BaseActions' import type BaseActions from './BaseActions' +import type { PageCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' import SettingsPageActions from './SettingsPageActions' /** Actions for the user menu. */ export interface UserMenuActions, Context> { - readonly downloadApp: (callback: (download: test.Download) => Promise | void) => T + readonly downloadApp: (callback: (download: Download) => Promise | void) => T readonly settings: () => SettingsPageActions readonly logout: () => LoginPageActions readonly goToLoginPage: () => LoginPageActions @@ -16,10 +16,10 @@ export interface UserMenuActions, Context> { /** Generate actions for the user menu. */ export function userMenuActions, Context>( - step: (name: string, callback: baseActions.PageCallback) => T, + step: (name: string, callback: PageCallback) => T, ): UserMenuActions { return { - downloadApp: (callback: (download: test.Download) => Promise | void) => + downloadApp: (callback: (download: Download) => Promise | void) => step('Download app (user menu)', async (page) => { const downloadPromise = page.waitForEvent('download') await page.getByRole('button', { name: 'Download App' }).getByText('Download App').click() From 490602e17c24620d880cbb2b9959d71eb44210c1 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 2 Dec 2024 16:58:21 +1000 Subject: [PATCH 06/27] WIP: `goToSettingsTabActions` --- .../dashboard/actions/BaseActions.ts | 5 ++ .../actions/BaseSettingsTabActions.ts | 20 +++++ .../actions/SettingsAccountFormActions.ts | 31 +++++++ .../actions/SettingsAccountTabActions.ts | 20 +++++ .../actions/SettingsActivityLogTabActions.ts | 8 ++ .../SettingsBillingAndPlansTabActions.ts | 8 ++ .../SettingsChangePasswordFormActions.ts | 54 ++++++++++++ .../dashboard/actions/SettingsFormActions.ts | 34 +++++++ .../SettingsKeyboardShortcutsTabActions.ts | 8 ++ .../actions/SettingsLocalTabActions.ts | 8 ++ .../actions/SettingsMembersTabActions.ts | 8 ++ .../actions/SettingsOrganizationTabActions.ts | 8 ++ .../dashboard/actions/SettingsPageActions.ts | 20 ++--- .../actions/SettingsUserGroupsTabActions.ts | 8 ++ .../actions/gotoSettingsTabActions.ts | 88 +++++++++++++++++++ 15 files changed, 317 insertions(+), 11 deletions(-) create mode 100644 app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsFormActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 5aa5ce49c2d2..187cb848b236 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -15,6 +15,11 @@ export interface LocatorCallback { (input: Locator): Promise | void } +export interface BaseActionsClass { + // The return type should be `InstanceType`, but that results in a circular reference error. + new (page: Page, context: Context, promise: Promise, ...args: Args): any +} + /** * The base class from which all `Actions` classes are derived. * It contains method common to all `Actions` subclasses. diff --git a/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts new file mode 100644 index 000000000000..3fca365215b5 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts @@ -0,0 +1,20 @@ +/** @file Actions for the "user" tab of the "settings" page. */ +import { goToPageActions, type GoToPageActions } from './goToPageActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' +import PageActions from './PageActions' + +/** Actions common to all settings pages. */ +export default class BaseSettingsTabActions< + CurrentTab extends keyof GoToSettingsTabActions, + Context, +> extends PageActions { + /** Actions for navigating to another page. */ + get goToPage(): Omit, 'settings'> { + return goToPageActions(this.step.bind(this)) + } + + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, CurrentTab> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts new file mode 100644 index 000000000000..2511e6e4f6c4 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts @@ -0,0 +1,31 @@ +/** @file Actions for the "account" form in settings. */ +import { TEXT } from '.' +import type PageActions from './PageActions' +import SettingsAccountTabActions from './SettingsAccountTabActions' +import SettingsFormActions from './SettingsFormActions' + +/** Actions for the "account" form in settings. */ +export default class SettingsAccountFormActions extends SettingsFormActions< + Context, + typeof SettingsAccountTabActions +> { + /** Create a {@link SettingsAccountFormActions}. */ + constructor(...args: ConstructorParameters>) { + super( + SettingsAccountTabActions, + (page) => + page + .getByRole('heading') + .and(page.getByText(TEXT.userAccountSettingsSection)) + .locator('..'), + ...args, + ) + } + + /** Fill the "name" input of this form. */ + fillName(name: string) { + return this.step('', (page) => + this.locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox').fill(name), + ) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts new file mode 100644 index 000000000000..4b8be616d197 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts @@ -0,0 +1,20 @@ +/** @file Actions for the "account" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' +import SettingsAccountFormActions from './SettingsAccountFormActions' +import SettingsChangePasswordFormActions from './SettingsChangePasswordFormActions' + +/** Actions for the "account" tab of the "settings" page. */ +export default class SettingsAccountTabActions extends BaseSettingsTabActions< + 'account', + Context +> { + /** Manipulate the "account" form. */ + accountForm() { + return this.into(SettingsAccountFormActions) + } + + /** Manipulate the "change password" form. */ + changePasswordForm() { + return this.into(SettingsChangePasswordFormActions) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts new file mode 100644 index 000000000000..95061fa83446 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts @@ -0,0 +1,8 @@ +/** @file Actions for the "activity log" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' + +/** Actions for the "activity log" tab of the "settings" page. */ +export default class SettingsActivityLogShortcutsTabActions extends BaseSettingsTabActions< + 'activityLog', + Context +> {} diff --git a/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts new file mode 100644 index 000000000000..11d0f3d0877b --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts @@ -0,0 +1,8 @@ +/** @file Actions for the "billing and plans" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' + +/** Actions for the "billing and plans" tab of the "settings" page. */ +export default class SettingsBillingAndPlansTabActions extends BaseSettingsTabActions< + 'billingAndPlans', + Context +> {} diff --git a/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts new file mode 100644 index 000000000000..80a07fe60652 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts @@ -0,0 +1,54 @@ +/** @file Actions for the "change password" form in settings. */ +import { TEXT } from '.' +import type PageActions from './PageActions' +import SettingsAccountTabActions from './SettingsAccountTabActions' +import SettingsFormActions from './SettingsFormActions' + +/** Actions for the "change password" form in settings. */ +export default class SettingsChangePasswordFormActions extends SettingsFormActions< + Context, + typeof SettingsAccountTabActions +> { + /** Create a {@link SettingsChangePasswordFormActions}. */ + constructor(...args: ConstructorParameters>) { + super( + SettingsAccountTabActions, + (page) => + page + .getByRole('heading') + .and(page.getByText(TEXT.changePasswordSettingsSection)) + .locator('..'), + ...args, + ) + } + + /** Fill the "current password" input of this form. */ + fillCurrentPassword(name: string) { + return this.step('', (page) => + this.locate(page) + .getByLabel(TEXT.userCurrentPasswordSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "new password" input of this form. */ + fillNewPassword(name: string) { + return this.step('', (page) => + this.locate(page) + .getByLabel(TEXT.userNewPasswordSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "confirm new password" input of this form. */ + fillConfirmNewPassword(name: string) { + return this.step('', (page) => + this.locate(page) + .getByLabel(TEXT.userConfirmNewPasswordSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts new file mode 100644 index 000000000000..1d69a26a3108 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts @@ -0,0 +1,34 @@ +/** @file Actions for the "account" form in settings. */ +import type { Locator, Page } from 'playwright' +import { TEXT } from '.' +import type { BaseActionsClass } from './BaseActions' +import PageActions from './PageActions' + +/** Actions for the "account" form in settings. */ +export default class SettingsFormActions< + Context, + ParentClass extends BaseActionsClass, +> extends PageActions { + /** Construct a {@link SettingsFormActions}. */ + constructor( + private parentClass: ParentClass, + protected locate: (page: Page) => Locator, + ...args: ConstructorParameters> + ) { + super(...args) + } + + /** Save and submit this settings section. */ + save(): InstanceType { + return this.step('Save settings form', (page) => + this.locate(page).getByRole('button', { name: TEXT.save }).getByText(TEXT.save).click(), + ).into(this.parentClass) + } + + /** Cancel editing this settings section. */ + cancel(): InstanceType { + return this.step('Cancel editing settings form', (page) => + this.locate(page).getByRole('button', { name: TEXT.save }).getByText(TEXT.save).click(), + ).into(this.parentClass) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts new file mode 100644 index 000000000000..e4e8953d3740 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts @@ -0,0 +1,8 @@ +/** @file Actions for the "keyboard shortcuts" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' + +/** Actions for the "keyboard shortcuts" tab of the "settings" page. */ +export default class SettingsKeyboardShortcutsTabActions extends BaseSettingsTabActions< + 'keyboardShortcuts', + Context +> {} diff --git a/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts new file mode 100644 index 000000000000..2b2ca526bae7 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts @@ -0,0 +1,8 @@ +/** @file Actions for the "local" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' + +/** Actions for the "local" tab of the "settings" page. */ +export default class SettingsLocalTabActions extends BaseSettingsTabActions< + 'local', + Context +> {} diff --git a/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts new file mode 100644 index 000000000000..7206f9f2b6e7 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts @@ -0,0 +1,8 @@ +/** @file Actions for the "members" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' + +/** Actions for the "members" tab of the "settings" page. */ +export default class SettingsMembersTabActions extends BaseSettingsTabActions< + 'members', + Context +> {} diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts new file mode 100644 index 000000000000..573135228c28 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts @@ -0,0 +1,8 @@ +/** @file Actions for the "organization" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' + +/** Actions for the "organization" tab of the "settings" page. */ +export default class SettingsOrganizationTabActions extends BaseSettingsTabActions< + 'organization', + Context +> {} diff --git a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts index 57a5f6333d04..fc426eb4c7a9 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts @@ -1,12 +1,10 @@ -/** @file Actions for the "user" tab of the "settings" page. */ -import { goToPageActions, type GoToPageActions } from './goToPageActions' -import PageActions from './PageActions' +/** @file Actions for the default tab of the "settings" page. */ +import SettingsAccountTabActions from './SettingsAccountTabActions' -// TODO: split settings page actions into different classes for each settings tab. -/** Actions for the "user" tab of the "settings" page. */ -export default class SettingsPageActions extends PageActions { - /** Actions for navigating to another page. */ - get goToPage(): Omit, 'settings'> { - return goToPageActions(this.step.bind(this)) - } -} +/** Actions for the default tab of the "settings" page. */ +type SettingsPageActions = SettingsAccountTabActions + +/** Actions for the default tab of the "settings" page. */ +const SettingsPageActions = SettingsAccountTabActions + +export default SettingsPageActions diff --git a/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts new file mode 100644 index 000000000000..60354ae84066 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts @@ -0,0 +1,8 @@ +/** @file Actions for the "user groups" tab of the "settings" page. */ +import BaseSettingsTabActions from './BaseSettingsTabActions' + +/** Actions for the "user groups" tab of the "settings" page. */ +export default class SettingsUserGroupsTabActions extends BaseSettingsTabActions< + 'userGroups', + Context +> {} diff --git a/app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts b/app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts new file mode 100644 index 000000000000..ca92c3adf93f --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts @@ -0,0 +1,88 @@ +/** @file Actions for going to a different page. */ +import { TEXT } from '.' +import type { PageCallback } from './BaseActions' +import BaseActions from './BaseActions' +import SettingsAccountTabActions from './SettingsAccountTabActions' +import SettingsActivityLogShortcutsTabActions from './SettingsActivityLogTabActions' +import SettingsBillingAndPlansTabActions from './SettingsBillingAndPlansTabActions' +import SettingsKeyboardShortcutsTabActions from './SettingsKeyboardShortcutsTabActions' +import SettingsLocalTabActions from './SettingsLocalTabActions' +import SettingsMembersTabActions from './SettingsMembersTabActions' +import SettingsOrganizationTabActions from './SettingsOrganizationTabActions' +import SettingsUserGroupsTabActions from './SettingsUserGroupsTabActions' + +/** Actions for going to a different settings tab. */ +export interface GoToSettingsTabActions { + readonly account: () => SettingsAccountTabActions + readonly organization: () => SettingsOrganizationTabActions + readonly local: () => SettingsLocalTabActions + readonly billingAndPlans: () => SettingsBillingAndPlansTabActions + readonly members: () => SettingsMembersTabActions + readonly userGroups: () => SettingsUserGroupsTabActions + readonly keyboardShortcuts: () => SettingsKeyboardShortcutsTabActions + readonly activityLog: () => SettingsActivityLogShortcutsTabActions +} + +/** Generate actions for going to a different page. */ +export function goToSettingsTabActions( + step: (name: string, callback: PageCallback) => BaseActions, +): GoToSettingsTabActions { + return { + account: () => + step('Go to "account" settings tab', (page) => + page + .getByRole('button', { name: TEXT.accountSettingsTab }) + .getByText(TEXT.accountSettingsTab) + .click(), + ).into(SettingsAccountTabActions), + organization: () => + step('Go to "organization" settings tab', (page) => + page + .getByRole('button', { name: TEXT.organizationSettingsTab }) + .getByText(TEXT.organizationSettingsTab) + .click(), + ).into(SettingsOrganizationTabActions), + local: () => + step('Go to "local" settings tab', (page) => + page + .getByRole('button', { name: TEXT.localSettingsTab }) + .getByText(TEXT.localSettingsTab) + .click(), + ).into(SettingsLocalTabActions), + billingAndPlans: () => + step('Go to "billing and plans" settings tab', (page) => + page + .getByRole('button', { name: TEXT.billingAndPlansSettingsTab }) + .getByText(TEXT.billingAndPlansSettingsTab) + .click(), + ).into(SettingsBillingAndPlansTabActions), + members: () => + step('Go to "members" settings tab', (page) => + page + .getByRole('button', { name: TEXT.membersSettingsTab }) + .getByText(TEXT.membersSettingsTab) + .click(), + ).into(SettingsMembersTabActions), + userGroups: () => + step('Go to "user groups" settings tab', (page) => + page + .getByRole('button', { name: TEXT.userGroupsSettingsTab }) + .getByText(TEXT.userGroupsSettingsTab) + .click(), + ).into(SettingsUserGroupsTabActions), + keyboardShortcuts: () => + step('Go to "keyboard shortcuts" settings tab', (page) => + page + .getByRole('button', { name: TEXT.keyboardShortcutsSettingsTab }) + .getByText(TEXT.keyboardShortcutsSettingsTab) + .click(), + ).into(SettingsKeyboardShortcutsTabActions), + activityLog: () => + step('Go to "activity log" settings tab', (page) => + page + .getByRole('button', { name: TEXT.activityLogSettingsTab }) + .getByText(TEXT.activityLogSettingsTab) + .click(), + ).into(SettingsActivityLogShortcutsTabActions), + } +} From c7f4ea0bd4763bba7c06f9a61e10b123c0cccac6 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 2 Dec 2024 19:47:06 +1000 Subject: [PATCH 07/27] Update `userSettings.spec` to use new API --- .../dashboard/userSettings.spec.ts | 124 +++++++++--------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/app/gui/integration-test/dashboard/userSettings.spec.ts b/app/gui/integration-test/dashboard/userSettings.spec.ts index d491940f0f10..6b596714502b 100644 --- a/app/gui/integration-test/dashboard/userSettings.spec.ts +++ b/app/gui/integration-test/dashboard/userSettings.spec.ts @@ -3,71 +3,71 @@ import * as test from '@playwright/test' import * as actions from './actions' -test.test('user settings', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ page }) - const localActions = actions.settings.userAccount - test.expect(api.currentUser()?.name).toBe(api.defaultName) +const NEW_USERNAME = 'another user-name' +const NEW_PASSWORD = '1234!' + actions.VALID_PASSWORD - await localActions.go(page) - const nameInput = localActions.locateNameInput(page) - const newName = 'another user-name' - await nameInput.fill(newName) - await nameInput.press('Enter') - test.expect(api.currentUser()?.name).toBe(newName) - test.expect(api.currentOrganization()?.name).not.toBe(newName) -}) +test.test('user settings', ({ page }) => + actions + .mockAllAndLogin({ page }) + .do((_, { api }) => { + test.expect(api.currentUser()?.name).toBe(api.defaultName) + }) + .goToPage.settings() + .accountForm() + .fillName(NEW_USERNAME) + .save() + .do((_, { api }) => { + test.expect(api.currentUser()?.name).toBe(NEW_USERNAME) + test.expect(api.currentOrganization()?.name).not.toBe(NEW_USERNAME) + }), +) test.test('change password form', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ page }) - const localActions = actions.settings.changePassword - - await localActions.go(page) - test.expect(api.currentPassword()).toBe(actions.VALID_PASSWORD) - await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) - - await test.test.step('Invalid new password', async () => { - await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD) - await localActions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD) - await localActions.locateSaveButton(page).click() - await test - .expect( - localActions - .locate(page) - .getByRole('group', { name: /^New password/, exact: true }) - .locator('.text-danger') - .last(), - ) - .toHaveText(actions.TEXT.passwordValidationError) - }) - - await test.test.step('Invalid new password confirmation', async () => { - await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD) - await localActions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a') - await localActions.locateSaveButton(page).click() - await test - .expect( - localActions - .locate(page) - .getByRole('group', { name: /^Confirm new password/, exact: true }) - .locator('.text-danger') - .last(), - ) - .toHaveText(actions.TEXT.passwordMismatchError) - }) - - await test.test.step('Successful password change', async () => { - const newPassword = '1234!' + actions.VALID_PASSWORD - await localActions.locateNewPasswordInput(page).fill(newPassword) - await localActions.locateConfirmNewPasswordInput(page).fill(newPassword) - await localActions.locateSaveButton(page).click() - await test.expect(localActions.locateCurrentPasswordInput(page)).toHaveText('') - await test.expect(localActions.locateNewPasswordInput(page)).toHaveText('') - await test.expect(localActions.locateConfirmNewPasswordInput(page)).toHaveText('') - test.expect(api.currentPassword()).toBe(newPassword) - }) + actions + .mockAllAndLogin({ page }) + .do((_, { api }) => { + test.expect(api.currentPassword()).toBe(actions.VALID_PASSWORD) + }) + .goToPage.settings() + .changePasswordForm() + .fillCurrentPassword(actions.VALID_PASSWORD) + .fillNewPassword(actions.INVALID_PASSWORD) + .fillConfirmNewPassword(actions.INVALID_PASSWORD) + .save() + .step('Invalid new password', async (page) => { + await test + .expect( + page + .getByRole('group', { name: /^New password/, exact: true }) + .locator('.text-danger') + .last(), + ) + .toHaveText(actions.TEXT.passwordValidationError) + }) + .changePasswordForm() + .fillCurrentPassword(actions.VALID_PASSWORD) + .fillNewPassword(actions.VALID_PASSWORD) + .fillConfirmNewPassword(actions.VALID_PASSWORD + 'a') + .save() + .step('Invalid new password confirmation', async (page) => { + await test + .expect( + page + .getByRole('group', { name: /^Confirm new password/, exact: true }) + .locator('.text-danger') + .last(), + ) + .toHaveText(actions.TEXT.passwordMismatchError) + }) + .changePasswordForm() + .fillCurrentPassword(actions.VALID_PASSWORD) + .fillNewPassword(NEW_PASSWORD) + .fillConfirmNewPassword(NEW_PASSWORD) + .save() + // TODO: consider checking that password inputs are now empty. + .step('Successful password change', (_, { api }) => { + test.expect(api.currentPassword()).toBe(NEW_PASSWORD) + }) }) test.test('upload profile picture', async ({ page }) => { From b7daa296c9acee1a97b615e21be024fb7ffd2626 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 3 Dec 2024 16:23:12 +1000 Subject: [PATCH 08/27] Finish updating `userSettings.spec` to use new API --- .../actions/SettingsAccountTabActions.ts | 14 ++++++ .../dashboard/userSettings.spec.ts | 44 +++++++++---------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts index 4b8be616d197..989703e75a2f 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts @@ -17,4 +17,18 @@ export default class SettingsAccountTabActions extends BaseSettingsTabA changePasswordForm() { return this.into(SettingsChangePasswordFormActions) } + + /** Upload a profile picture. */ + uploadProfilePicture( + name: string, + content: WithImplicitCoercion, + mimeType = 'image/png', + ) { + return this.step('Upload account profile picture', async (page) => { + const fileChooserPromise = page.waitForEvent('filechooser') + await page.locator('label').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }]) + }) + } } diff --git a/app/gui/integration-test/dashboard/userSettings.spec.ts b/app/gui/integration-test/dashboard/userSettings.spec.ts index 6b596714502b..37b825d27643 100644 --- a/app/gui/integration-test/dashboard/userSettings.spec.ts +++ b/app/gui/integration-test/dashboard/userSettings.spec.ts @@ -5,6 +5,8 @@ import * as actions from './actions' const NEW_USERNAME = 'another user-name' const NEW_PASSWORD = '1234!' + actions.VALID_PASSWORD +const PROFILE_PICTURE_FILENAME = 'foo.png' +const PROFILE_PICTURE_CONTENT = 'a profile picture' test.test('user settings', ({ page }) => actions @@ -22,7 +24,7 @@ test.test('user settings', ({ page }) => }), ) -test.test('change password form', async ({ page }) => { +test.test('change password form', ({ page }) => actions .mockAllAndLogin({ page }) .do((_, { api }) => { @@ -34,7 +36,7 @@ test.test('change password form', async ({ page }) => { .fillNewPassword(actions.INVALID_PASSWORD) .fillConfirmNewPassword(actions.INVALID_PASSWORD) .save() - .step('Invalid new password', async (page) => { + .step('Invalid new password should fail', async (page) => { await test .expect( page @@ -49,7 +51,7 @@ test.test('change password form', async ({ page }) => { .fillNewPassword(actions.VALID_PASSWORD) .fillConfirmNewPassword(actions.VALID_PASSWORD + 'a') .save() - .step('Invalid new password confirmation', async (page) => { + .step('Invalid new password confirmation should fail', async (page) => { await test .expect( page @@ -65,25 +67,21 @@ test.test('change password form', async ({ page }) => { .fillConfirmNewPassword(NEW_PASSWORD) .save() // TODO: consider checking that password inputs are now empty. - .step('Successful password change', (_, { api }) => { + .step('Password change should be successful', (_, { api }) => { test.expect(api.currentPassword()).toBe(NEW_PASSWORD) - }) -}) - -test.test('upload profile picture', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ page }) - const localActions = actions.settings.profilePicture + }), +) - await localActions.go(page) - const fileChooserPromise = page.waitForEvent('filechooser') - await localActions.locateInput(page).click() - const fileChooser = await fileChooserPromise - const name = 'foo.png' - const content = 'a profile picture' - await fileChooser.setFiles([{ name, mimeType: 'image/png', buffer: Buffer.from(content) }]) - await test - .expect(() => { - test.expect(api.currentProfilePicture()).toEqual(content) - }) - .toPass() -}) +test.test('upload profile picture', ({ page }) => + actions + .mockAllAndLogin({ page }) + .goToPage.settings() + .uploadProfilePicture(PROFILE_PICTURE_FILENAME, PROFILE_PICTURE_CONTENT) + .step('Profile picture should be updated', async (_, { api }) => { + await test + .expect(() => { + test.expect(api.currentProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) + }) + .toPass() + }), +) From 62a942730277c5948c18e406cb325e5fc97971de Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 3 Dec 2024 18:10:28 +1000 Subject: [PATCH 09/27] Update `organizationSettings.spec` to use new API --- .../actions/SettingsAccountFormActions.ts | 2 +- .../actions/SettingsAccountTabActions.ts | 2 +- .../SettingsChangePasswordFormActions.ts | 6 +- .../SettingsOrganizationFormActions.ts | 64 ++++++ .../actions/SettingsOrganizationTabActions.ts | 22 ++- .../dashboard/organizationSettings.spec.ts | 187 +++++++++--------- .../dashboard/userSettings.spec.ts | 7 +- 7 files changed, 186 insertions(+), 104 deletions(-) create mode 100644 app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts index 2511e6e4f6c4..6870d948c5de 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts @@ -24,7 +24,7 @@ export default class SettingsAccountFormActions extends SettingsFormAct /** Fill the "name" input of this form. */ fillName(name: string) { - return this.step('', (page) => + return this.step("Fill 'name' input of 'account' form", (page) => this.locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox').fill(name), ) } diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts index 989703e75a2f..a18864d2bf15 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts @@ -22,7 +22,7 @@ export default class SettingsAccountTabActions extends BaseSettingsTabA uploadProfilePicture( name: string, content: WithImplicitCoercion, - mimeType = 'image/png', + mimeType: string, ) { return this.step('Upload account profile picture', async (page) => { const fileChooserPromise = page.waitForEvent('filechooser') diff --git a/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts index 80a07fe60652..4ee400d0a047 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts @@ -24,7 +24,7 @@ export default class SettingsChangePasswordFormActions extends Settings /** Fill the "current password" input of this form. */ fillCurrentPassword(name: string) { - return this.step('', (page) => + return this.step("Fill 'current password' input of 'change password' form", (page) => this.locate(page) .getByLabel(TEXT.userCurrentPasswordSettingsInput) .getByRole('textbox') @@ -34,7 +34,7 @@ export default class SettingsChangePasswordFormActions extends Settings /** Fill the "new password" input of this form. */ fillNewPassword(name: string) { - return this.step('', (page) => + return this.step("Fill 'new password' input of 'change password' form", (page) => this.locate(page) .getByLabel(TEXT.userNewPasswordSettingsInput) .getByRole('textbox') @@ -44,7 +44,7 @@ export default class SettingsChangePasswordFormActions extends Settings /** Fill the "confirm new password" input of this form. */ fillConfirmNewPassword(name: string) { - return this.step('', (page) => + return this.step("Fill 'confirm new password' input of 'change password' form", (page) => this.locate(page) .getByLabel(TEXT.userConfirmNewPasswordSettingsInput) .getByRole('textbox') diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts new file mode 100644 index 000000000000..be8f076e465d --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts @@ -0,0 +1,64 @@ +/** @file Actions for the "organization" form in settings. */ +import { TEXT } from '.' +import type PageActions from './PageActions' +import SettingsFormActions from './SettingsFormActions' +import SettingsOrganizationTabActions from './SettingsOrganizationTabActions' + +/** Actions for the "organization" form in settings. */ +export default class SettingsOrganizationFormActions extends SettingsFormActions< + Context, + typeof SettingsOrganizationTabActions +> { + /** Create a {@link SettingsAccountFormActions}. */ + constructor(...args: ConstructorParameters>) { + super( + SettingsOrganizationTabActions, + (page) => + page + .getByRole('heading') + .and(page.getByText(TEXT.userAccountSettingsSection)) + .locator('..'), + ...args, + ) + } + + /** Fill the "name" input of this form. */ + fillName(name: string) { + return this.step("Fill 'name' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationNameSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "email" input of this form. */ + fillEmail(name: string) { + return this.step("Fill 'email' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationEmailSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "website" input of this form. */ + fillWebsite(name: string) { + return this.step("Fill 'website' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationWebsiteSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "location" input of this form. */ + fillLocation(name: string) { + return this.step("Fill 'location' input of 'organization' form", (page) => + this.locate(page) + .getByLabel(TEXT.organizationLocationSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts index 573135228c28..d914198e6c6a 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts @@ -1,8 +1,28 @@ /** @file Actions for the "organization" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' +import SettingsOrganizationFormActions from './SettingsOrganizationFormActions' /** Actions for the "organization" tab of the "settings" page. */ export default class SettingsOrganizationTabActions extends BaseSettingsTabActions< 'organization', Context -> {} +> { + /** Manipulate the "organization" form. */ + organizationForm() { + return this.into(SettingsOrganizationFormActions) + } + + /** Upload a profile picture. */ + uploadProfilePicture( + name: string, + content: WithImplicitCoercion, + mimeType: string, + ) { + return this.step('Upload organization profile picture', async (page) => { + const fileChooserPromise = page.waitForEvent('filechooser') + await page.locator('label').click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }]) + }) + } +} diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index 6fd4e02160b4..4cccd71789b3 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -4,103 +4,96 @@ import * as test from '@playwright/test' import { Plan } from 'enso-common/src/services/Backend' import * as actions from './actions' -test.test('organization settings', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ - page, - setupAPI: (theApi) => { - theApi.setPlan(Plan.team) - }, - }) - const localActions = actions.settings.organization +const NEW_NAME = 'another organization-name' +const INVALID_EMAIL = 'invalid@email' +const NEW_EMAIL = 'organization@email.com' +const NEW_WEBSITE = 'organization.org' +const NEW_LOCATION = 'Somewhere, CA' +const PROFILE_PICTURE_FILENAME = 'bar.jpeg' +const PROFILE_PICTURE_CONTENT = 'organization profile picture' +const PROFILE_PICTURE_MIMETYPE = 'image/jpeg' - // Setup - api.setCurrentOrganization(api.defaultOrganization) - await test.test.step('Initial state', () => { - test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) - test.expect(api.currentOrganization()?.email).toBe(null) - test.expect(api.currentOrganization()?.picture).toBe(null) - test.expect(api.currentOrganization()?.website).toBe(null) - test.expect(api.currentOrganization()?.address).toBe(null) - }) - await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() - - await localActions.go(page) - const nameInput = localActions.locateNameInput(page) - const newName = 'another organization-name' - await test.test.step('Set name', async () => { - await nameInput.fill(newName) - await nameInput.press('Enter') - test.expect(api.currentOrganization()?.name).toBe(newName) - test.expect(api.currentUser()?.name).not.toBe(newName) - }) - - await test.test.step('Unset name (should fail)', async () => { - await nameInput.fill('') - await nameInput.press('Enter') - await test.expect(nameInput).toHaveValue('') - test.expect(api.currentOrganization()?.name).toBe(newName) - await page.getByRole('button', { name: actions.TEXT.cancel }).click() - }) - - const invalidEmail = 'invalid@email' - const emailInput = localActions.locateEmailInput(page) - - await test.test.step('Set invalid email', async () => { - await emailInput.fill(invalidEmail) - await emailInput.press('Enter') - test.expect(api.currentOrganization()?.email).toBe('') - }) - - const newEmail = 'organization@email.com' - - await test.test.step('Set email', async () => { - await emailInput.fill(newEmail) - await emailInput.press('Enter') - test.expect(api.currentOrganization()?.email).toBe(newEmail) - await test.expect(emailInput).toHaveValue(newEmail) - }) - - const websiteInput = localActions.locateWebsiteInput(page) - const newWebsite = 'organization.org' - - // NOTE: It's not yet possible to unset the website or the location. - await test.test.step('Set website', async () => { - await websiteInput.fill(newWebsite) - await websiteInput.press('Enter') - test.expect(api.currentOrganization()?.website).toBe(newWebsite) - await test.expect(websiteInput).toHaveValue(newWebsite) - }) - - const locationInput = localActions.locateLocationInput(page) - const newLocation = 'Somewhere, CA' - - await test.test.step('Set location', async () => { - await locationInput.fill(newLocation) - await locationInput.press('Enter') - test.expect(api.currentOrganization()?.address).toBe(newLocation) - await test.expect(locationInput).toHaveValue(newLocation) - }) -}) - -test.test('upload organization profile picture', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ - page, - setupAPI: (theApi) => { - theApi.setPlan(Plan.team) - }, - }) - const localActions = actions.settings.organizationProfilePicture +test.test('organization settings', async ({ page }) => + actions + .mockAllAndLogin({ + page, + setupAPI: (api) => { + api.setPlan(Plan.team) + api.setCurrentOrganization(api.defaultOrganization) + }, + }) + .step('Verify initial organization state', (_, { api }) => { + test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) + test.expect(api.currentOrganization()?.email).toBe(null) + test.expect(api.currentOrganization()?.picture).toBe(null) + test.expect(api.currentOrganization()?.website).toBe(null) + test.expect(api.currentOrganization()?.address).toBe(null) + }) + .do(async (page) => { + await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() + }) + .goToPage.settings() + .goToSettingsTab.organization() + .organizationForm() + .fillName(NEW_NAME) + .save() + .step('Set organization name', (_, { api }) => { + test.expect(api.currentOrganization()?.name).toBe(NEW_NAME) + test.expect(api.currentUser()?.name).not.toBe(NEW_NAME) + }) + .organizationForm() + .fillName('') + .step('Unsetting organization name should fail', (_, { api }) => { + test.expect(api.currentOrganization()?.name).toBe(NEW_NAME) + }) + .cancel() + .organizationForm() + .fillEmail(INVALID_EMAIL) + .save() + .step('Setting invalid email should fail', (_, { api }) => { + test.expect(api.currentOrganization()?.email).toBe('') + }) + .organizationForm() + .fillEmail(NEW_EMAIL) + .save() + .step('Set email', (_, { api }) => { + test.expect(api.currentOrganization()?.email).toBe(NEW_EMAIL) + }) + .organizationForm() + .fillWebsite(NEW_WEBSITE) + .save() + // NOTE: It is not yet possible to unset the website or the location. + .step('Set website', async (_, { api }) => { + test.expect(api.currentOrganization()?.website).toBe(NEW_WEBSITE) + }) + .organizationForm() + .fillLocation(NEW_LOCATION) + .save() + .step('Set website', async (_, { api }) => { + test.expect(api.currentOrganization()?.address).toBe(NEW_LOCATION) + }), +) - await localActions.go(page) - const fileChooserPromise = page.waitForEvent('filechooser') - await localActions.locateInput(page).click() - const fileChooser = await fileChooserPromise - const name = 'bar.jpeg' - const content = 'organization profile picture' - await fileChooser.setFiles([{ name, buffer: Buffer.from(content), mimeType: 'image/jpeg' }]) - await test - .expect(() => { - test.expect(api.currentOrganizationProfilePicture()).toEqual(content) +test.test('upload organization profile picture', ({ page }) => + actions + .mockAllAndLogin({ + page, + setupAPI: (theApi) => { + theApi.setPlan(Plan.team) + }, }) - .toPass() -}) + .goToPage.settings() + .goToSettingsTab.organization() + .uploadProfilePicture( + PROFILE_PICTURE_FILENAME, + PROFILE_PICTURE_CONTENT, + PROFILE_PICTURE_MIMETYPE, + ) + .step('Profile picture should be updated', async (_, { api }) => { + await test + .expect(() => { + test.expect(api.currentOrganizationProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) + }) + .toPass() + }), +) diff --git a/app/gui/integration-test/dashboard/userSettings.spec.ts b/app/gui/integration-test/dashboard/userSettings.spec.ts index 37b825d27643..25763349d3ff 100644 --- a/app/gui/integration-test/dashboard/userSettings.spec.ts +++ b/app/gui/integration-test/dashboard/userSettings.spec.ts @@ -7,6 +7,7 @@ const NEW_USERNAME = 'another user-name' const NEW_PASSWORD = '1234!' + actions.VALID_PASSWORD const PROFILE_PICTURE_FILENAME = 'foo.png' const PROFILE_PICTURE_CONTENT = 'a profile picture' +const PROFILE_PICTURE_MIMETYPE = 'image/png' test.test('user settings', ({ page }) => actions @@ -76,7 +77,11 @@ test.test('upload profile picture', ({ page }) => actions .mockAllAndLogin({ page }) .goToPage.settings() - .uploadProfilePicture(PROFILE_PICTURE_FILENAME, PROFILE_PICTURE_CONTENT) + .uploadProfilePicture( + PROFILE_PICTURE_FILENAME, + PROFILE_PICTURE_CONTENT, + PROFILE_PICTURE_MIMETYPE, + ) .step('Profile picture should be updated', async (_, { api }) => { await test .expect(() => { From 75efa0a779e59322b30212799761d3bc887f7dea Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 3 Dec 2024 18:15:24 +1000 Subject: [PATCH 10/27] Switch integration tests to use named imports --- .../dashboard/assetPanel.spec.ts | 65 +++---- .../dashboard/assetSearchBar.spec.ts | 96 +++++----- .../dashboard/assetsTableFeatures.spec.ts | 109 ++++++----- .../dashboard/authPreserveEmail.spec.ts | 18 +- .../integration-test/dashboard/copy.spec.ts | 171 ++++++++--------- .../dashboard/createAsset.spec.ts | 56 +++--- .../dashboard/dataLinkEditor.spec.ts | 12 +- .../dashboard/driveView.spec.ts | 33 ++-- .../dashboard/editAssetName.spec.ts | 114 +++++------ .../integration-test/dashboard/labels.spec.ts | 100 +++++----- .../dashboard/labelsPanel.spec.ts | 22 +-- .../dashboard/loginLogout.spec.ts | 22 +-- .../dashboard/loginScreen.spec.ts | 11 +- .../dashboard/organizationSettings.spec.ts | 76 ++++---- .../dashboard/pageSwitcher.spec.ts | 24 ++- .../dashboard/renameAsset.spec.ts | 78 ++++---- .../integration-test/dashboard/setup.spec.ts | 86 ++++----- .../integration-test/dashboard/signUp.spec.ts | 9 +- .../integration-test/dashboard/sort.spec.ts | 180 +++++++++--------- .../dashboard/startModal.spec.ts | 16 +- .../dashboard/userSettings.spec.ts | 90 ++++----- 21 files changed, 671 insertions(+), 717 deletions(-) diff --git a/app/gui/integration-test/dashboard/assetPanel.spec.ts b/app/gui/integration-test/dashboard/assetPanel.spec.ts index b0315d375419..336f51c94c12 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -1,11 +1,11 @@ /** @file Tests for the asset panel. */ import { expect, test } from '@playwright/test' -import * as backend from '#/services/Backend' +import { EmailAddress, UserId } from '#/services/Backend' -import * as permissions from '#/utilities/permissions' +import { PermissionAction } from '#/utilities/permissions' -import * as actions from './actions' +import { locateAssetPanelDescription, mockAllAndLogin } from './actions' /** An example description for the asset selected in the asset panel. */ const DESCRIPTION = 'foo bar' @@ -15,8 +15,7 @@ const USERNAME = 'baz quux' const EMAIL = 'baz.quux@email.com' test('open and close asset panel', ({ page }) => - actions - .mockAllAndLogin({ page }) + mockAllAndLogin({ page }) .withAssetPanel(async (assetPanel) => { await expect(assetPanel).toBeVisible() }) @@ -26,45 +25,43 @@ test('open and close asset panel', ({ page }) => })) test('asset panel contents', ({ page }) => - actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - const { defaultOrganizationId, defaultUserId } = api - api.addProject('project', { - description: DESCRIPTION, - permissions: [ - { - permission: permissions.PermissionAction.own, - user: { - organizationId: defaultOrganizationId, - // Using the default ID causes the asset to have a dynamic username. - userId: backend.UserId(defaultUserId + '2'), - name: USERNAME, - email: backend.EmailAddress(EMAIL), - }, + mockAllAndLogin({ + page, + setupAPI: (api) => { + const { defaultOrganizationId, defaultUserId } = api + api.addProject('project', { + description: DESCRIPTION, + permissions: [ + { + permission: PermissionAction.own, + user: { + organizationId: defaultOrganizationId, + // Using the default ID causes the asset to have a dynamic username. + userId: UserId(defaultUserId + '2'), + name: USERNAME, + email: EmailAddress(EMAIL), }, - ], - }) - }, - }) + }, + ], + }) + }, + }) .driveTable.clickRow(0) .toggleDescriptionAssetPanel() .do(async () => { - await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) + await test.expect(locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) // `getByText` is required so that this assertion works if there are multiple permissions. // This is not visible; "Shared with" should only be visible on the Enterprise plan. // await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() })) test('Asset Panel Documentation view', ({ page }) => { - return actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addProject('project', { description: DESCRIPTION }) - }, - }) + return mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject('project', { description: DESCRIPTION }) + }, + }) .driveTable.clickRow(0) .toggleDocsAssetPanel() .withAssetPanel(async (assetPanel) => { diff --git a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts index a69a7d0d3920..75cafdbc9eb6 100644 --- a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts +++ b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts @@ -1,30 +1,36 @@ /** @file Test the search bar and its suggestions. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as backend from '#/services/Backend' +import { COLORS } from '#/services/Backend' -import * as actions from './actions' +import { + locateSearchBarInput, + locateSearchBarLabels, + locateSearchBarSuggestions, + locateSearchBarTags, + mockAllAndLogin, +} from './actions' -test.test('tags (positive)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const searchBarInput = actions.locateSearchBarInput(page) - const tags = actions.locateSearchBarTags(page) +test('tags (positive)', async ({ page }) => { + await mockAllAndLogin({ page }) + const searchBarInput = locateSearchBarInput(page) + const tags = 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) + expect(text.length).toBeGreaterThan(0) await positiveTag.click() - await test.expect(searchBarInput).toHaveValue(text) + await expect(searchBarInput).toHaveValue(text) } }) -test.test('tags (negative)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const searchBarInput = actions.locateSearchBarInput(page) - const tags = actions.locateSearchBarTags(page) +test('tags (negative)', async ({ page }) => { + await mockAllAndLogin({ page }) + const searchBarInput = locateSearchBarInput(page) + const tags = locateSearchBarTags(page) await searchBarInput.click() await page.keyboard.down('Shift') @@ -32,40 +38,40 @@ test.test('tags (negative)', async ({ page }) => { await searchBarInput.selectText() await searchBarInput.press('Backspace') const text = (await negativeTag.textContent()) ?? '' - test.expect(text.length).toBeGreaterThan(0) + expect(text.length).toBeGreaterThan(0) await negativeTag.click() - await test.expect(searchBarInput).toHaveValue(text) + await expect(searchBarInput).toHaveValue(text) } }) -test.test('labels', async ({ page }) => { - await actions.mockAllAndLogin({ +test('labels', async ({ page }) => { + await mockAllAndLogin({ page, setupAPI: (api) => { - api.addLabel('aaaa', backend.COLORS[0]) - api.addLabel('bbbb', backend.COLORS[1]) - api.addLabel('cccc', backend.COLORS[2]) - api.addLabel('dddd', backend.COLORS[3]) + api.addLabel('aaaa', COLORS[0]) + api.addLabel('bbbb', COLORS[1]) + api.addLabel('cccc', COLORS[2]) + api.addLabel('dddd', COLORS[3]) }, }) - const searchBarInput = actions.locateSearchBarInput(page) - const labels = actions.locateSearchBarLabels(page) + const searchBarInput = locateSearchBarInput(page) + const labels = locateSearchBarLabels(page) await searchBarInput.click() for (const label of await labels.all()) { const name = (await label.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) + expect(name.length).toBeGreaterThan(0) await label.click() - await test.expect(searchBarInput).toHaveValue('label:' + name) + await expect(searchBarInput).toHaveValue('label:' + name) await label.click() - await test.expect(searchBarInput).toHaveValue('-label:' + name) + await expect(searchBarInput).toHaveValue('-label:' + name) await label.click() - await test.expect(searchBarInput).toHaveValue('') + await expect(searchBarInput).toHaveValue('') } }) -test.test('suggestions', async ({ page }) => { - await actions.mockAllAndLogin({ +test('suggestions', async ({ page }) => { + await mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory('foo') @@ -75,23 +81,23 @@ test.test('suggestions', async ({ page }) => { }, }) - const searchBarInput = actions.locateSearchBarInput(page) - const suggestions = actions.locateSearchBarSuggestions(page) + const searchBarInput = locateSearchBarInput(page) + const suggestions = locateSearchBarSuggestions(page) await searchBarInput.click() for (const suggestion of await suggestions.all()) { const name = (await suggestion.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) + expect(name.length).toBeGreaterThan(0) await suggestion.click() - await test.expect(searchBarInput).toHaveValue('name:' + name) + await expect(searchBarInput).toHaveValue('name:' + name) await searchBarInput.selectText() await searchBarInput.press('Backspace') } }) -test.test('suggestions (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ +test('suggestions (keyboard)', async ({ page }) => { + await mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory('foo') @@ -101,22 +107,22 @@ test.test('suggestions (keyboard)', async ({ page }) => { }, }) - const searchBarInput = actions.locateSearchBarInput(page) - const suggestions = actions.locateSearchBarSuggestions(page) + const searchBarInput = locateSearchBarInput(page) + const suggestions = locateSearchBarSuggestions(page) await searchBarInput.click() for (const suggestion of await suggestions.all()) { const name = (await suggestion.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) + expect(name.length).toBeGreaterThan(0) await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + name) + await expect(searchBarInput).toHaveValue('name:' + name) } }) -test.test('complex flows', async ({ page }) => { +test('complex flows', async ({ page }) => { const firstName = 'foo' - await actions.mockAllAndLogin({ + await mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory(firstName) @@ -125,14 +131,14 @@ test.test('complex flows', async ({ page }) => { api.addSecret('quux') }, }) - const searchBarInput = actions.locateSearchBarInput(page) + const searchBarInput = locateSearchBarInput(page) await searchBarInput.click() await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + firstName) + await expect(searchBarInput).toHaveValue('name:' + firstName) await searchBarInput.selectText() await searchBarInput.press('Backspace') - await test.expect(searchBarInput).toHaveValue('') + await expect(searchBarInput).toHaveValue('') await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + firstName) + await expect(searchBarInput).toHaveValue('name:' + firstName) }) diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index 3b93ab03d14a..d16aead9b80a 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -1,13 +1,19 @@ /** @file Test the drive view. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { + getAssetRowLeftPx, + locateAssetsTable, + locateExtraColumns, + locateRootDirectoryDropzone, + mockAllAndLogin, + TEXT, +} from './actions' const PASS_TIMEOUT = 5_000 -test.test('extra columns should stick to right side of assets table', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('extra columns should stick to right side of assets table', ({ page }) => + mockAllAndLogin({ page }) .withAssetsTable(async (table) => { await table.evaluate((element) => { let scrollableParent: HTMLElement | SVGElement | null = element @@ -21,24 +27,21 @@ test.test('extra columns should stick to right side of assets table', ({ page }) }) }) .do(async (thePage) => { - const extraColumns = actions.locateExtraColumns(thePage) - const assetsTable = actions.locateAssetsTable(thePage) - await test - .expect(async () => { - const extraColumnsRight = await extraColumns.evaluate( - (element) => element.getBoundingClientRect().right, - ) - const assetsTableRight = await assetsTable.evaluate( - (element) => element.getBoundingClientRect().right, - ) - test.expect(extraColumnsRight).toEqual(assetsTableRight - 12) - }) - .toPass({ timeout: PASS_TIMEOUT }) - }), -) + const extraColumns = locateExtraColumns(thePage) + const assetsTable = locateAssetsTable(thePage) + await expect(async () => { + const extraColumnsRight = await extraColumns.evaluate( + (element) => element.getBoundingClientRect().right, + ) + const assetsTableRight = await assetsTable.evaluate( + (element) => element.getBoundingClientRect().right, + ) + expect(extraColumnsRight).toEqual(assetsTableRight - 12) + }).toPass({ timeout: PASS_TIMEOUT }) + })) -test.test('extra columns should stick to top of scroll container', async ({ page }) => { - await actions.mockAllAndLogin({ +test('extra columns should stick to top of scroll container', async ({ page }) => { + await mockAllAndLogin({ page, setupAPI: (api) => { for (let i = 0; i < 100; i += 1) { @@ -47,7 +50,7 @@ test.test('extra columns should stick to top of scroll container', async ({ page }, }) - await actions.locateAssetsTable(page).evaluate((element) => { + await locateAssetsTable(page).evaluate((element) => { let scrollableParent: HTMLElement | SVGElement | null = element while ( scrollableParent != null && @@ -57,44 +60,40 @@ test.test('extra columns should stick to top of scroll container', async ({ page } scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' }) }) - const extraColumns = actions.locateExtraColumns(page) - const assetsTable = actions.locateAssetsTable(page) - await test - .expect(async () => { - const extraColumnsTop = await extraColumns.evaluate( - (element) => element.getBoundingClientRect().top, - ) - const assetsTableTop = await assetsTable.evaluate((element) => { - let scrollableParent: HTMLElement | SVGElement | null = element - while ( - scrollableParent != null && - scrollableParent.scrollHeight <= scrollableParent.clientHeight - ) { - scrollableParent = scrollableParent.parentElement - } - return scrollableParent?.getBoundingClientRect().top ?? 0 - }) - test.expect(extraColumnsTop).toEqual(assetsTableTop + 2) + const extraColumns = locateExtraColumns(page) + const assetsTable = locateAssetsTable(page) + await expect(async () => { + const extraColumnsTop = await extraColumns.evaluate( + (element) => element.getBoundingClientRect().top, + ) + const assetsTableTop = await assetsTable.evaluate((element) => { + let scrollableParent: HTMLElement | SVGElement | null = element + while ( + scrollableParent != null && + scrollableParent.scrollHeight <= scrollableParent.clientHeight + ) { + scrollableParent = scrollableParent.parentElement + } + return scrollableParent?.getBoundingClientRect().top ?? 0 }) - .toPass({ timeout: PASS_TIMEOUT }) + expect(extraColumnsTop).toEqual(assetsTableTop + 2) + }).toPass({ timeout: PASS_TIMEOUT }) }) -test.test('can drop onto root directory dropzone', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('can drop onto root directory dropzone', ({ page }) => + mockAllAndLogin({ page }) .createFolder() .uploadFile('b', 'testing') .driveTable.doubleClickRow(0) .driveTable.withRows(async (rows, nonAssetRows) => { - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - await test.expect(nonAssetRows.nth(0)).toHaveText(actions.TEXT.thisFolderIsEmpty) - const childLeft = await actions.getAssetRowLeftPx(nonAssetRows.nth(0)) - test.expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft) + const parentLeft = await getAssetRowLeftPx(rows.nth(0)) + await expect(nonAssetRows.nth(0)).toHaveText(TEXT.thisFolderIsEmpty) + const childLeft = await getAssetRowLeftPx(nonAssetRows.nth(0)) + expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft) }) - .driveTable.dragRow(1, actions.locateRootDirectoryDropzone(page)) + .driveTable.dragRow(1, locateRootDirectoryDropzone(page)) .driveTable.withRows(async (rows) => { - const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const secondLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft) - }), -) + const firstLeft = await getAssetRowLeftPx(rows.nth(0)) + const secondLeft = await getAssetRowLeftPx(rows.nth(1)) + expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft) + })) diff --git a/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts b/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts index bee0e0bdd448..f2ef355a1555 100644 --- a/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts +++ b/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts @@ -1,30 +1,30 @@ /** @file Test that emails are preserved when navigating between auth pages. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' + import { VALID_EMAIL, mockAll } from './actions' // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('preserve email input when changing pages', ({ page }) => +test('preserve email input when changing pages', ({ page }) => mockAll({ page }) .fillEmail(VALID_EMAIL) .goToPage.register() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(VALID_EMAIL) + await expect(emailInput).toHaveValue(VALID_EMAIL) }) .fillEmail(`2${VALID_EMAIL}`) .goToPage.login() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(`2${VALID_EMAIL}`) + await expect(emailInput).toHaveValue(`2${VALID_EMAIL}`) }) .fillEmail(`3${VALID_EMAIL}`) .goToPage.forgotPassword() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(`3${VALID_EMAIL}`) + await expect(emailInput).toHaveValue(`3${VALID_EMAIL}`) }) .fillEmail(`4${VALID_EMAIL}`) .goToPage.login() .withEmailInput(async (emailInput) => { - await test.expect(emailInput).toHaveValue(`4${VALID_EMAIL}`) - }), -) + await expect(emailInput).toHaveValue(`4${VALID_EMAIL}`) + })) diff --git a/app/gui/integration-test/dashboard/copy.spec.ts b/app/gui/integration-test/dashboard/copy.spec.ts index 160f22838a10..021d7e338a35 100644 --- a/app/gui/integration-test/dashboard/copy.spec.ts +++ b/app/gui/integration-test/dashboard/copy.spec.ts @@ -1,11 +1,15 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { + getAssetRowLeftPx, + locateContextMenus, + locateTrashCategory, + mockAllAndLogin, +} from './actions' -test.test('copy', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('copy', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -17,18 +21,16 @@ test.test('copy', ({ page }) => // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] .contextMenu.paste() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(3) - await test.expect(rows.nth(2)).toBeVisible() - await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) + await expect(rows).toHaveCount(3) + await expect(rows.nth(2)).toBeVisible() + await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) + const parentLeft = await getAssetRowLeftPx(rows.nth(1)) + const childLeft = await getAssetRowLeftPx(rows.nth(2)) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) -test.test('copy (keyboard)', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('copy (keyboard)', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -40,18 +42,16 @@ test.test('copy (keyboard)', ({ page }) => // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] .press('Mod+V') .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(3) - await test.expect(rows.nth(2)).toBeVisible() - await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) + await expect(rows).toHaveCount(3) + await expect(rows.nth(2)).toBeVisible() + await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) + const parentLeft = await getAssetRowLeftPx(rows.nth(1)) + const childLeft = await getAssetRowLeftPx(rows.nth(2)) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) -test.test('move', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('move', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -63,18 +63,16 @@ test.test('move', ({ page }) => // Assets: [0: Folder 1, 1: Folder 2 ] .contextMenu.paste() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) + await expect(rows).toHaveCount(2) + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Folder 1/) + const parentLeft = await getAssetRowLeftPx(rows.nth(0)) + const childLeft = await getAssetRowLeftPx(rows.nth(1)) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) -test.test('move (drag)', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('move (drag)', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -82,18 +80,16 @@ test.test('move (drag)', ({ page }) => // Assets: [0: Folder 1, 1: Folder 2 ] .driveTable.dragRowToRow(0, 1) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) + await expect(rows).toHaveCount(2) + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Folder 1/) + const parentLeft = await getAssetRowLeftPx(rows.nth(0)) + const childLeft = await getAssetRowLeftPx(rows.nth(1)) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) -test.test('move to trash', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('move to trash', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -101,17 +97,15 @@ test.test('move to trash', ({ page }) => // NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still // held. .withModPressed((modActions) => modActions.driveTable.clickRow(0).driveTable.clickRow(1)) - .driveTable.dragRow(0, actions.locateTrashCategory(page)) + .driveTable.dragRow(0, locateTrashCategory(page)) .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/]) - }), -) + await expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/]) + })) -test.test('move (keyboard)', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('move (keyboard)', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -123,36 +117,30 @@ test.test('move (keyboard)', ({ page }) => // Assets: [0: Folder 1, 1: Folder 2 ] .press('Mod+V') .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) - const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) - test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) - }), -) + await expect(rows).toHaveCount(2) + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Folder 1/) + const parentLeft = await getAssetRowLeftPx(rows.nth(0)) + const childLeft = await getAssetRowLeftPx(rows.nth(1)) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) + })) -test.test('cut (keyboard)', async ({ page }) => - actions - .mockAllAndLogin({ page }) +test('cut (keyboard)', async ({ page }) => + mockAllAndLogin({ page }) .createFolder() .driveTable.clickRow(0) .press('Mod+X') .driveTable.withRows(async (rows) => { // This action is not a builtin `expect` action, so it needs to be manually retried. - await test - .expect(async () => { - test - .expect(await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity))) - .toBeLessThan(1) - }) - .toPass() - }), -) + await expect(async () => { + expect( + await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity)), + ).toBeLessThan(1) + }).toPass() + })) -test.test('duplicate', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('duplicate', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: New Project 1] .newEmptyProject() .waitForEditorToLoad() @@ -161,16 +149,14 @@ test.test('duplicate', ({ page }) => .contextMenu.duplicate() .driveTable.withRows(async (rows) => { // Assets: [0: New Project 1, 1: New Project 1 (copy)] - await test.expect(rows).toHaveCount(2) - await test.expect(actions.locateContextMenus(page)).not.toBeVisible() - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) - }), -) + await expect(rows).toHaveCount(2) + await expect(locateContextMenus(page)).not.toBeVisible() + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) + })) -test.test('duplicate (keyboard)', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('duplicate (keyboard)', ({ page }) => + mockAllAndLogin({ page }) // Assets: [0: New Project 1] .newEmptyProject() .waitForEditorToLoad() @@ -179,8 +165,7 @@ test.test('duplicate (keyboard)', ({ page }) => .press('Mod+D') .driveTable.withRows(async (rows) => { // Assets: [0: New Project 1 (copy), 1: New Project 1] - await test.expect(rows).toHaveCount(2) - await test.expect(rows.nth(1)).toBeVisible() - await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) - }), -) + await expect(rows).toHaveCount(2) + await expect(rows.nth(1)).toBeVisible() + await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) + })) diff --git a/app/gui/integration-test/dashboard/createAsset.spec.ts b/app/gui/integration-test/dashboard/createAsset.spec.ts index ecff3640a016..42fe7269a4b3 100644 --- a/app/gui/integration-test/dashboard/createAsset.spec.ts +++ b/app/gui/integration-test/dashboard/createAsset.spec.ts @@ -1,7 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { locateEditor, mockAllAndLogin } from './actions' /** The name of the uploaded file. */ const FILE_NAME = 'foo.txt' @@ -12,44 +12,36 @@ const SECRET_NAME = 'a secret name' /** The value of the created secret. */ const SECRET_VALUE = 'a secret value' -test.test('create folder', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create folder', ({ page }) => + mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(/^New Folder 1/) + })) -test.test('create project', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create project', ({ page }) => + mockAllAndLogin({ page }) .newEmptyProject() - .do((thePage) => test.expect(actions.locateEditor(thePage)).toBeAttached()) + .do((thePage) => expect(locateEditor(thePage)).toBeAttached()) .goToPage.drive() - .driveTable.withRows((rows) => test.expect(rows).toHaveCount(1)), -) + .driveTable.withRows((rows) => expect(rows).toHaveCount(1))) -test.test('upload file', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('upload file', ({ page }) => + mockAllAndLogin({ page }) .uploadFile(FILE_NAME, FILE_CONTENTS) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) + })) -test.test('create secret', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create secret', ({ page }) => + mockAllAndLogin({ page }) .createSecret(SECRET_NAME, SECRET_VALUE) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) + })) diff --git a/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts b/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts index ed50465d4e00..36c66dbd3dc8 100644 --- a/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts +++ b/app/gui/integration-test/dashboard/dataLinkEditor.spec.ts @@ -1,15 +1,13 @@ /** @file Test the user settings tab. */ -import * as test from '@playwright/test' +import { test } from '@playwright/test' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' const DATA_LINK_NAME = 'a data link' -test.test('data link editor', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('data link editor', ({ page }) => + mockAllAndLogin({ page }) .openDataLinkModal() .withNameInput(async (input) => { await input.fill(DATA_LINK_NAME) - }), -) + })) diff --git a/app/gui/integration-test/dashboard/driveView.spec.ts b/app/gui/integration-test/dashboard/driveView.spec.ts index 7b65bf794977..a5874edbf4e3 100644 --- a/app/gui/integration-test/dashboard/driveView.spec.ts +++ b/app/gui/integration-test/dashboard/driveView.spec.ts @@ -1,44 +1,47 @@ /** @file Test the drive view. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { + locateAssetsTable, + locateEditor, + locateStopProjectButton, + mockAllAndLogin, +} from './actions' -test.test('drive view', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('drive view', ({ page }) => + mockAllAndLogin({ page }) .withDriveView(async (view) => { - await test.expect(view).toBeVisible() + await expect(view).toBeVisible() }) .driveTable.expectPlaceholderRow() .newEmptyProject() .do(async () => { - await test.expect(actions.locateEditor(page)).toBeAttached() + await expect(locateEditor(page)).toBeAttached() }) .goToPage.drive() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .do(async () => { - await test.expect(actions.locateAssetsTable(page)).toBeVisible() + await expect(locateAssetsTable(page)).toBeVisible() }) .newEmptyProject() .do(async () => { - await test.expect(actions.locateEditor(page)).toBeAttached() + await expect(locateEditor(page)).toBeAttached() }) .goToPage.drive() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(2) + await expect(rows).toHaveCount(2) }) // 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. .driveTable.withRows(async (rows) => { - await actions.locateStopProjectButton(rows.nth(1)).click() + await locateStopProjectButton(rows.nth(1)).click() }) // Project context menu .driveTable.rightClickRow(0) .contextMenu.moveNonFolderToTrash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - }), -) + await expect(rows).toHaveCount(1) + })) diff --git a/app/gui/integration-test/dashboard/editAssetName.spec.ts b/app/gui/integration-test/dashboard/editAssetName.spec.ts index 11ce1f5a9296..d938e7907d92 100644 --- a/app/gui/integration-test/dashboard/editAssetName.spec.ts +++ b/app/gui/integration-test/dashboard/editAssetName.spec.ts @@ -1,37 +1,45 @@ /** @file Test copying, moving, cutting and pasting. */ import { test } from '@playwright/test' -import * as actions from './actions' +import { + locateAssetRowName, + locateAssetRows, + locateContextMenus, + locateEditingCross, + locateEditingTick, + locateNewFolderIcon, + mockAllAndLogin, + press, +} from './actions' test('edit name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) + await mockAllAndLogin({ page }) + const assetRows = locateAssetRows(page) const row = assetRows.nth(0) const newName = 'foo bar baz' - await actions.locateNewFolderIcon(page).click() - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).fill(newName) - await actions.locateEditingTick(row).click() + await locateNewFolderIcon(page).click() + await locateAssetRowName(row).click() + await locateAssetRowName(row).click() + await locateAssetRowName(row).fill(newName) + await locateEditingTick(row).click() await test.expect(row).toHaveText(new RegExp('^' + newName)) }) test('edit name (context menu)', async ({ page }) => { - await actions.mockAllAndLogin({ + await mockAllAndLogin({ page, setupAPI: (api) => { api.addAsset(api.createDirectory('foo')) }, }) - const assetRows = actions.locateAssetRows(page) + const assetRows = locateAssetRows(page) const row = assetRows.nth(0) const newName = 'foo bar baz' - await actions.locateAssetRowName(row).click({ button: 'right' }) - await actions - .locateContextMenus(page) + await locateAssetRowName(row).click({ button: 'right' }) + await locateContextMenus(page) .getByText(/Rename/) .click() @@ -50,80 +58,80 @@ test('edit name (context menu)', async ({ page }) => { }) test('edit name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) + const assetRows = locateAssetRows(page) const row = assetRows.nth(0) const newName = 'foo bar baz quux' - await actions.locateNewFolderIcon(page).click() - await actions.locateAssetRowName(row).click() - await actions.press(page, 'Mod+R') - await actions.locateAssetRowName(row).fill(newName) - await actions.locateAssetRowName(row).press('Enter') + await locateNewFolderIcon(page).click() + await locateAssetRowName(row).click() + await press(page, 'Mod+R') + await locateAssetRowName(row).fill(newName) + await locateAssetRowName(row).press('Enter') await test.expect(row).toHaveText(new RegExp('^' + newName)) }) test('cancel editing name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) + const assetRows = locateAssetRows(page) const row = assetRows.nth(0) const newName = 'foo bar baz' - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).click() + await locateNewFolderIcon(page).click() + const oldName = (await locateAssetRowName(row).textContent()) ?? '' + await locateAssetRowName(row).click() + await locateAssetRowName(row).click() - await actions.locateAssetRowName(row).fill(newName) - await actions.locateEditingCross(row).click() + await locateAssetRowName(row).fill(newName) + await locateEditingCross(row).click() await test.expect(row).toHaveText(new RegExp('^' + oldName)) }) test('cancel editing name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) + const assetRows = locateAssetRows(page) const row = assetRows.nth(0) const newName = 'foo bar baz quux' - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.press(page, 'Mod+R') - await actions.locateAssetRowName(row).fill(newName) - await actions.locateAssetRowName(row).press('Escape') + await locateNewFolderIcon(page).click() + const oldName = (await locateAssetRowName(row).textContent()) ?? '' + await locateAssetRowName(row).click() + await press(page, 'Mod+R') + await locateAssetRowName(row).fill(newName) + await locateAssetRowName(row).press('Escape') await test.expect(row).toHaveText(new RegExp('^' + oldName)) }) test('change to blank name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) + const assetRows = locateAssetRows(page) const row = assetRows.nth(0) - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).fill('') - await test.expect(actions.locateEditingTick(row)).not.toBeVisible() - await actions.locateEditingCross(row).click() + await locateNewFolderIcon(page).click() + const oldName = (await locateAssetRowName(row).textContent()) ?? '' + await locateAssetRowName(row).click() + await locateAssetRowName(row).click() + await locateAssetRowName(row).fill('') + await test.expect(locateEditingTick(row)).not.toBeVisible() + await locateEditingCross(row).click() await test.expect(row).toHaveText(new RegExp('^' + oldName)) }) test('change to blank name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) + await mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) + const assetRows = locateAssetRows(page) const row = assetRows.nth(0) - await actions.locateNewFolderIcon(page).click() - const oldName = (await actions.locateAssetRowName(row).textContent()) ?? '' - await actions.locateAssetRowName(row).click() - await actions.press(page, 'Mod+R') - await actions.locateAssetRowName(row).fill('') - await actions.locateAssetRowName(row).press('Enter') + await locateNewFolderIcon(page).click() + const oldName = (await locateAssetRowName(row).textContent()) ?? '' + await locateAssetRowName(row).click() + await press(page, 'Mod+R') + await locateAssetRowName(row).fill('') + await locateAssetRowName(row).press('Enter') await test.expect(row).toHaveText(new RegExp('^' + oldName)) }) diff --git a/app/gui/integration-test/dashboard/labels.spec.ts b/app/gui/integration-test/dashboard/labels.spec.ts index 77b485aed2d4..41b913da8e0b 100644 --- a/app/gui/integration-test/dashboard/labels.spec.ts +++ b/app/gui/integration-test/dashboard/labels.spec.ts @@ -1,61 +1,59 @@ /** @file Test dragging of labels. */ -import * as test from '@playwright/test' +import { expect, test, type Locator } from '@playwright/test' -import * as backend from '#/services/Backend' +import { COLORS } from '#/services/Backend' -import * as actions from './actions' +import { + locateAssetLabels, + locateAssetRows, + locateLabelsPanelLabels, + mockAllAndLogin, + modModifier, +} from './actions' export const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } /** Click an asset row. The center must not be clicked as that is the button for adding a label. */ -export async function clickAssetRow(assetRow: test.Locator) { +export async function clickAssetRow(assetRow: Locator) { await assetRow.click({ position: ASSET_ROW_SAFE_POSITION }) } -test.test('drag labels onto single row', async ({ page }) => { +test('drag labels onto single row', async ({ page }) => { const label = 'aaaa' - return actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addLabel(label, backend.COLORS[0]) - api.addLabel('bbbb', backend.COLORS[1]) - api.addLabel('cccc', backend.COLORS[2]) - api.addLabel('dddd', backend.COLORS[3]) - api.addDirectory('foo') - api.addSecret('bar') - api.addFile('baz') - api.addSecret('quux') - }, - }) - .do(async () => { - const assetRows = actions.locateAssetRows(page) - const labelEl = actions.locateLabelsPanelLabels(page, label) + return mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addLabel(label, COLORS[0]) + api.addLabel('bbbb', COLORS[1]) + api.addLabel('cccc', COLORS[2]) + api.addLabel('dddd', COLORS[3]) + api.addDirectory('foo') + api.addSecret('bar') + api.addFile('baz') + api.addSecret('quux') + }, + }).do(async () => { + const assetRows = locateAssetRows(page) + const labelEl = locateLabelsPanelLabels(page, label) - await test.expect(labelEl).toBeVisible() - await labelEl.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() - }) + await expect(labelEl).toBeVisible() + await labelEl.dragTo(assetRows.nth(1)) + await expect(locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible() + await expect(locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible() + await expect(locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible() + await expect(locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() + }) }) -test.test('drag labels onto multiple rows', async ({ page }) => { +test('drag labels onto multiple rows', async ({ page }) => { const label = 'aaaa' - await actions.mockAllAndLogin({ + await mockAllAndLogin({ page, setupAPI: (api) => { - api.addLabel(label, backend.COLORS[0]) - api.addLabel('bbbb', backend.COLORS[1]) - api.addLabel('cccc', backend.COLORS[2]) - api.addLabel('dddd', backend.COLORS[3]) + api.addLabel(label, COLORS[0]) + api.addLabel('bbbb', COLORS[1]) + api.addLabel('cccc', COLORS[2]) + api.addLabel('dddd', COLORS[3]) api.addDirectory('foo') api.addSecret('bar') api.addFile('baz') @@ -63,18 +61,18 @@ test.test('drag labels onto multiple rows', async ({ page }) => { }, }) - const assetRows = actions.locateAssetRows(page) - const labelEl = actions.locateLabelsPanelLabels(page, label) + const assetRows = locateAssetRows(page) + const labelEl = locateLabelsPanelLabels(page, label) - await page.keyboard.down(await actions.modModifier(page)) - await test.expect(assetRows).toHaveCount(4) + await page.keyboard.down(await modModifier(page)) + await expect(assetRows).toHaveCount(4) await clickAssetRow(assetRows.nth(0)) await clickAssetRow(assetRows.nth(2)) - await test.expect(labelEl).toBeVisible() + await expect(labelEl).toBeVisible() await labelEl.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() + await page.keyboard.up(await modModifier(page)) + await expect(locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible() + await expect(locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible() + await expect(locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible() + await expect(locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() }) diff --git a/app/gui/integration-test/dashboard/labelsPanel.spec.ts b/app/gui/integration-test/dashboard/labelsPanel.spec.ts index bdc6f03e6981..07d0060786c9 100644 --- a/app/gui/integration-test/dashboard/labelsPanel.spec.ts +++ b/app/gui/integration-test/dashboard/labelsPanel.spec.ts @@ -1,5 +1,5 @@ /** @file Test the labels sidebar panel. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { locateCreateButton, @@ -13,19 +13,19 @@ import { TEXT, } from './actions' -test.test.beforeEach(({ page }) => mockAllAndLogin({ page })) +test.beforeEach(({ page }) => mockAllAndLogin({ page })) -test.test('labels', async ({ page }) => { +test('labels', async ({ page }) => { // Empty labels panel - await test.expect(locateLabelsPanel(page)).toBeVisible() + await expect(locateLabelsPanel(page)).toBeVisible() // "New Label" modal await locateNewLabelButton(page).click() - await test.expect(locateNewLabelModal(page)).toBeVisible() + await expect(locateNewLabelModal(page)).toBeVisible() // "New Label" modal with name set await locateNewLabelModalNameInput(page).fill('New Label') - await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/) + await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) await page.press('html', 'Escape') @@ -33,19 +33,19 @@ test.test('labels', async ({ page }) => { // The exact number is allowed to vary; but to click the fourth color, there must be at least // four colors. await locateNewLabelButton(page).click() - test.expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4) + expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4) // `force: true` is required because the `label` needs to handle the click event, not the // `button`. await locateNewLabelModalColorButtons(page).nth(4).click({ force: true }) - await test.expect(locateNewLabelModal(page)).toBeVisible() + await expect(locateNewLabelModal(page)).toBeVisible() // "New Label" modal with name and color set await locateNewLabelModalNameInput(page).fill('New Label') - await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/) + await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) // Labels panel with one entry await locateCreateButton(locateNewLabelModal(page)).click() - await test.expect(locateLabelsPanel(page)).toBeVisible() + await expect(locateLabelsPanel(page)).toBeVisible() // Empty labels panel again, after deleting the only entry await locateLabelsPanelLabels(page).first().hover() @@ -53,5 +53,5 @@ test.test('labels', async ({ page }) => { const labelsPanel = locateLabelsPanel(page) await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click() await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() - test.expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) + expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) }) diff --git a/app/gui/integration-test/dashboard/loginLogout.spec.ts b/app/gui/integration-test/dashboard/loginLogout.spec.ts index 3ee7793579e3..40d293af4158 100644 --- a/app/gui/integration-test/dashboard/loginLogout.spec.ts +++ b/app/gui/integration-test/dashboard/loginLogout.spec.ts @@ -1,23 +1,21 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { locateDriveView, locateLoginButton, mockAll } from './actions' // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('login and logout', ({ page }) => - actions - .mockAll({ page }) +test('login and logout', ({ page }) => + mockAll({ page }) .login() .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).toBeVisible() - await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible() + await expect(locateDriveView(thePage)).toBeVisible() + await expect(locateLoginButton(thePage)).not.toBeVisible() }) .openUserMenu() .userMenu.logout() .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() - await test.expect(actions.locateLoginButton(thePage)).toBeVisible() - }), -) + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateLoginButton(thePage)).toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/loginScreen.spec.ts b/app/gui/integration-test/dashboard/loginScreen.spec.ts index c5a4af7fb5df..a537ba765fd0 100644 --- a/app/gui/integration-test/dashboard/loginScreen.spec.ts +++ b/app/gui/integration-test/dashboard/loginScreen.spec.ts @@ -1,12 +1,12 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions' // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('login screen', ({ page }) => +test('login screen', ({ page }) => mockAll({ page }) .loginThatShouldFail('invalid email', VALID_PASSWORD, { assert: { @@ -18,6 +18,5 @@ test.test('login screen', ({ page }) => // Technically it should not be allowed, but .login(VALID_EMAIL, INVALID_PASSWORD) .withDriveView(async (driveView) => { - await test.expect(driveView).toBeVisible() - }), -) + await expect(driveView).toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index 4cccd71789b3..9b75528e10bb 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -1,8 +1,8 @@ /** @file Test the organization settings tab. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { Plan } from 'enso-common/src/services/Backend' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' const NEW_NAME = 'another organization-name' const INVALID_EMAIL = 'invalid@email' @@ -13,24 +13,23 @@ const PROFILE_PICTURE_FILENAME = 'bar.jpeg' const PROFILE_PICTURE_CONTENT = 'organization profile picture' const PROFILE_PICTURE_MIMETYPE = 'image/jpeg' -test.test('organization settings', async ({ page }) => - actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.setPlan(Plan.team) - api.setCurrentOrganization(api.defaultOrganization) - }, - }) +test('organization settings', async ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.setPlan(Plan.team) + api.setCurrentOrganization(api.defaultOrganization) + }, + }) .step('Verify initial organization state', (_, { api }) => { - test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) - test.expect(api.currentOrganization()?.email).toBe(null) - test.expect(api.currentOrganization()?.picture).toBe(null) - test.expect(api.currentOrganization()?.website).toBe(null) - test.expect(api.currentOrganization()?.address).toBe(null) + expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) + expect(api.currentOrganization()?.email).toBe(null) + expect(api.currentOrganization()?.picture).toBe(null) + expect(api.currentOrganization()?.website).toBe(null) + expect(api.currentOrganization()?.address).toBe(null) }) .do(async (page) => { - await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible() + await expect(page.getByText('Logging in to Enso...')).not.toBeVisible() }) .goToPage.settings() .goToSettingsTab.organization() @@ -38,50 +37,48 @@ test.test('organization settings', async ({ page }) => .fillName(NEW_NAME) .save() .step('Set organization name', (_, { api }) => { - test.expect(api.currentOrganization()?.name).toBe(NEW_NAME) - test.expect(api.currentUser()?.name).not.toBe(NEW_NAME) + expect(api.currentOrganization()?.name).toBe(NEW_NAME) + expect(api.currentUser()?.name).not.toBe(NEW_NAME) }) .organizationForm() .fillName('') .step('Unsetting organization name should fail', (_, { api }) => { - test.expect(api.currentOrganization()?.name).toBe(NEW_NAME) + expect(api.currentOrganization()?.name).toBe(NEW_NAME) }) .cancel() .organizationForm() .fillEmail(INVALID_EMAIL) .save() .step('Setting invalid email should fail', (_, { api }) => { - test.expect(api.currentOrganization()?.email).toBe('') + expect(api.currentOrganization()?.email).toBe('') }) .organizationForm() .fillEmail(NEW_EMAIL) .save() .step('Set email', (_, { api }) => { - test.expect(api.currentOrganization()?.email).toBe(NEW_EMAIL) + expect(api.currentOrganization()?.email).toBe(NEW_EMAIL) }) .organizationForm() .fillWebsite(NEW_WEBSITE) .save() // NOTE: It is not yet possible to unset the website or the location. .step('Set website', async (_, { api }) => { - test.expect(api.currentOrganization()?.website).toBe(NEW_WEBSITE) + expect(api.currentOrganization()?.website).toBe(NEW_WEBSITE) }) .organizationForm() .fillLocation(NEW_LOCATION) .save() .step('Set website', async (_, { api }) => { - test.expect(api.currentOrganization()?.address).toBe(NEW_LOCATION) - }), -) + expect(api.currentOrganization()?.address).toBe(NEW_LOCATION) + })) -test.test('upload organization profile picture', ({ page }) => - actions - .mockAllAndLogin({ - page, - setupAPI: (theApi) => { - theApi.setPlan(Plan.team) - }, - }) +test('upload organization profile picture', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (theApi) => { + theApi.setPlan(Plan.team) + }, + }) .goToPage.settings() .goToSettingsTab.organization() .uploadProfilePicture( @@ -90,10 +87,7 @@ test.test('upload organization profile picture', ({ page }) => PROFILE_PICTURE_MIMETYPE, ) .step('Profile picture should be updated', async (_, { api }) => { - await test - .expect(() => { - test.expect(api.currentOrganizationProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) - }) - .toPass() - }), -) + await expect(() => { + expect(api.currentOrganizationProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) + }).toPass() + })) diff --git a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts index 4800b78ab532..7b7816acfdbd 100644 --- a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts +++ b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts @@ -1,25 +1,23 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { locateDriveView, locateEditor, mockAllAndLogin } from './actions' -test.test('page switcher', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('page switcher', ({ page }) => + mockAllAndLogin({ page }) // Create a new project so that the editor page can be switched to. .newEmptyProject() .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() - await test.expect(actions.locateEditor(thePage)).toBeVisible() + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateEditor(thePage)).toBeVisible() }) .goToPage.drive() .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).toBeVisible() - await test.expect(actions.locateEditor(thePage)).not.toBeVisible() + await expect(locateDriveView(thePage)).toBeVisible() + await expect(locateEditor(thePage)).not.toBeVisible() }) .goToPage.editor() .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() - await test.expect(actions.locateEditor(thePage)).toBeVisible() - }), -) + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateEditor(thePage)).toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/renameAsset.spec.ts b/app/gui/integration-test/dashboard/renameAsset.spec.ts index 05e3e0f2164d..d00d3a9c9bde 100644 --- a/app/gui/integration-test/dashboard/renameAsset.spec.ts +++ b/app/gui/integration-test/dashboard/renameAsset.spec.ts @@ -1,7 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { locateAssetRowName, locateEditingTick, locateEditor, mockAllAndLogin } from './actions' /** The name of the uploaded file. */ const FILE_NAME = 'foo.txt' @@ -13,57 +13,49 @@ const SECRET_NAME = 'a secret name' const SECRET_VALUE = 'a secret value' const NEW_NAME = 'some new name' -test.test('rename folder', ({ page }) => - actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addDirectory('a directory') - }, - }) +test('rename folder', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory('a directory') + }, + }) .createFolder() .driveTable.withRows(async (rows, _, { api }) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) const row = rows.nth(0) - await test.expect(row).toBeVisible() - await test.expect(row).toHaveText(/^a directory/) - await actions.locateAssetRowName(row).click() - await actions.locateAssetRowName(row).click() + await expect(row).toBeVisible() + await expect(row).toHaveText(/^a directory/) + await locateAssetRowName(row).click() + await locateAssetRowName(row).click() const calls = api.trackCalls() - await actions.locateAssetRowName(row).fill(NEW_NAME) - await actions.locateEditingTick(row).click() - await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME)) - test.expect(calls.updateDirectory).toBeGreaterThan(0) - }), -) + await locateAssetRowName(row).fill(NEW_NAME) + await locateEditingTick(row).click() + await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + expect(calls.updateDirectory).toBeGreaterThan(0) + })) -test.test('create project', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create project', ({ page }) => + mockAllAndLogin({ page }) .newEmptyProject() - .do((thePage) => test.expect(actions.locateEditor(thePage)).toBeAttached()) + .do((thePage) => expect(locateEditor(thePage)).toBeAttached()) .goToPage.drive() - .driveTable.withRows((rows) => test.expect(rows).toHaveCount(1)), -) + .driveTable.withRows((rows) => expect(rows).toHaveCount(1))) -test.test('upload file', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('upload file', ({ page }) => + mockAllAndLogin({ page }) .uploadFile(FILE_NAME, FILE_CONTENTS) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) + })) -test.test('create secret', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create secret', ({ page }) => + mockAllAndLogin({ page }) .createSecret(SECRET_NAME, SECRET_VALUE) .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) - }), -) + await expect(rows).toHaveCount(1) + await expect(rows.nth(0)).toBeVisible() + await expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) + })) diff --git a/app/gui/integration-test/dashboard/setup.spec.ts b/app/gui/integration-test/dashboard/setup.spec.ts index 711f419d7b74..1ef76f9af1a5 100644 --- a/app/gui/integration-test/dashboard/setup.spec.ts +++ b/app/gui/integration-test/dashboard/setup.spec.ts @@ -1,54 +1,49 @@ /** @file Test the setup flow. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { Plan } from 'enso-common/src/services/Backend' -import * as actions from './actions' +import { mockAll } from './actions' // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('setup (free plan)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (free plan)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .stayOnFreePlan() .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) -test.test('setup (solo plan)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (solo plan)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .selectSoloPlan() .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) -test.test('setup (team plan, skipping invites)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (team plan, skipping invites)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .selectTeamPlan(Plan.team) @@ -57,18 +52,16 @@ test.test('setup (team plan, skipping invites)', ({ page }) => .setTeamName('test team') .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) -test.test('setup (team plan)', ({ page }) => - actions - .mockAll({ - page, - setupAPI: (api) => { - api.setCurrentUser(null) - }, - }) +test('setup (team plan)', ({ page }) => + mockAll({ + page, + setupAPI: (api) => { + api.setCurrentUser(null) + }, + }) .loginAsNewUser() .setUsername('test user') .selectTeamPlan(Plan.team, 10) @@ -77,8 +70,7 @@ test.test('setup (team plan)', ({ page }) => .setTeamName('test team') .goToPage.drive() .withDriveView(async (drive) => { - await test.expect(drive).toBeVisible() - }), -) + await expect(drive).toBeVisible() + })) // No test for enterprise plan as the plan must be set to enterprise manually. diff --git a/app/gui/integration-test/dashboard/signUp.spec.ts b/app/gui/integration-test/dashboard/signUp.spec.ts index 3892e7f61f15..166e58e1455b 100644 --- a/app/gui/integration-test/dashboard/signUp.spec.ts +++ b/app/gui/integration-test/dashboard/signUp.spec.ts @@ -1,12 +1,12 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { test } from '@playwright/test' import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions' // Reset storage state for this file to avoid being authenticated -test.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('sign up without organization id', ({ page }) => +test('sign up without organization id', ({ page }) => mockAll({ page }) .goToPage.register() .registerThatShouldFail('invalid email', VALID_PASSWORD, VALID_PASSWORD, { @@ -33,5 +33,4 @@ test.test('sign up without organization id', ({ page }) => formError: null, }, }) - .register(), -) + .register()) diff --git a/app/gui/integration-test/dashboard/sort.spec.ts b/app/gui/integration-test/dashboard/sort.spec.ts index b40d0101a940..d91fd6e52e18 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -1,26 +1,36 @@ /** @file Test sorting of assets columns. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as dateTime from '#/utilities/dateTime' +import { toRfc3339 } from '#/utilities/dateTime' -import * as actions from './actions' +import { + expectNotOpacity0, + expectOpacity0, + locateAssetRows, + locateModifiedColumnHeading, + locateNameColumnHeading, + locateSortAscendingIcon, + locateSortDescendingIcon, + login, + mockAll, +} from './actions' const START_DATE_EPOCH_MS = 1.7e12 /** The number of milliseconds in a minute. */ const MIN_MS = 60_000 -test.test('sort', async ({ page }) => { - await actions.mockAll({ +test('sort', async ({ page }) => { + await mockAll({ page, setupAPI: (api) => { - 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)) + const date1 = toRfc3339(new Date(START_DATE_EPOCH_MS)) + const date2 = toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS)) + const date3 = toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS)) + const date4 = toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS)) + const date5 = toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS)) + const date6 = toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS)) + const date7 = toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS)) + const date8 = 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 }) @@ -40,99 +50,99 @@ test.test('sort', async ({ page }) => { // d file }, }) - const assetRows = actions.locateAssetRows(page) - const nameHeading = actions.locateNameColumnHeading(page) - const modifiedHeading = actions.locateModifiedColumnHeading(page) - await actions.login({ page }) + const assetRows = locateAssetRows(page) + const nameHeading = locateNameColumnHeading(page) + const modifiedHeading = locateModifiedColumnHeading(page) + await login({ page }) // By default, assets should be grouped by type. // Assets in each group are ordered by insertion order. - await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading)) - await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible() - await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading)) - 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/) + await expectOpacity0(locateSortAscendingIcon(nameHeading)) + await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) + await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + await expect(assetRows.nth(0)).toHaveText(/^a directory/) + await expect(assetRows.nth(1)).toHaveText(/^G directory/) + await expect(assetRows.nth(2)).toHaveText(/^C project/) + await expect(assetRows.nth(3)).toHaveText(/^b project/) + await expect(assetRows.nth(4)).toHaveText(/^d file/) + await expect(assetRows.nth(5)).toHaveText(/^e file/) + await expect(assetRows.nth(6)).toHaveText(/^H secret/) + await expect(assetRows.nth(7)).toHaveText(/^f secret/) // Sort by name ascending. await nameHeading.click() - await actions.expectNotOpacity0(actions.locateSortAscendingIcon(nameHeading)) - await test.expect(assetRows.nth(0)).toHaveText(/^a directory/) - await test.expect(assetRows.nth(1)).toHaveText(/^b project/) - await test.expect(assetRows.nth(2)).toHaveText(/^C project/) - await test.expect(assetRows.nth(3)).toHaveText(/^d file/) - await test.expect(assetRows.nth(4)).toHaveText(/^e file/) - await test.expect(assetRows.nth(5)).toHaveText(/^f secret/) - await test.expect(assetRows.nth(6)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(7)).toHaveText(/^H secret/) + await expectNotOpacity0(locateSortAscendingIcon(nameHeading)) + await expect(assetRows.nth(0)).toHaveText(/^a directory/) + await expect(assetRows.nth(1)).toHaveText(/^b project/) + await expect(assetRows.nth(2)).toHaveText(/^C project/) + await expect(assetRows.nth(3)).toHaveText(/^d file/) + await expect(assetRows.nth(4)).toHaveText(/^e file/) + await expect(assetRows.nth(5)).toHaveText(/^f secret/) + await expect(assetRows.nth(6)).toHaveText(/^G directory/) + await expect(assetRows.nth(7)).toHaveText(/^H secret/) // Sort by name descending. await nameHeading.click() - await actions.expectNotOpacity0(actions.locateSortDescendingIcon(nameHeading)) - await test.expect(assetRows.nth(0)).toHaveText(/^H secret/) - await test.expect(assetRows.nth(1)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(2)).toHaveText(/^f secret/) - await test.expect(assetRows.nth(3)).toHaveText(/^e file/) - await test.expect(assetRows.nth(4)).toHaveText(/^d file/) - await test.expect(assetRows.nth(5)).toHaveText(/^C project/) - await test.expect(assetRows.nth(6)).toHaveText(/^b project/) - await test.expect(assetRows.nth(7)).toHaveText(/^a directory/) + await expectNotOpacity0(locateSortDescendingIcon(nameHeading)) + await expect(assetRows.nth(0)).toHaveText(/^H secret/) + await expect(assetRows.nth(1)).toHaveText(/^G directory/) + await expect(assetRows.nth(2)).toHaveText(/^f secret/) + await expect(assetRows.nth(3)).toHaveText(/^e file/) + await expect(assetRows.nth(4)).toHaveText(/^d file/) + await expect(assetRows.nth(5)).toHaveText(/^C project/) + await expect(assetRows.nth(6)).toHaveText(/^b project/) + await expect(assetRows.nth(7)).toHaveText(/^a directory/) // Sorting should be unset. await nameHeading.click() await page.mouse.move(0, 0) - await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading)) - 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/) + await expectOpacity0(locateSortAscendingIcon(nameHeading)) + await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + await expect(assetRows.nth(0)).toHaveText(/^a directory/) + await expect(assetRows.nth(1)).toHaveText(/^G directory/) + await expect(assetRows.nth(2)).toHaveText(/^C project/) + await expect(assetRows.nth(3)).toHaveText(/^b project/) + await expect(assetRows.nth(4)).toHaveText(/^d file/) + await expect(assetRows.nth(5)).toHaveText(/^e file/) + await expect(assetRows.nth(6)).toHaveText(/^H secret/) + await expect(assetRows.nth(7)).toHaveText(/^f secret/) // Sort by date ascending. await modifiedHeading.click() - await actions.expectNotOpacity0(actions.locateSortAscendingIcon(modifiedHeading)) - await test.expect(assetRows.nth(0)).toHaveText(/^b project/) - await test.expect(assetRows.nth(1)).toHaveText(/^H secret/) - await test.expect(assetRows.nth(2)).toHaveText(/^f secret/) - await test.expect(assetRows.nth(3)).toHaveText(/^a directory/) - await test.expect(assetRows.nth(4)).toHaveText(/^e file/) - await test.expect(assetRows.nth(5)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(6)).toHaveText(/^C project/) - await test.expect(assetRows.nth(7)).toHaveText(/^d file/) + await expectNotOpacity0(locateSortAscendingIcon(modifiedHeading)) + await expect(assetRows.nth(0)).toHaveText(/^b project/) + await expect(assetRows.nth(1)).toHaveText(/^H secret/) + await expect(assetRows.nth(2)).toHaveText(/^f secret/) + await expect(assetRows.nth(3)).toHaveText(/^a directory/) + await expect(assetRows.nth(4)).toHaveText(/^e file/) + await expect(assetRows.nth(5)).toHaveText(/^G directory/) + await expect(assetRows.nth(6)).toHaveText(/^C project/) + await expect(assetRows.nth(7)).toHaveText(/^d file/) // Sort by date descending. await modifiedHeading.click() - await actions.expectNotOpacity0(actions.locateSortDescendingIcon(modifiedHeading)) - await test.expect(assetRows.nth(0)).toHaveText(/^d file/) - await test.expect(assetRows.nth(1)).toHaveText(/^C project/) - await test.expect(assetRows.nth(2)).toHaveText(/^G directory/) - await test.expect(assetRows.nth(3)).toHaveText(/^e file/) - await test.expect(assetRows.nth(4)).toHaveText(/^a directory/) - await test.expect(assetRows.nth(5)).toHaveText(/^f secret/) - await test.expect(assetRows.nth(6)).toHaveText(/^H secret/) - await test.expect(assetRows.nth(7)).toHaveText(/^b project/) + await expectNotOpacity0(locateSortDescendingIcon(modifiedHeading)) + await expect(assetRows.nth(0)).toHaveText(/^d file/) + await expect(assetRows.nth(1)).toHaveText(/^C project/) + await expect(assetRows.nth(2)).toHaveText(/^G directory/) + await expect(assetRows.nth(3)).toHaveText(/^e file/) + await expect(assetRows.nth(4)).toHaveText(/^a directory/) + await expect(assetRows.nth(5)).toHaveText(/^f secret/) + await expect(assetRows.nth(6)).toHaveText(/^H secret/) + await expect(assetRows.nth(7)).toHaveText(/^b project/) // Sorting should be unset. await modifiedHeading.click() await page.mouse.move(0, 0) - await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading)) - 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/) + await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) + await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + await expect(assetRows.nth(0)).toHaveText(/^a directory/) + await expect(assetRows.nth(1)).toHaveText(/^G directory/) + await expect(assetRows.nth(2)).toHaveText(/^C project/) + await expect(assetRows.nth(3)).toHaveText(/^b project/) + await expect(assetRows.nth(4)).toHaveText(/^d file/) + await expect(assetRows.nth(5)).toHaveText(/^e file/) + await expect(assetRows.nth(6)).toHaveText(/^H secret/) + await expect(assetRows.nth(7)).toHaveText(/^f secret/) }) diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index f484a6f530ed..5f96bd55e7e4 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -1,15 +1,13 @@ /** @file Test the "change password" modal. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { locateEditor, locateSamples, mockAllAndLogin } from './actions' -test.test('create project from template', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('create project from template', ({ page }) => + mockAllAndLogin({ page }) .openStartModal() .createProjectFromTemplate(0) .do(async (thePage) => { - await test.expect(actions.locateEditor(thePage)).toBeAttached() - await test.expect(actions.locateSamples(page).first()).not.toBeVisible() - }), -) + await expect(locateEditor(thePage)).toBeAttached() + await expect(locateSamples(page).first()).not.toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/userSettings.spec.ts b/app/gui/integration-test/dashboard/userSettings.spec.ts index 25763349d3ff..83a1793721da 100644 --- a/app/gui/integration-test/dashboard/userSettings.spec.ts +++ b/app/gui/integration-test/dashboard/userSettings.spec.ts @@ -1,81 +1,72 @@ /** @file Test the user settings tab. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' -import * as actions from './actions' +import { INVALID_PASSWORD, TEXT, VALID_PASSWORD, mockAllAndLogin } from './actions' const NEW_USERNAME = 'another user-name' -const NEW_PASSWORD = '1234!' + actions.VALID_PASSWORD +const NEW_PASSWORD = '1234!' + VALID_PASSWORD const PROFILE_PICTURE_FILENAME = 'foo.png' const PROFILE_PICTURE_CONTENT = 'a profile picture' const PROFILE_PICTURE_MIMETYPE = 'image/png' -test.test('user settings', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('user settings', ({ page }) => + mockAllAndLogin({ page }) .do((_, { api }) => { - test.expect(api.currentUser()?.name).toBe(api.defaultName) + expect(api.currentUser()?.name).toBe(api.defaultName) }) .goToPage.settings() .accountForm() .fillName(NEW_USERNAME) .save() .do((_, { api }) => { - test.expect(api.currentUser()?.name).toBe(NEW_USERNAME) - test.expect(api.currentOrganization()?.name).not.toBe(NEW_USERNAME) - }), -) + expect(api.currentUser()?.name).toBe(NEW_USERNAME) + expect(api.currentOrganization()?.name).not.toBe(NEW_USERNAME) + })) -test.test('change password form', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('change password form', ({ page }) => + mockAllAndLogin({ page }) .do((_, { api }) => { - test.expect(api.currentPassword()).toBe(actions.VALID_PASSWORD) + expect(api.currentPassword()).toBe(VALID_PASSWORD) }) .goToPage.settings() .changePasswordForm() - .fillCurrentPassword(actions.VALID_PASSWORD) - .fillNewPassword(actions.INVALID_PASSWORD) - .fillConfirmNewPassword(actions.INVALID_PASSWORD) + .fillCurrentPassword(VALID_PASSWORD) + .fillNewPassword(INVALID_PASSWORD) + .fillConfirmNewPassword(INVALID_PASSWORD) .save() .step('Invalid new password should fail', async (page) => { - await test - .expect( - page - .getByRole('group', { name: /^New password/, exact: true }) - .locator('.text-danger') - .last(), - ) - .toHaveText(actions.TEXT.passwordValidationError) + await expect( + page + .getByRole('group', { name: /^New password/, exact: true }) + .locator('.text-danger') + .last(), + ).toHaveText(TEXT.passwordValidationError) }) .changePasswordForm() - .fillCurrentPassword(actions.VALID_PASSWORD) - .fillNewPassword(actions.VALID_PASSWORD) - .fillConfirmNewPassword(actions.VALID_PASSWORD + 'a') + .fillCurrentPassword(VALID_PASSWORD) + .fillNewPassword(VALID_PASSWORD) + .fillConfirmNewPassword(VALID_PASSWORD + 'a') .save() .step('Invalid new password confirmation should fail', async (page) => { - await test - .expect( - page - .getByRole('group', { name: /^Confirm new password/, exact: true }) - .locator('.text-danger') - .last(), - ) - .toHaveText(actions.TEXT.passwordMismatchError) + await expect( + page + .getByRole('group', { name: /^Confirm new password/, exact: true }) + .locator('.text-danger') + .last(), + ).toHaveText(TEXT.passwordMismatchError) }) .changePasswordForm() - .fillCurrentPassword(actions.VALID_PASSWORD) + .fillCurrentPassword(VALID_PASSWORD) .fillNewPassword(NEW_PASSWORD) .fillConfirmNewPassword(NEW_PASSWORD) .save() // TODO: consider checking that password inputs are now empty. .step('Password change should be successful', (_, { api }) => { - test.expect(api.currentPassword()).toBe(NEW_PASSWORD) - }), -) + expect(api.currentPassword()).toBe(NEW_PASSWORD) + })) -test.test('upload profile picture', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('upload profile picture', ({ page }) => + mockAllAndLogin({ page }) .goToPage.settings() .uploadProfilePicture( PROFILE_PICTURE_FILENAME, @@ -83,10 +74,7 @@ test.test('upload profile picture', ({ page }) => PROFILE_PICTURE_MIMETYPE, ) .step('Profile picture should be updated', async (_, { api }) => { - await test - .expect(() => { - test.expect(api.currentProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) - }) - .toPass() - }), -) + await expect(() => { + expect(api.currentProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) + }).toPass() + })) From 554e1745c80e6f10948ef50421639859258a18ee Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 3 Dec 2024 20:47:43 +1000 Subject: [PATCH 11/27] Add `DrivePageActions.withSearchBar` to switch `assetSearchBar.spec` to new API --- .../dashboard/actions/DrivePageActions.ts | 5 + .../dashboard/actions/index.ts | 5 - .../dashboard/assetSearchBar.spec.ts | 200 +++++++++--------- 3 files changed, 100 insertions(+), 110 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 5545a26871ec..aa7539006003 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -81,6 +81,11 @@ export default class DrivePageActions extends PageActions { } } + /** Interact with the assets search bar. */ + withSearchBar(callback: LocatorCallback) { + callback(this.page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/)) + } + /** Actions specific to the Drive table. */ get driveTable() { // eslint-disable-next-line @typescript-eslint/no-this-alias diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 8acbd458c49c..1e841f5b5b36 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -60,11 +60,6 @@ export function locateSecretValueInput(page: Page) { return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder) } -/** Find a search bar input (if any) on the current page. */ -export function locateSearchBarInput(page: Page) { - return locateSearchBar(page).getByPlaceholder(/(?:)/) -} - /** Find the name column of the given assets table row. */ export function locateAssetRowName(locator: Locator) { return locator.getByTestId('asset-row-name') diff --git a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts index 75cafdbc9eb6..51eaa0ce2136 100644 --- a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts +++ b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts @@ -4,48 +4,47 @@ import { expect, test } from '@playwright/test' import { COLORS } from '#/services/Backend' import { - locateSearchBarInput, locateSearchBarLabels, locateSearchBarSuggestions, locateSearchBarTags, mockAllAndLogin, } from './actions' -test('tags (positive)', async ({ page }) => { - await mockAllAndLogin({ page }) - const searchBarInput = locateSearchBarInput(page) - const tags = locateSearchBarTags(page) - - await searchBarInput.click() - for (const positiveTag of await tags.all()) { - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - const text = (await positiveTag.textContent()) ?? '' - expect(text.length).toBeGreaterThan(0) - await positiveTag.click() - await expect(searchBarInput).toHaveValue(text) - } -}) - -test('tags (negative)', async ({ page }) => { - await mockAllAndLogin({ page }) - const searchBarInput = locateSearchBarInput(page) - const tags = locateSearchBarTags(page) - - await searchBarInput.click() - await page.keyboard.down('Shift') - for (const negativeTag of await tags.all()) { - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - const text = (await negativeTag.textContent()) ?? '' - expect(text.length).toBeGreaterThan(0) - await negativeTag.click() - await expect(searchBarInput).toHaveValue(text) - } -}) - -test('labels', async ({ page }) => { - await mockAllAndLogin({ +const FIRST_ASSET_NAME = 'foo' + +test('tags (positive)', ({ page }) => + mockAllAndLogin({ page }).withSearchBar(async (searchBarInput) => { + const tags = locateSearchBarTags(page) + + await searchBarInput.click() + for (const positiveTag of await tags.all()) { + await searchBarInput.selectText() + await searchBarInput.press('Backspace') + const text = (await positiveTag.textContent()) ?? '' + expect(text.length).toBeGreaterThan(0) + await positiveTag.click() + await expect(searchBarInput).toHaveValue(text) + } + })) + +test('tags (negative)', ({ page }) => + mockAllAndLogin({ page }).withSearchBar(async (searchBar) => { + const tags = locateSearchBarTags(page) + + await searchBar.click() + await page.keyboard.down('Shift') + for (const negativeTag of await tags.all()) { + await searchBar.selectText() + await searchBar.press('Backspace') + const text = (await negativeTag.textContent()) ?? '' + expect(text.length).toBeGreaterThan(0) + await negativeTag.click() + await expect(searchBar).toHaveValue(text) + } + })) + +test('labels', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addLabel('aaaa', COLORS[0]) @@ -53,25 +52,24 @@ test('labels', async ({ page }) => { api.addLabel('cccc', COLORS[2]) api.addLabel('dddd', COLORS[3]) }, - }) - const searchBarInput = locateSearchBarInput(page) - const labels = locateSearchBarLabels(page) - - await searchBarInput.click() - for (const label of await labels.all()) { - const name = (await label.textContent()) ?? '' - expect(name.length).toBeGreaterThan(0) - await label.click() - await expect(searchBarInput).toHaveValue('label:' + name) - await label.click() - await expect(searchBarInput).toHaveValue('-label:' + name) - await label.click() - await expect(searchBarInput).toHaveValue('') - } -}) - -test('suggestions', async ({ page }) => { - await mockAllAndLogin({ + }).withSearchBar(async (searchBar) => { + const labels = locateSearchBarLabels(page) + + await searchBar.click() + for (const label of await labels.all()) { + const name = (await label.textContent()) ?? '' + expect(name.length).toBeGreaterThan(0) + await label.click() + await expect(searchBar).toHaveValue('label:' + name) + await label.click() + await expect(searchBar).toHaveValue('-label:' + name) + await label.click() + await expect(searchBar).toHaveValue('') + } + })) + +test('suggestions', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory('foo') @@ -79,25 +77,23 @@ test('suggestions', async ({ page }) => { api.addSecret('baz') api.addSecret('quux') }, - }) - - const searchBarInput = locateSearchBarInput(page) - const suggestions = locateSearchBarSuggestions(page) - - await searchBarInput.click() - - for (const suggestion of await suggestions.all()) { - const name = (await suggestion.textContent()) ?? '' - expect(name.length).toBeGreaterThan(0) - await suggestion.click() - await expect(searchBarInput).toHaveValue('name:' + name) - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - } -}) - -test('suggestions (keyboard)', async ({ page }) => { - await mockAllAndLogin({ + }).withSearchBar(async (searchBar) => { + const suggestions = locateSearchBarSuggestions(page) + + await searchBar.click() + + for (const suggestion of await suggestions.all()) { + const name = (await suggestion.textContent()) ?? '' + expect(name.length).toBeGreaterThan(0) + await suggestion.click() + await expect(searchBar).toHaveValue('name:' + name) + await searchBar.selectText() + await searchBar.press('Backspace') + } + })) + +test('suggestions (keyboard)', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory('foo') @@ -105,40 +101,34 @@ test('suggestions (keyboard)', async ({ page }) => { api.addSecret('baz') api.addSecret('quux') }, - }) - - const searchBarInput = locateSearchBarInput(page) - const suggestions = locateSearchBarSuggestions(page) - - await searchBarInput.click() - for (const suggestion of await suggestions.all()) { - const name = (await suggestion.textContent()) ?? '' - expect(name.length).toBeGreaterThan(0) - await page.press('body', 'ArrowDown') - await expect(searchBarInput).toHaveValue('name:' + name) - } -}) - -test('complex flows', async ({ page }) => { - const firstName = 'foo' - - await mockAllAndLogin({ + }).withSearchBar(async (searchBar) => { + const suggestions = locateSearchBarSuggestions(page) + + await searchBar.click() + for (const suggestion of await suggestions.all()) { + const name = (await suggestion.textContent()) ?? '' + expect(name.length).toBeGreaterThan(0) + await page.press('body', 'ArrowDown') + await expect(searchBar).toHaveValue('name:' + name) + } + })) + +test('complex flows', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { - api.addDirectory(firstName) + api.addDirectory(FIRST_ASSET_NAME) api.addProject('bar') api.addSecret('baz') api.addSecret('quux') }, - }) - const searchBarInput = locateSearchBarInput(page) - - await searchBarInput.click() - await page.press('body', 'ArrowDown') - await expect(searchBarInput).toHaveValue('name:' + firstName) - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - await expect(searchBarInput).toHaveValue('') - await page.press('body', 'ArrowDown') - await expect(searchBarInput).toHaveValue('name:' + firstName) -}) + }).withSearchBar(async (searchBar) => { + await searchBar.click() + await page.press('body', 'ArrowDown') + await expect(searchBar).toHaveValue('name:' + FIRST_ASSET_NAME) + await searchBar.selectText() + await searchBar.press('Backspace') + await expect(searchBar).toHaveValue('') + await page.press('body', 'ArrowDown') + await expect(searchBar).toHaveValue('name:' + FIRST_ASSET_NAME) + })) From e049e330ef7a85dcb91bd8a37ebdb02ca8b0f27c Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 11 Dec 2024 18:10:26 +1000 Subject: [PATCH 12/27] Basic conversion of E2E tests to new structure --- .../dashboard/actions/BaseActions.ts | 13 +- .../dashboard/actions/DrivePageActions.ts | 111 ++- .../dashboard/actions/StartModalActions.ts | 14 +- .../dashboard/{ => actions}/api.ts | 2 +- .../dashboard/actions/index.ts | 754 +----------------- .../{ => actions}/latestGithubReleases.json | 0 .../dashboard/assetPanel.spec.ts | 30 +- .../dashboard/assetSearchBar.spec.ts | 30 +- .../dashboard/assetsTableFeatures.spec.ts | 102 ++- .../integration-test/dashboard/copy.spec.ts | 31 +- .../dashboard/createAsset.spec.ts | 10 +- .../dashboard/driveView.spec.ts | 24 +- .../dashboard/editAssetName.spec.ts | 265 +++--- .../integration-test/dashboard/labels.spec.ts | 102 +-- .../dashboard/labelsPanel.spec.ts | 150 ++-- .../dashboard/loginLogout.spec.ts | 19 +- .../dashboard/organizationSettings.spec.ts | 2 +- .../dashboard/pageSwitcher.spec.ts | 16 +- .../dashboard/renameAsset.spec.ts | 61 -- .../integration-test/dashboard/sort.spec.ts | 260 +++--- .../dashboard/startModal.spec.ts | 22 +- 21 files changed, 783 insertions(+), 1235 deletions(-) rename app/gui/integration-test/dashboard/{ => actions}/api.ts (99%) rename app/gui/integration-test/dashboard/{ => actions}/latestGithubReleases.json (100%) delete mode 100644 app/gui/integration-test/dashboard/renameAsset.spec.ts diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 187cb848b236..631d5a20eebb 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -3,7 +3,14 @@ import { expect, test, type Locator, type Page } from '@playwright/test' import type { AutocompleteKeybind } from '#/utilities/inputBindings' -import { modModifier } from '.' +/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ +async function modModifier(page: Page) { + let userAgent = '' + await test.step('Detect browser OS', async () => { + userAgent = await page.evaluate(() => navigator.userAgent) + }) + return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' +} /** A callback that performs actions on a {@link Page}. */ export interface PageCallback { @@ -105,7 +112,7 @@ export default class BaseActions implements Promise { } /** - * Perform an action on the current page. This should generally be avoided in favor of using + * Perform an action. This should generally be avoided in favor of using * specific methods; this is more or less an escape hatch used ONLY when the methods do not * support desired functionality. */ @@ -118,7 +125,7 @@ export default class BaseActions implements Promise { ) } - /** Perform an action on the current page. */ + /** Perform an action. */ step(name: string, callback: PageCallback) { return this.do(() => test.step(name, () => callback(this.page, this.context))) } diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index aa7539006003..9c65f9b03d31 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -1,18 +1,7 @@ /** @file Actions for the "drive" page. */ -import { expect, type Locator, type Page } from 'playwright/test' - -import { - locateAssetPanel, - locateAssetsTable, - locateContextMenus, - locateCreateButton, - locateDriveView, - locateNewSecretIcon, - locateNonAssetRows, - locateSecretNameInput, - locateSecretValueInput, - TEXT, -} from '.' +import { expect, type Locator, type Page } from '@playwright/test' + +import { TEXT } from '.' import type { LocatorCallback } from './BaseActions' import { contextMenuActions } from './contextMenuActions' import EditorPageActions from './EditorPageActions' @@ -23,11 +12,65 @@ import StartModalActions from './StartModalActions' const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } -/** Find all assets table rows (if any). */ +/** Find a set of context menus. */ +function locateContextMenus(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menus') +} + +/** Find a drive view. */ +function locateDriveView(page: Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} + +/** Find a "create" button. */ +function locateCreateButton(page: Page) { + return page.getByRole('button', { name: TEXT.create }).getByText(TEXT.create) +} + +/** Find an assets table. */ +function locateAssetsTable(page: Page) { + return page.getByTestId('drive-view').getByRole('table') +} + +/** Find all assets table rows. */ function locateAssetRows(page: Page) { return locateAssetsTable(page).getByTestId('asset-row') } +/** Find assets table placeholder rows. */ +function locateNonAssetRows(page: Page) { + return locateAssetsTable(page).locator('tbody tr:not([data-testid="asset-row"])') +} + +/** Find a "new secret" icon. */ +function locateNewSecretIcon(page: Page) { + return page.getByRole('button', { name: 'New Secret' }) +} + +/** Find an "upsert secret" modal. */ +function locateUpsertSecretModal(page: Page) { + // This has no identifying features. + return page.getByTestId('upsert-secret-modal') +} + +/** Find a "name" input for an "upsert secret" modal. */ +function locateSecretNameInput(page: Page) { + return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder) +} + +/** Find a "value" input for an "upsert secret" modal. */ +function locateSecretValueInput(page: Page) { + return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder) +} + +/** Find an asset panel. */ +function locateAssetPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('asset-panel').locator('visible=true') +} + /** Actions for the "drive" page. */ export default class DrivePageActions extends PageActions { /** Actions for navigating to another page. */ @@ -90,20 +133,39 @@ export default class DrivePageActions extends PageActions { get driveTable() { // eslint-disable-next-line @typescript-eslint/no-this-alias const self: DrivePageActions = this + const locateNameColumnHeading = (page: Page) => + page + .getByLabel(TEXT.sortByName) + .or(page.getByLabel(TEXT.sortByNameDescending)) + .or(page.getByLabel(TEXT.stopSortingByName)) + const locateModifiedColumnHeading = (page: Page) => + page + .getByLabel(TEXT.sortByModificationDate) + .or(page.getByLabel(TEXT.sortByModificationDateDescending)) + .or(page.getByLabel(TEXT.stopSortingByModificationDate)) return { /** Click the column heading for the "name" column to change its sort order. */ clickNameColumnHeading() { return self.step('Click "name" column heading', (page) => - page.getByLabel(TEXT.sortByName).or(page.getByLabel(TEXT.stopSortingByName)).click(), + locateNameColumnHeading(page).click(), + ) + }, + /** Interact with the column heading for the "name" column. */ + withNameColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "name" column heading', (page) => + callback(locateNameColumnHeading(page)), ) }, /** Click the column heading for the "modified" column to change its sort order. */ clickModifiedColumnHeading() { return self.step('Click "modified" column heading', (page) => - page - .getByLabel(TEXT.sortByModificationDate) - .or(page.getByLabel(TEXT.stopSortingByModificationDate)) - .click(), + locateModifiedColumnHeading(page).click(), + ) + }, + /** Interact with the column heading for the "modified" column. */ + withModifiedColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "modified" column heading', (page) => + callback(locateNameColumnHeading(page)), ) }, /** Click to select a specific row. */ @@ -135,10 +197,11 @@ export default class DrivePageActions extends PageActions { assetRows: Locator, nonAssetRows: Locator, context: Context, + page: Page, ) => Promise | void, ) { return self.step('Interact with drive table rows', async (page) => { - await callback(locateAssetRows(page), locateNonAssetRows(page), self.context) + await callback(locateAssetRows(page), locateNonAssetRows(page), self.context, page) }) }, /** Drag a row onto another row. */ @@ -349,9 +412,11 @@ export default class DrivePageActions extends PageActions { } /** Interact with the container element of the assets table. */ - withAssetsTable(callback: LocatorCallback) { + withAssetsTable( + callback: (input: Locator, context: Context, page: Page) => Promise | void, + ) { return this.step('Interact with drive table', async (page) => { - await callback(locateAssetsTable(page)) + await callback(locateAssetsTable(page), this.context, page) }) } diff --git a/app/gui/integration-test/dashboard/actions/StartModalActions.ts b/app/gui/integration-test/dashboard/actions/StartModalActions.ts index 16d7f0eb1bdf..cc680629b5aa 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -1,9 +1,21 @@ /** @file Actions for the "home" page. */ -import { locateSamples } from '.' +import type { Page } from '@playwright/test' import BaseActions from './BaseActions' import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' +/** Find a samples list. */ +function locateSamplesList(page: Page) { + // This has no identifying features. + return page.getByTestId('samples') +} + +/** Find all samples list. */ +function locateSamples(page: Page) { + // This has no identifying features. + return locateSamplesList(page).getByRole('button') +} + /** Actions for the "start" modal. */ export default class StartModalActions extends BaseActions { /** Close this modal and go back to the Drive page. */ diff --git a/app/gui/integration-test/dashboard/api.ts b/app/gui/integration-test/dashboard/actions/api.ts similarity index 99% rename from app/gui/integration-test/dashboard/api.ts rename to app/gui/integration-test/dashboard/actions/api.ts index b0f851036b06..53bcf2cc3457 100644 --- a/app/gui/integration-test/dashboard/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -10,7 +10,7 @@ import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' import * as uniqueString from 'enso-common/src/utilities/uniqueString' -import * as actions from './actions' +import * as actions from '.' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 1e841f5b5b36..79e8713a6a37 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -1,14 +1,14 @@ /** @file Various actions, locators, and constants used in end-to-end tests. */ -import { expect, test, type Locator, type Page } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { TEXTS } from 'enso-common/src/text' -import { EULA_JSON, PRIVACY_JSON, mockApi, type MockApi, type SetupAPI } from '../api' -import LATEST_GITHUB_RELEASES from '../latestGithubReleases.json' with { type: 'json' } +import { EULA_JSON, PRIVACY_JSON, mockApi, type MockApi, type SetupAPI } from './api' import DrivePageActions from './DrivePageActions' +import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } import LoginPageActions from './LoginPageActions' -export { mockApi } from '../api' +export { mockApi } from './api' /** An example password that does not meet validation requirements. */ export const INVALID_PASSWORD = 'password' @@ -18,649 +18,6 @@ export const VALID_PASSWORD = 'Password0!' export const VALID_EMAIL = 'email@example.com' export const TEXT = TEXTS.english -// === Input locators === - -/** Find an email input (if any) on the current page. */ -export function locateEmailInput(page: Locator | Page) { - return page.getByPlaceholder('Enter your email') -} - -/** Find a password input (if any) on the current page. */ -export function locatePasswordInput(page: Locator | Page) { - return page.getByPlaceholder('Enter your password') -} - -/** Find a "confirm password" input (if any) on the current page. */ -export function locateConfirmPasswordInput(page: Locator | Page) { - return page.getByPlaceholder('Confirm your password') -} - -/** Find a "name" input for a "new label" modal (if any) on the current page. */ -export function locateNewLabelModalNameInput(page: Page) { - return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox')) -} - -/** Find all color radio button inputs for a "new label" modal (if any) on the current page. */ -export function locateNewLabelModalColorButtons(page: Page) { - return ( - locateNewLabelModal(page) - .filter({ has: page.getByText('Color') }) - // The `radio` inputs are invisible, so they cannot be used in the locator. - .locator('label[data-rac]') - ) -} - -/** Find a "name" input for an "upsert secret" modal (if any) on the current page. */ -export function locateSecretNameInput(page: Page) { - return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder) -} - -/** Find a "value" input for an "upsert secret" modal (if any) on the current page. */ -export function locateSecretValueInput(page: Page) { - return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder) -} - -/** Find the name column of the given assets table row. */ -export function locateAssetRowName(locator: Locator) { - return locator.getByTestId('asset-row-name') -} - -// === Button locators === - -/** Find a "login" button (if any) on the current locator. */ -export function locateLoginButton(page: Locator | Page) { - return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') -} - -/** Find a "register" button (if any) on the current locator. */ -export function locateRegisterButton(page: Locator | Page) { - return page.getByRole('button', { name: 'Register' }).getByText('Register') -} - -/** Find a "create" button (if any) on the current page. */ -export function locateCreateButton(page: Locator | 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: Locator | Page) { - return page.getByLabel('Open in editor') -} - -/** Find a button to close the project (if any) on the current page. */ -export function locateStopProjectButton(page: Locator | Page) { - return page.getByLabel('Stop execution') -} - -/** Close a modal. */ -export function closeModal(page: Page) { - return test.step('Close modal', async () => { - await page.getByLabel('Close').click() - }) -} - -/** Find all labels in the labels panel (if any) on the current page. */ -export function locateLabelsPanelLabels(page: Page, name?: string) { - return ( - locateLabelsPanel(page) - .getByRole('button') - .filter(name != null ? { has: page.getByText(name) } : {}) - // The delete button is also a `button`. - .and(page.locator(':nth-child(1)')) - ) -} - -/** Find a tick button (if any) on the current page. */ -export function locateEditingTick(page: Locator | Page) { - return page.getByLabel('Confirm Edit') -} - -/** Find a cross button (if any) on the current page. */ -export function locateEditingCross(page: Locator | Page) { - return page.getByLabel('Cancel Edit') -} - -/** Find labels in the "Labels" column of the assets table (if any) on the current page. */ -export function locateAssetLabels(page: Locator | Page) { - return page.getByTestId('asset-label') -} - -/** Find a toggle for the "Name" column (if any) on the current page. */ -export function locateNameColumnToggle(page: Locator | Page) { - return page.getByLabel('Name') -} - -/** Find a toggle for the "Modified" column (if any) on the current page. */ -export function locateModifiedColumnToggle(page: Locator | Page) { - return page.getByLabel('Modified') -} - -/** Find a toggle for the "Shared with" column (if any) on the current page. */ -export function locateSharedWithColumnToggle(page: Locator | Page) { - return page.getByLabel('Shared With') -} - -/** Find a toggle for the "Labels" column (if any) on the current page. */ -export function locateLabelsColumnToggle(page: Locator | Page) { - return page.getByLabel('Labels') -} - -/** Find a toggle for the "Accessed by projects" column (if any) on the current page. */ -export function locateAccessedByProjectsColumnToggle(page: Locator | Page) { - return page.getByLabel('Accessed By Projects') -} - -/** Find a toggle for the "Accessed data" column (if any) on the current page. */ -export function locateAccessedDataColumnToggle(page: Locator | Page) { - return page.getByLabel('Accessed Data') -} - -/** Find a toggle for the "Docs" column (if any) on the current page. */ -export function locateDocsColumnToggle(page: Locator | Page) { - return page.getByLabel('Docs') -} - -/** Find a button for the "Recent" category (if any) on the current page. */ -export function locateRecentCategory(page: Locator | Page) { - return page.getByLabel('Recent').locator('visible=true') -} - -/** Find a button for the "Home" category (if any) on the current page. */ -export function locateHomeCategory(page: Locator | Page) { - return page.getByLabel('Home').locator('visible=true') -} - -/** Find a button for the "Trash" category (if any) on the current page. */ -export function locateTrashCategory(page: Locator | Page) { - return page.getByLabel('Trash').locator('visible=true') -} - -// === Other buttons === - -/** Find a "new label" button (if any) on the current page. */ -export function locateNewLabelButton(page: Locator | 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: Locator | Page) { - return page.getByRole('link', { name: 'Upgrade', exact: true }).getByText('Upgrade').first() -} - -/** Find a not enabled stub view (if any) on the current page. */ -export function locateNotEnabledStub(page: Locator | Page) { - return page.getByTestId('not-enabled-stub') -} - -/** Find a "new folder" icon (if any) on the current page. */ -export function locateNewFolderIcon(page: Locator | Page) { - return page.getByRole('button', { name: 'New Folder', exact: true }) -} - -/** Find a "new secret" icon (if any) on the current page. */ -export function locateNewSecretIcon(page: Locator | Page) { - return page.getByRole('button', { name: 'New Secret' }) -} - -/** Find a "download files" icon (if any) on the current page. */ -export function locateDownloadFilesIcon(page: Locator | Page) { - return page.getByRole('button', { name: 'Export' }) -} - -/** Find a list of tags in the search bar (if any) on the current page. */ -export function locateSearchBarTags(page: 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: 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: 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: Locator | Page) { - return page.getByAltText('Sort Ascending') -} - -/** Find a "sort descending" icon (if any) on the current page. */ -export function locateSortDescendingIcon(page: Locator | Page) { - return page.getByAltText('Sort Descending') -} - -// === Heading locators === - -/** Find a "name" column heading (if any) on the current page. */ -export function locateNameColumnHeading(page: Locator | Page) { - return page - .getByLabel('Sort by name') - .or(page.getByLabel('Stop sorting by name')) - .or(page.getByLabel('Sort by name descending')) -} - -/** Find a "modified" column heading (if any) on the current page. */ -export function locateModifiedColumnHeading(page: Locator | Page) { - return page - .getByLabel('Sort by modification date') - .or(page.getByLabel('Stop sorting by modification date')) - .or(page.getByLabel('Sort by modification date descending')) -} - -// === Container locators === - -/** Find a drive view (if any) on the current page. */ -export function locateDriveView(page: Locator | 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: Locator | Page) { - // This has no identifying features. - return page.getByTestId('samples') -} - -/** Find all samples list (if any) on the current page. */ -export function locateSamples(page: Locator | Page) { - // This has no identifying features. - return locateSamplesList(page).getByRole('button') -} - -/** Find an editor container (if any) on the current page. */ -export function locateEditor(page: Page) { - // Test ID of a placeholder editor component used during testing. - return page.locator('.App') -} - -/** Find an assets table (if any) on the current page. */ -export function locateAssetsTable(page: Page) { - return locateDriveView(page).getByRole('table') -} - -/** Find assets table rows (if any) on the current page. */ -export function locateAssetRows(page: Page) { - return locateAssetsTable(page).getByTestId('asset-row') -} - -/** Find assets table placeholder rows (if any) on the current page. */ -export function locateNonAssetRows(page: Page) { - return locateAssetsTable(page).locator('tbody tr:not([data-testid="asset-row"])') -} - -/** Find the name column of the given asset row. */ -export function locateAssetName(locator: 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: Page) { - // The icon is hidden when not hovered so `getByLabel` will not work. - return locateAssetRows(page).filter({ has: page.locator('[aria-label=Expand]') }) -} - -/** - * Find assets table rows that represent directories that can be collapsed (if any) - * on the current page. - */ -export function locateCollapsibleDirectories(page: Page) { - // The icon is hidden when not hovered so `getByLabel` will not work. - return locateAssetRows(page).filter({ has: page.locator('[aria-label=Collapse]') }) -} - -/** Find a "new label" modal (if any) on the current page. */ -export function locateNewLabelModal(page: 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: 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: Page) { - return page.getByLabel(TEXT.userMenuLabel).and(page.getByRole('button')).locator('visible=true') -} - -/** Find a "set username" panel (if any) on the current page. */ -export function locateSetUsernamePanel(page: 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: 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: 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: 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: Page) { - // This has no identifying features. - return page.getByTestId('asset-panel').locator('visible=true') -} - -/** Find a search bar (if any) on the current page. */ -export function locateSearchBar(page: Page) { - // This has no identifying features. - return page.getByTestId('asset-search-bar') -} - -/** Find an extra columns button panel (if any) on the current page. */ -export function locateExtraColumns(page: Page) { - // This has no identifying features. - return page.getByTestId('extra-columns') -} - -/** - * Find a root directory dropzone (if any) on the current page. - * This is the empty space below the assets table, if it doesn't take up the whole screen - * vertically. - */ -export function locateRootDirectoryDropzone(page: Page) { - // This has no identifying features. - return page.getByTestId('root-directory-dropzone') -} - -// === Content locators === - -/** Find an asset description in an asset panel (if any) on the current page. */ -export function locateAssetPanelDescription(page: 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: Page) { - // This has no identifying features. - return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button') -} - -export namespace settings { - export namespace tab { - export namespace organization { - /** Find an "organization" tab button. */ - export function locate(page: Page) { - return page.getByRole('button', { name: 'Organization' }).getByText('Organization') - } - } - export namespace members { - /** Find a "members" tab button. */ - export function locate(page: Page) { - return page.getByRole('button', { name: 'Members', exact: true }).getByText('Members') - } - } - } - - export namespace userAccount { - /** Navigate so that the "user account" settings section is visible. */ - export async function go(page: Page) { - await test.step('Go to "user account" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }) - } - - /** Find a "user account" settings section. */ - export function locate(page: Page) { - return page.getByRole('heading').and(page.getByText('User Account')).locator('..') - } - - /** Find a "name" input in the "user account" settings section. */ - export function locateNameInput(page: Page) { - return locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox') - } - } - - export namespace changePassword { - /** Navigate so that the "change password" settings section is visible. */ - export async function go(page: Page) { - await test.step('Go to "change password" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }) - } - - /** Find a "change password" settings section. */ - export function locate(page: Page) { - return page.getByRole('heading').and(page.getByText('Change Password')).locator('..') - } - - /** Find a "current password" input in the "user account" settings section. */ - export function locateCurrentPasswordInput(page: Page) { - return locate(page).getByRole('group', { name: 'Current password' }).getByRole('textbox') - } - - /** Find a "new password" input in the "user account" settings section. */ - export function locateNewPasswordInput(page: Page) { - return locate(page) - .getByRole('group', { name: /^New password/, exact: true }) - .getByRole('textbox') - } - - /** Find a "confirm new password" input in the "user account" settings section. */ - export function locateConfirmNewPasswordInput(page: Page) { - return locate(page) - .getByRole('group', { name: /^Confirm new password/, exact: true }) - .getByRole('textbox') - } - - /** Find a "save" button. */ - export function locateSaveButton(page: Page) { - return locate(page).getByRole('button', { name: 'Save' }).getByText('Save') - } - } - - export namespace profilePicture { - /** Navigate so that the "profile picture" settings section is visible. */ - export async function go(page: Page) { - await test.step('Go to "profile picture" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - }) - } - - /** Find a "profile picture" settings section. */ - export function locate(page: Page) { - return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') - } - - /** Find a "profile picture" input. */ - export function locateInput(page: Page) { - return locate(page).locator('label') - } - } - - export namespace organization { - /** Navigate so that the "organization" settings section is visible. */ - export async function go(page: Page) { - await test.step('Go to "organization" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - await settings.tab.organization.locate(page).click() - }) - } - - /** Find an "organization" settings section. */ - export function locate(page: Page) { - return page.getByRole('heading').and(page.getByText('Organization')).locator('..') - } - - /** Find a "name" input in the "organization" settings section. */ - export function locateNameInput(page: Page) { - return locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox') - } - - /** Find an "email" input in the "organization" settings section. */ - export function locateEmailInput(page: Page) { - return locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox') - } - - /** Find an "website" input in the "organization" settings section. */ - export function locateWebsiteInput(page: Page) { - return locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox') - } - - /** Find an "location" input in the "organization" settings section. */ - export function locateLocationInput(page: Page) { - return locate(page).getByLabel(TEXT.organizationLocationSettingsInput).getByRole('textbox') - } - } - - export namespace organizationProfilePicture { - /** Navigate so that the "organization profile picture" settings section is visible. */ - export async function go(page: Page) { - await test.step('Go to "organization profile picture" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - await settings.tab.organization.locate(page).click() - }) - } - - /** Find an "organization profile picture" settings section. */ - export function locate(page: Page) { - return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') - } - - /** Find a "profile picture" input. */ - export function locateInput(page: Page) { - return locate(page).locator('label') - } - } - - export namespace members { - /** Navigate so that the "members" settings section is visible. */ - export async function go(page: Page, force = false) { - await test.step('Go to "members" settings section', async () => { - await locateUserMenu(page).click() - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() - await settings.tab.members.locate(page).click({ force }) - }) - } - - /** Find a "members" settings section. */ - export function locate(page: Page) { - return page.getByRole('heading').and(page.getByText('Members')).locator('..') - } - - /** Find all rows representing members of the current organization. */ - export function locateMembersRows(page: Page) { - return locate(page).locator('tbody').getByRole('row') - } - } -} - -// =============================== -// === 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: Locator) { - return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) -} - -// =================================== -// === Expect functions for themes === -// =================================== - -/** A test assertion to confirm that the element has the class `selected`. */ -export async function expectClassSelected(locator: Locator) { - await test.step('Expect `selected`', async () => { - await expect(locator).toHaveClass(/(?:^| )selected(?: |$)/) - }) -} - -// ============================== -// === Other expect functions === -// ============================== - -/** A test assertion to confirm that the element is fully transparent. */ -export async function expectOpacity0(locator: Locator) { - await test.step('Expect `opacity: 0`', async () => { - await expect(async () => { - expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).toBe('0') - }).toPass() - }) -} - -/** A test assertion to confirm that the element is not fully transparent. */ -export async function expectNotOpacity0(locator: Locator) { - await test.step('Expect not `opacity: 0`', async () => { - await expect(async () => { - expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0') - }).toPass() - }) -} - -// ========================== -// === Keyboard utilities === -// ========================== - -/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ -export async function modModifier(page: Page) { - let userAgent = '' - await 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: Page, keyOrShortcut: string) { - await test.step(`Press '${keyOrShortcut}'`, async () => { - if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { - let userAgent = '' - await test.step('Detect browser OS', async () => { - userAgent = await page.evaluate(() => navigator.userAgent) - }) - const isMacOS = /\bMac OS\b/i.test(userAgent) - const ctrlKey = isMacOS ? 'Meta' : 'Control' - const deleteKey = isMacOS ? 'Backspace' : 'Delete' - const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey) - await page.keyboard.press(shortcut) - } else { - await page.keyboard.press(keyOrShortcut) - } - }) -} - -// =============================== -// === Miscellaneous utilities === -// =============================== - /** Perform a successful login. */ export async function login( { page, setupAPI }: MockParams, @@ -675,9 +32,9 @@ export async function login( return } - await locateEmailInput(page).fill(email) - await locatePasswordInput(page).fill(password) - await locateLoginButton(page).click() + await page.getByPlaceholder(TEXT.emailPlaceholder).fill(email) + await page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password) + await page.getByRole('button', { name: TEXT.login, exact: true }).getByText(TEXT.login).click() await expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() @@ -688,30 +45,6 @@ export async function login( }) } -/** Reload. */ -export async function reload({ page }: MockParams) { - await test.step('Reload', async () => { - await page.reload() - await expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() - }) -} - -/** Logout and then login again. */ -export async function relog( - { page, setupAPI }: MockParams, - email = 'email@example.com', - password = VALID_PASSWORD, -) { - await test.step('Relog', async () => { - await page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click() - await page - .getByRole('button', { name: TEXT.signOutShortcut }) - .getByText(TEXT.signOutShortcut) - .click() - await login({ page, setupAPI }, email, password, false) - }) -} - /** A placeholder date for visual regression testing. */ const MOCK_DATE = Number(new Date('01/23/45 01:23:45')) @@ -721,27 +54,6 @@ interface MockParams { readonly setupAPI?: SetupAPI | undefined } -/** Replace `Date` with a version that returns a fixed time. */ -async function mockDate({ page }: MockParams) { - // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 - await test.step('Mock Date', async () => { - 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; - }`) - }) -} - /** Pass the Agreements dialog. */ export async function passAgreementsDialog({ page }: MockParams) { await test.step('Accept Terms and Conditions', async () => { @@ -764,17 +76,16 @@ interface Context { /** Set up all mocks, without logging in. */ export function mockAll({ page, setupAPI }: MockParams) { - let api!: MockApi - return new LoginPageActions(page, { api }).step('Execute all mocks', async () => { + const context: { api: MockApi } = { api: undefined! } + return new LoginPageActions(page, context).step('Execute all mocks', async () => { await Promise.all([ - mockApi({ page, setupAPI }).then((theApi) => { - api = theApi + mockApi({ page, setupAPI }).then((api) => { + context.api = api }), mockDate({ page, setupAPI }), mockAllAnimations({ page }), mockUnneededUrls({ page }), ]) - await page.goto('/') }) } @@ -783,13 +94,32 @@ export function mockAll({ page, setupAPI }: MockParams) { export function mockAllAndLogin({ page, setupAPI }: MockParams) { return mockAll({ page, setupAPI }) .into(DrivePageActions) - .step('Login', async () => { - await login({ page, setupAPI }) - }) + .step('Login', () => login({ page, setupAPI })) +} + +/** Replace `Date` with a version that returns a fixed time. */ +async function mockDate({ page }: MockParams) { + // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 + await test.step('Mock Date', async () => { + 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; + }`) + }) } /** Mock all animations. */ -export async function mockAllAnimations({ page }: MockParams) { +async function mockAllAnimations({ page }: MockParams) { await page.addInitScript({ content: ` window.DISABLE_ANIMATIONS = true; @@ -801,7 +131,7 @@ export async function mockAllAnimations({ page }: MockParams) { } /** Mock unneeded URLs. */ -export async function mockUnneededUrls({ page }: MockParams) { +async function mockUnneededUrls({ page }: MockParams) { const eulaJsonBody = JSON.stringify(EULA_JSON) const privacyJsonBody = JSON.stringify(PRIVACY_JSON) @@ -883,17 +213,3 @@ export async function mockUnneededUrls({ page }: MockParams) { ]), ]) } - -/** - * Set up all mocks, and log in with dummy credentials. - * @deprecated Prefer {@link mockAllAndLogin}. - */ -export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams) { - return await test.step('Execute all mocks and login', async () => { - const api = await mockApi({ page, setupAPI }) - await mockDate({ page, setupAPI }) - await page.goto('/') - await login({ page, setupAPI }) - return api - }) -} diff --git a/app/gui/integration-test/dashboard/latestGithubReleases.json b/app/gui/integration-test/dashboard/actions/latestGithubReleases.json similarity index 100% rename from app/gui/integration-test/dashboard/latestGithubReleases.json rename to app/gui/integration-test/dashboard/actions/latestGithubReleases.json diff --git a/app/gui/integration-test/dashboard/assetPanel.spec.ts b/app/gui/integration-test/dashboard/assetPanel.spec.ts index 336f51c94c12..68e6ac95129a 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -1,11 +1,30 @@ /** @file Tests for the asset panel. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { EmailAddress, UserId } from '#/services/Backend' import { PermissionAction } from '#/utilities/permissions' -import { locateAssetPanelDescription, mockAllAndLogin } from './actions' +import { mockAllAndLogin } from './actions' + +/** Find an asset panel. */ +function locateAssetPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('asset-panel').locator('visible=true') +} + +/** Find an asset description in an asset panel. */ +function locateAssetPanelDescription(page: Page) { + // This has no identifying features. + return locateAssetPanel(page).getByTestId('asset-panel-description') +} + +/** Find asset permissions in an asset panel. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function locateAssetPanelPermissions(page: Page) { + // This has no identifying features. + return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button') +} /** An example description for the asset selected in the asset panel. */ const DESCRIPTION = 'foo bar' @@ -55,8 +74,8 @@ test('asset panel contents', ({ page }) => // await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() })) -test('Asset Panel Documentation view', ({ page }) => { - return mockAllAndLogin({ +test('Asset Panel Documentation view', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addProject('project', { description: DESCRIPTION }) @@ -68,5 +87,4 @@ test('Asset Panel Documentation view', ({ page }) => { await expect(assetPanel.getByTestId('asset-panel-tab-panel-docs')).toBeVisible() await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible() await expect(assetPanel.getByTestId('asset-docs-content')).toHaveText(/Project Goal/) - }) -}) + })) diff --git a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts index 51eaa0ce2136..adffb3a51b26 100644 --- a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts +++ b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts @@ -1,14 +1,30 @@ /** @file Test the search bar and its suggestions. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { COLORS } from '#/services/Backend' -import { - locateSearchBarLabels, - locateSearchBarSuggestions, - locateSearchBarTags, - mockAllAndLogin, -} from './actions' +import { mockAllAndLogin } from './actions' + +/** Find a search bar. */ +function locateSearchBar(page: Page) { + // This has no identifying features. + return page.getByTestId('asset-search-bar') +} + +/** Find a list of tags in the search bar. */ +function locateSearchBarTags(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-tag-names').getByRole('button') +} + +/** Find a list of labels in the search bar. */ +function locateSearchBarLabels(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-labels').getByRole('button') +} + +/** Find a list of labels in the search bar. */ +function locateSearchBarSuggestions(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-suggestion') +} const FIRST_ASSET_NAME = 'foo' diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index d16aead9b80a..c37e71303cc2 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -1,14 +1,32 @@ /** @file Test the drive view. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import { - getAssetRowLeftPx, - locateAssetsTable, - locateExtraColumns, - locateRootDirectoryDropzone, - mockAllAndLogin, - TEXT, -} from './actions' +import { mockAllAndLogin, TEXT } from './actions' + +/** Find an extra columns button panel. */ +function locateExtraColumns(page: Page) { + // This has no identifying features. + return page.getByTestId('extra-columns') +} + +/** + * 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. + */ +function getAssetRowLeftPx(locator: Locator) { + return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) +} + +/** + * Find a root directory dropzone. + * This is the empty space below the assets table, if it doesn't take up the whole screen + * vertically. + */ +function locateRootDirectoryDropzone(page: Page) { + // This has no identifying features. + return page.getByTestId('root-directory-dropzone') +} const PASS_TIMEOUT = 5_000 @@ -26,9 +44,8 @@ test('extra columns should stick to right side of assets table', ({ page }) => scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' }) }) }) - .do(async (thePage) => { + .withAssetsTable(async (assetsTable, _, thePage) => { const extraColumns = locateExtraColumns(thePage) - const assetsTable = locateAssetsTable(thePage) await expect(async () => { const extraColumnsRight = await extraColumns.evaluate( (element) => element.getBoundingClientRect().right, @@ -40,8 +57,8 @@ test('extra columns should stick to right side of assets table', ({ page }) => }).toPass({ timeout: PASS_TIMEOUT }) })) -test('extra columns should stick to top of scroll container', async ({ page }) => { - await mockAllAndLogin({ +test('extra columns should stick to top of scroll container', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { for (let i = 0; i < 100; i += 1) { @@ -49,36 +66,37 @@ test('extra columns should stick to top of scroll container', async ({ page }) = } }, }) - - await locateAssetsTable(page).evaluate((element) => { - let scrollableParent: HTMLElement | SVGElement | null = element - while ( - scrollableParent != null && - scrollableParent.scrollHeight <= scrollableParent.clientHeight - ) { - scrollableParent = scrollableParent.parentElement - } - scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' }) - }) - const extraColumns = locateExtraColumns(page) - const assetsTable = locateAssetsTable(page) - await expect(async () => { - const extraColumnsTop = await extraColumns.evaluate( - (element) => element.getBoundingClientRect().top, - ) - const assetsTableTop = await assetsTable.evaluate((element) => { - let scrollableParent: HTMLElement | SVGElement | null = element - while ( - scrollableParent != null && - scrollableParent.scrollHeight <= scrollableParent.clientHeight - ) { - scrollableParent = scrollableParent.parentElement - } - return scrollableParent?.getBoundingClientRect().top ?? 0 + .withAssetsTable(async (assetsTable) => { + await assetsTable.evaluate((element) => { + let scrollableParent: HTMLElement | SVGElement | null = element + while ( + scrollableParent != null && + scrollableParent.scrollHeight <= scrollableParent.clientHeight + ) { + scrollableParent = scrollableParent.parentElement + } + scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' }) + }) }) - expect(extraColumnsTop).toEqual(assetsTableTop + 2) - }).toPass({ timeout: PASS_TIMEOUT }) -}) + .withAssetsTable(async (assetsTable, _, thePage) => { + const extraColumns = locateExtraColumns(thePage) + await expect(async () => { + const extraColumnsTop = await extraColumns.evaluate( + (element) => element.getBoundingClientRect().top, + ) + const assetsTableTop = await assetsTable.evaluate((element) => { + let scrollableParent: HTMLElement | SVGElement | null = element + while ( + scrollableParent != null && + scrollableParent.scrollHeight <= scrollableParent.clientHeight + ) { + scrollableParent = scrollableParent.parentElement + } + return scrollableParent?.getBoundingClientRect().top ?? 0 + }) + expect(extraColumnsTop).toEqual(assetsTableTop + 2) + }).toPass({ timeout: PASS_TIMEOUT }) + })) test('can drop onto root directory dropzone', ({ page }) => mockAllAndLogin({ page }) diff --git a/app/gui/integration-test/dashboard/copy.spec.ts b/app/gui/integration-test/dashboard/copy.spec.ts index 021d7e338a35..b3eee0a967c0 100644 --- a/app/gui/integration-test/dashboard/copy.spec.ts +++ b/app/gui/integration-test/dashboard/copy.spec.ts @@ -1,12 +1,27 @@ /** @file Test copying, moving, cutting and pasting. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import { - getAssetRowLeftPx, - locateContextMenus, - locateTrashCategory, - mockAllAndLogin, -} from './actions' +import { mockAllAndLogin } from './actions' + +/** Find a set of context menus. */ +function locateContextMenus(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menus') +} + +/** Find a button for the "Trash" category. */ +function locateTrashCategory(page: Page) { + return page.getByLabel('Trash').locator('visible=true') +} + +/** + * 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. + */ +function getAssetRowLeftPx(locator: Locator) { + return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) +} test('copy', ({ page }) => mockAllAndLogin({ page }) @@ -125,7 +140,7 @@ test('move (keyboard)', ({ page }) => expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) })) -test('cut (keyboard)', async ({ page }) => +test('cut (keyboard)', ({ page }) => mockAllAndLogin({ page }) .createFolder() .driveTable.clickRow(0) diff --git a/app/gui/integration-test/dashboard/createAsset.spec.ts b/app/gui/integration-test/dashboard/createAsset.spec.ts index 42fe7269a4b3..d438bbe68ba1 100644 --- a/app/gui/integration-test/dashboard/createAsset.spec.ts +++ b/app/gui/integration-test/dashboard/createAsset.spec.ts @@ -1,7 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import { locateEditor, mockAllAndLogin } from './actions' +import { mockAllAndLogin } from './actions' /** The name of the uploaded file. */ const FILE_NAME = 'foo.txt' @@ -12,6 +12,12 @@ const SECRET_NAME = 'a secret name' /** The value of the created secret. */ const SECRET_VALUE = 'a secret value' +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} + test('create folder', ({ page }) => mockAllAndLogin({ page }) .createFolder() diff --git a/app/gui/integration-test/dashboard/driveView.spec.ts b/app/gui/integration-test/dashboard/driveView.spec.ts index a5874edbf4e3..658522f9f72a 100644 --- a/app/gui/integration-test/dashboard/driveView.spec.ts +++ b/app/gui/integration-test/dashboard/driveView.spec.ts @@ -1,12 +1,18 @@ /** @file Test the drive view. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import { - locateAssetsTable, - locateEditor, - locateStopProjectButton, - mockAllAndLogin, -} from './actions' +import { TEXT, mockAllAndLogin } from './actions' + +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} + +/** Find a button to close the project. */ +function locateStopProjectButton(page: Locator) { + return page.getByLabel(TEXT.stopExecution) +} test('drive view', ({ page }) => mockAllAndLogin({ page }) @@ -22,8 +28,8 @@ test('drive view', ({ page }) => .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(1) }) - .do(async () => { - await expect(locateAssetsTable(page)).toBeVisible() + .withAssetsTable(async (assetsTable) => { + await expect(assetsTable).toBeVisible() }) .newEmptyProject() .do(async () => { diff --git a/app/gui/integration-test/dashboard/editAssetName.spec.ts b/app/gui/integration-test/dashboard/editAssetName.spec.ts index d938e7907d92..68835afc2c3c 100644 --- a/app/gui/integration-test/dashboard/editAssetName.spec.ts +++ b/app/gui/integration-test/dashboard/editAssetName.spec.ts @@ -1,137 +1,134 @@ /** @file Test copying, moving, cutting and pasting. */ -import { test } from '@playwright/test' - -import { - locateAssetRowName, - locateAssetRows, - locateContextMenus, - locateEditingCross, - locateEditingTick, - locateNewFolderIcon, - mockAllAndLogin, - press, -} from './actions' - -test('edit name (double click)', async ({ page }) => { - await mockAllAndLogin({ page }) - const assetRows = locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' - - await locateNewFolderIcon(page).click() - await locateAssetRowName(row).click() - await locateAssetRowName(row).click() - await locateAssetRowName(row).fill(newName) - await locateEditingTick(row).click() - await test.expect(row).toHaveText(new RegExp('^' + newName)) -}) - -test('edit name (context menu)', async ({ page }) => { - await mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addAsset(api.createDirectory('foo')) - }, - }) - - const assetRows = locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' - - await locateAssetRowName(row).click({ button: 'right' }) - await locateContextMenus(page) - .getByText(/Rename/) - .click() - - const input = page.getByTestId('asset-row-name') - - await test.expect(input).toBeVisible() - await test.expect(input).toBeFocused() - - await input.fill(newName) - - await test.expect(input).toHaveValue(newName) - - await input.press('Enter') - - await test.expect(row).toHaveText(new RegExp('^' + newName)) -}) - -test('edit name (keyboard)', async ({ page }) => { - await mockAllAndLogin({ page }) - - const assetRows = locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz quux' - - await locateNewFolderIcon(page).click() - await locateAssetRowName(row).click() - await press(page, 'Mod+R') - await locateAssetRowName(row).fill(newName) - await locateAssetRowName(row).press('Enter') - await test.expect(row).toHaveText(new RegExp('^' + newName)) +import { test, type Locator, type Page } from '@playwright/test' + +import { TEXT, mockAllAndLogin } from './actions' + +const NEW_NAME = 'foo bar baz' +const NEW_NAME_2 = 'foo bar baz quux' + +/** Find a set of context menus. */ +function locateContextMenus(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menus') +} + +/** Find the name column of the given assets table row. */ +function locateAssetRowName(locator: Locator) { + return locator.getByTestId('asset-row-name') +} + +/** Find a tick button. */ +function locateEditingTick(page: Locator) { + return page.getByLabel(TEXT.confirmEdit) +} + +/** Find a cross button. */ +function locateEditingCross(page: Locator) { + return page.getByLabel(TEXT.cancelEdit) +} + +test('edit name (double click)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + await nameEl.fill(NEW_NAME) + await locateEditingTick(row).click() + await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + })) + +test('edit name (context menu)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click({ button: 'right' }) + await locateContextMenus(page) + .getByText(/Rename/) + .click() + const nameEl = locateAssetRowName(row) + await test.expect(nameEl).toBeVisible() + await test.expect(nameEl).toBeFocused() + await nameEl.fill(NEW_NAME) + await test.expect(nameEl).toHaveValue(NEW_NAME) + await nameEl.press('Enter') + await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + })) + +test('edit name (keyboard)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + await locateAssetRowName(rows.nth(0)).click() + }) + .press('Mod+R') + .driveTable.withRows(async (rows) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + await nameEl.fill(NEW_NAME_2) + await nameEl.press('Enter') + await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME_2)) + })) + +test('cancel editing name (double click)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + const oldName = (await nameEl.textContent()) ?? '' + await nameEl.click() + await nameEl.click() + await nameEl.fill(NEW_NAME) + await locateEditingCross(row).click() + await test.expect(row).toHaveText(new RegExp('^' + oldName)) + })) + +test('cancel editing name (keyboard)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .press('Mod+R') + .driveTable.withRows(async (rows) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + const oldName = (await nameEl.textContent()) ?? '' + await nameEl.click() + await nameEl.fill(NEW_NAME_2) + await nameEl.press('Escape') + await test.expect(row).toHaveText(new RegExp('^' + oldName)) + })) + +test('change to blank name (double click)', ({ page }) => { + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + const oldName = (await nameEl.textContent()) ?? '' + await nameEl.click() + await nameEl.click() + await nameEl.fill('') + await test.expect(locateEditingTick(row)).not.toBeVisible() + await locateEditingCross(row).click() + await test.expect(row).toHaveText(new RegExp('^' + oldName)) + }) }) -test('cancel editing name (double click)', async ({ page }) => { - await mockAllAndLogin({ page }) - - const assetRows = locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' - - await locateNewFolderIcon(page).click() - const oldName = (await locateAssetRowName(row).textContent()) ?? '' - await locateAssetRowName(row).click() - await locateAssetRowName(row).click() - - await locateAssetRowName(row).fill(newName) - await locateEditingCross(row).click() - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) - -test('cancel editing name (keyboard)', async ({ page }) => { - await mockAllAndLogin({ page }) - - const assetRows = locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz quux' - - await locateNewFolderIcon(page).click() - const oldName = (await locateAssetRowName(row).textContent()) ?? '' - await locateAssetRowName(row).click() - await press(page, 'Mod+R') - await locateAssetRowName(row).fill(newName) - await locateAssetRowName(row).press('Escape') - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) - -test('change to blank name (double click)', async ({ page }) => { - await mockAllAndLogin({ page }) - - const assetRows = locateAssetRows(page) - const row = assetRows.nth(0) - - await locateNewFolderIcon(page).click() - const oldName = (await locateAssetRowName(row).textContent()) ?? '' - await locateAssetRowName(row).click() - await locateAssetRowName(row).click() - await locateAssetRowName(row).fill('') - await test.expect(locateEditingTick(row)).not.toBeVisible() - await locateEditingCross(row).click() - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) - -test('change to blank name (keyboard)', async ({ page }) => { - await mockAllAndLogin({ page }) - - const assetRows = locateAssetRows(page) - const row = assetRows.nth(0) - - await locateNewFolderIcon(page).click() - const oldName = (await locateAssetRowName(row).textContent()) ?? '' - await locateAssetRowName(row).click() - await press(page, 'Mod+R') - await locateAssetRowName(row).fill('') - await locateAssetRowName(row).press('Enter') - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) +test('change to blank name (keyboard)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async (rows) => { + await locateAssetRowName(rows.nth(0)).click() + }) + .press('Mod+R') + .driveTable.withRows(async (rows) => { + const row = rows.nth(0) + const nameEl = locateAssetRowName(row) + const oldName = (await nameEl.textContent()) ?? '' + await nameEl.fill('') + await nameEl.press('Enter') + await test.expect(row).toHaveText(new RegExp('^' + oldName)) + })) diff --git a/app/gui/integration-test/dashboard/labels.spec.ts b/app/gui/integration-test/dashboard/labels.spec.ts index 41b913da8e0b..63f35aecee57 100644 --- a/app/gui/integration-test/dashboard/labels.spec.ts +++ b/app/gui/integration-test/dashboard/labels.spec.ts @@ -1,29 +1,45 @@ /** @file Test dragging of labels. */ -import { expect, test, type Locator } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { COLORS } from '#/services/Backend' -import { - locateAssetLabels, - locateAssetRows, - locateLabelsPanelLabels, - mockAllAndLogin, - modModifier, -} from './actions' +import { mockAllAndLogin } from './actions' -export const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } +const LABEL = 'aaaa' +const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } /** Click an asset row. The center must not be clicked as that is the button for adding a label. */ -export async function clickAssetRow(assetRow: Locator) { +async function clickAssetRow(assetRow: Locator) { await assetRow.click({ position: ASSET_ROW_SAFE_POSITION }) } -test('drag labels onto single row', async ({ page }) => { - const label = 'aaaa' - return mockAllAndLogin({ +/** Find labels in the "Labels" column of the assets table. */ +function locateAssetLabels(page: Locator) { + return page.getByTestId('asset-label') +} + +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} + +/** Find all labels in the labels panel. */ +function locateLabelsPanelLabels(page: Page, name?: string) { + return ( + locateLabelsPanel(page) + .getByRole('button') + .filter(name != null ? { has: page.getByText(name) } : {}) + // The delete button is also a `button`. + .and(page.locator(':nth-child(1)')) + ) +} + +test('drag labels onto single row', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { - api.addLabel(label, COLORS[0]) + api.addLabel(LABEL, COLORS[0]) api.addLabel('bbbb', COLORS[1]) api.addLabel('cccc', COLORS[2]) api.addLabel('dddd', COLORS[3]) @@ -32,25 +48,21 @@ test('drag labels onto single row', async ({ page }) => { api.addFile('baz') api.addSecret('quux') }, - }).do(async () => { - const assetRows = locateAssetRows(page) - const labelEl = locateLabelsPanelLabels(page, label) - + }).driveTable.withRows(async (rows, _, _context, page) => { + const labelEl = locateLabelsPanelLabels(page, LABEL) await expect(labelEl).toBeVisible() - await labelEl.dragTo(assetRows.nth(1)) - await expect(locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible() - await expect(locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible() - await expect(locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible() - await expect(locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() - }) -}) + await labelEl.dragTo(rows.nth(1)) + await expect(locateAssetLabels(rows.nth(0)).getByText(LABEL)).not.toBeVisible() + await expect(locateAssetLabels(rows.nth(1)).getByText(LABEL)).toBeVisible() + await expect(locateAssetLabels(rows.nth(2)).getByText(LABEL)).not.toBeVisible() + await expect(locateAssetLabels(rows.nth(3)).getByText(LABEL)).not.toBeVisible() + })) -test('drag labels onto multiple rows', async ({ page }) => { - const label = 'aaaa' - await mockAllAndLogin({ +test('drag labels onto multiple rows', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { - api.addLabel(label, COLORS[0]) + api.addLabel(LABEL, COLORS[0]) api.addLabel('bbbb', COLORS[1]) api.addLabel('cccc', COLORS[2]) api.addLabel('dddd', COLORS[3]) @@ -60,19 +72,19 @@ test('drag labels onto multiple rows', async ({ page }) => { api.addSecret('quux') }, }) - - const assetRows = locateAssetRows(page) - const labelEl = locateLabelsPanelLabels(page, label) - - await page.keyboard.down(await modModifier(page)) - await expect(assetRows).toHaveCount(4) - await clickAssetRow(assetRows.nth(0)) - await clickAssetRow(assetRows.nth(2)) - await expect(labelEl).toBeVisible() - await labelEl.dragTo(assetRows.nth(2)) - await page.keyboard.up(await modModifier(page)) - await expect(locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible() - await expect(locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible() - await expect(locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible() - await expect(locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() -}) + .withModPressed((self) => + self.driveTable.withRows(async (rows, _, _context, page) => { + const labelEl = locateLabelsPanelLabels(page, LABEL) + await expect(rows).toHaveCount(4) + await clickAssetRow(rows.nth(0)) + await clickAssetRow(rows.nth(2)) + await expect(labelEl).toBeVisible() + await labelEl.dragTo(rows.nth(2)) + }), + ) + .driveTable.withRows(async (rows) => { + await expect(locateAssetLabels(rows.nth(0)).getByText(LABEL)).toBeVisible() + await expect(locateAssetLabels(rows.nth(1)).getByText(LABEL)).not.toBeVisible() + await expect(locateAssetLabels(rows.nth(2)).getByText(LABEL)).toBeVisible() + await expect(locateAssetLabels(rows.nth(3)).getByText(LABEL)).not.toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/labelsPanel.spec.ts b/app/gui/integration-test/dashboard/labelsPanel.spec.ts index 07d0060786c9..f38f2204974a 100644 --- a/app/gui/integration-test/dashboard/labelsPanel.spec.ts +++ b/app/gui/integration-test/dashboard/labelsPanel.spec.ts @@ -1,57 +1,95 @@ /** @file Test the labels sidebar panel. */ -import { expect, test } from '@playwright/test' - -import { - locateCreateButton, - locateLabelsPanel, - locateLabelsPanelLabels, - locateNewLabelButton, - locateNewLabelModal, - locateNewLabelModalColorButtons, - locateNewLabelModalNameInput, - mockAllAndLogin, - TEXT, -} from './actions' - -test.beforeEach(({ page }) => mockAllAndLogin({ page })) - -test('labels', async ({ page }) => { - // Empty labels panel - await expect(locateLabelsPanel(page)).toBeVisible() - - // "New Label" modal - await locateNewLabelButton(page).click() - await expect(locateNewLabelModal(page)).toBeVisible() - - // "New Label" modal with name set - await locateNewLabelModalNameInput(page).fill('New Label') - await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) - - await page.press('html', 'Escape') - - // "New 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 locateNewLabelButton(page).click() - expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4) - // `force: true` is required because the `label` needs to handle the click event, not the - // `button`. - await locateNewLabelModalColorButtons(page).nth(4).click({ force: true }) - await expect(locateNewLabelModal(page)).toBeVisible() - - // "New Label" modal with name and color set - await locateNewLabelModalNameInput(page).fill('New Label') - await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) - - // Labels panel with one entry - await locateCreateButton(locateNewLabelModal(page)).click() - await expect(locateLabelsPanel(page)).toBeVisible() - - // Empty labels panel again, after deleting the only entry - await locateLabelsPanelLabels(page).first().hover() - - const labelsPanel = locateLabelsPanel(page) - await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click() - await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() - expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) -}) +import { expect, test, type Locator, type Page } from '@playwright/test' + +import { mockAllAndLogin, TEXT } from './actions' + +/** Find a "new label" button. */ +function locateNewLabelButton(page: Page) { + return page.getByRole('button', { name: 'new label' }).getByText('new label') +} + +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} + +/** Find a "new label" modal. */ +function locateNewLabelModal(page: Page) { + // This has no identifying features. + return page.getByTestId('new-label-modal') +} + +/** Find a "name" input for a "new label" modal. */ +function locateNewLabelModalNameInput(page: Page) { + return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox')) +} + +/** Find all color radio button inputs for a "new label" modal. */ +function locateNewLabelModalColorButtons(page: Page) { + return ( + locateNewLabelModal(page) + .filter({ has: page.getByText('Color') }) + // The `radio` inputs are invisible, so they cannot be used in the locator. + .locator('label[data-rac]') + ) +} + +/** Find a "create" button. */ +function locateCreateButton(page: Locator) { + return page.getByRole('button', { name: TEXT.create }).getByText(TEXT.create) +} + +/** Find all labels in the labels panel. */ +function locateLabelsPanelLabels(page: Page, name?: string) { + return ( + locateLabelsPanel(page) + .getByRole('button') + .filter(name != null ? { has: page.getByText(name) } : {}) + // The delete button is also a `button`. + .and(page.locator(':nth-child(1)')) + ) +} + +test('labels', ({ page }) => + mockAllAndLogin({ page }) + .do(async (page) => { + // Empty labels panel + await expect(locateLabelsPanel(page)).toBeVisible() + + // "New Label" modal + await locateNewLabelButton(page).click() + await expect(locateNewLabelModal(page)).toBeVisible() + + // "New Label" modal with name set + await locateNewLabelModalNameInput(page).fill('New Label') + await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) + }) + .press('Escape') + .do(async (page) => { + // "New 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 locateNewLabelButton(page).click() + expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4) + // `force: true` is required because the `label` needs to handle the click event, not the + // `button`. + await locateNewLabelModalColorButtons(page).nth(4).click({ force: true }) + await expect(locateNewLabelModal(page)).toBeVisible() + + // "New Label" modal with name and color set + await locateNewLabelModalNameInput(page).fill('New Label') + await expect(locateNewLabelModal(page)).toHaveText(/^New Label/) + + // Labels panel with one entry + await locateCreateButton(locateNewLabelModal(page)).click() + await expect(locateLabelsPanel(page)).toBeVisible() + + // Empty labels panel again, after deleting the only entry + await locateLabelsPanelLabels(page).first().hover() + + const labelsPanel = locateLabelsPanel(page) + await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click() + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) + })) diff --git a/app/gui/integration-test/dashboard/loginLogout.spec.ts b/app/gui/integration-test/dashboard/loginLogout.spec.ts index 40d293af4158..2d6ff1cf1b0f 100644 --- a/app/gui/integration-test/dashboard/loginLogout.spec.ts +++ b/app/gui/integration-test/dashboard/loginLogout.spec.ts @@ -1,7 +1,18 @@ /** @file Test the login flow. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import { locateDriveView, locateLoginButton, mockAll } from './actions' +import { TEXT, mockAll } from './actions' + +/** Find a "login" button.on the current locator. */ +function locateLoginButton(page: Page) { + return page.getByRole('button', { name: TEXT.login, exact: true }).getByText(TEXT.login) +} + +/** Find a drive view. */ +function locateDriveView(page: Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} // Reset storage state for this file to avoid being authenticated test.use({ storageState: { cookies: [], origins: [] } }) @@ -9,8 +20,10 @@ test.use({ storageState: { cookies: [], origins: [] } }) test('login and logout', ({ page }) => mockAll({ page }) .login() + .withDriveView(async (driveView) => { + await expect(driveView).toBeVisible() + }) .do(async (thePage) => { - await expect(locateDriveView(thePage)).toBeVisible() await expect(locateLoginButton(thePage)).not.toBeVisible() }) .openUserMenu() diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index 9b75528e10bb..bf296b220375 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -13,7 +13,7 @@ const PROFILE_PICTURE_FILENAME = 'bar.jpeg' const PROFILE_PICTURE_CONTENT = 'organization profile picture' const PROFILE_PICTURE_MIMETYPE = 'image/jpeg' -test('organization settings', async ({ page }) => +test('organization settings', ({ page }) => mockAllAndLogin({ page, setupAPI: (api) => { diff --git a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts index 7b7816acfdbd..3e62ee7bac9e 100644 --- a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts +++ b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts @@ -1,7 +1,19 @@ /** @file Test the login flow. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import { locateDriveView, locateEditor, mockAllAndLogin } from './actions' +import { mockAllAndLogin } from './actions' + +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} + +/** Find a drive view. */ +function locateDriveView(page: Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} test('page switcher', ({ page }) => mockAllAndLogin({ page }) diff --git a/app/gui/integration-test/dashboard/renameAsset.spec.ts b/app/gui/integration-test/dashboard/renameAsset.spec.ts deleted file mode 100644 index d00d3a9c9bde..000000000000 --- a/app/gui/integration-test/dashboard/renameAsset.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** @file Test copying, moving, cutting and pasting. */ -import { expect, test } from '@playwright/test' - -import { locateAssetRowName, locateEditingTick, locateEditor, mockAllAndLogin } from './actions' - -/** The name of the uploaded file. */ -const FILE_NAME = 'foo.txt' -/** The contents of the uploaded file. */ -const FILE_CONTENTS = 'hello world' -/** The name of the created secret. */ -const SECRET_NAME = 'a secret name' -/** The value of the created secret. */ -const SECRET_VALUE = 'a secret value' -const NEW_NAME = 'some new name' - -test('rename folder', ({ page }) => - mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addDirectory('a directory') - }, - }) - .createFolder() - .driveTable.withRows(async (rows, _, { api }) => { - await expect(rows).toHaveCount(1) - const row = rows.nth(0) - await expect(row).toBeVisible() - await expect(row).toHaveText(/^a directory/) - await locateAssetRowName(row).click() - await locateAssetRowName(row).click() - const calls = api.trackCalls() - await locateAssetRowName(row).fill(NEW_NAME) - await locateEditingTick(row).click() - await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) - expect(calls.updateDirectory).toBeGreaterThan(0) - })) - -test('create project', ({ page }) => - mockAllAndLogin({ page }) - .newEmptyProject() - .do((thePage) => expect(locateEditor(thePage)).toBeAttached()) - .goToPage.drive() - .driveTable.withRows((rows) => expect(rows).toHaveCount(1))) - -test('upload file', ({ page }) => - mockAllAndLogin({ page }) - .uploadFile(FILE_NAME, FILE_CONTENTS) - .driveTable.withRows(async (rows) => { - await expect(rows).toHaveCount(1) - await expect(rows.nth(0)).toBeVisible() - await expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) - })) - -test('create secret', ({ page }) => - mockAllAndLogin({ page }) - .createSecret(SECRET_NAME, SECRET_VALUE) - .driveTable.withRows(async (rows) => { - await expect(rows).toHaveCount(1) - await expect(rows.nth(0)).toBeVisible() - await expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) - })) diff --git a/app/gui/integration-test/dashboard/sort.spec.ts b/app/gui/integration-test/dashboard/sort.spec.ts index d91fd6e52e18..1c65f0691d5b 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -1,26 +1,44 @@ /** @file Test sorting of assets columns. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator } from '@playwright/test' import { toRfc3339 } from '#/utilities/dateTime' -import { - expectNotOpacity0, - expectOpacity0, - locateAssetRows, - locateModifiedColumnHeading, - locateNameColumnHeading, - locateSortAscendingIcon, - locateSortDescendingIcon, - login, - mockAll, -} from './actions' +import { mockAllAndLogin } from './actions' + +/** A test assertion to confirm that the element is fully transparent. */ +async function expectOpacity0(locator: Locator) { + await test.step('Expect `opacity: 0`', async () => { + await expect(async () => { + expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).toBe('0') + }).toPass() + }) +} + +/** A test assertion to confirm that the element is not fully transparent. */ +async function expectNotOpacity0(locator: Locator) { + await test.step('Expect not `opacity: 0`', async () => { + await expect(async () => { + expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0') + }).toPass() + }) +} + +/** Find a "sort ascending" icon. */ +function locateSortAscendingIcon(page: Locator) { + return page.getByAltText('Sort Ascending') +} + +/** Find a "sort descending" icon. */ +function locateSortDescendingIcon(page: Locator) { + return page.getByAltText('Sort Descending') +} const START_DATE_EPOCH_MS = 1.7e12 /** The number of milliseconds in a minute. */ const MIN_MS = 60_000 -test('sort', async ({ page }) => { - await mockAll({ +test('sort', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { const date1 = toRfc3339(new Date(START_DATE_EPOCH_MS)) @@ -50,99 +68,121 @@ test('sort', async ({ page }) => { // d file }, }) - const assetRows = locateAssetRows(page) - const nameHeading = locateNameColumnHeading(page) - const modifiedHeading = locateModifiedColumnHeading(page) - await login({ page }) - - // By default, assets should be grouped by type. - // Assets in each group are ordered by insertion order. - await expectOpacity0(locateSortAscendingIcon(nameHeading)) - await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() - await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) - await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() - await expect(assetRows.nth(0)).toHaveText(/^a directory/) - await expect(assetRows.nth(1)).toHaveText(/^G directory/) - await expect(assetRows.nth(2)).toHaveText(/^C project/) - await expect(assetRows.nth(3)).toHaveText(/^b project/) - await expect(assetRows.nth(4)).toHaveText(/^d file/) - await expect(assetRows.nth(5)).toHaveText(/^e file/) - await expect(assetRows.nth(6)).toHaveText(/^H secret/) - await expect(assetRows.nth(7)).toHaveText(/^f secret/) - - // Sort by name ascending. - await nameHeading.click() - await expectNotOpacity0(locateSortAscendingIcon(nameHeading)) - await expect(assetRows.nth(0)).toHaveText(/^a directory/) - await expect(assetRows.nth(1)).toHaveText(/^b project/) - await expect(assetRows.nth(2)).toHaveText(/^C project/) - await expect(assetRows.nth(3)).toHaveText(/^d file/) - await expect(assetRows.nth(4)).toHaveText(/^e file/) - await expect(assetRows.nth(5)).toHaveText(/^f secret/) - await expect(assetRows.nth(6)).toHaveText(/^G directory/) - await expect(assetRows.nth(7)).toHaveText(/^H secret/) - - // Sort by name descending. - await nameHeading.click() - await expectNotOpacity0(locateSortDescendingIcon(nameHeading)) - await expect(assetRows.nth(0)).toHaveText(/^H secret/) - await expect(assetRows.nth(1)).toHaveText(/^G directory/) - await expect(assetRows.nth(2)).toHaveText(/^f secret/) - await expect(assetRows.nth(3)).toHaveText(/^e file/) - await expect(assetRows.nth(4)).toHaveText(/^d file/) - await expect(assetRows.nth(5)).toHaveText(/^C project/) - await expect(assetRows.nth(6)).toHaveText(/^b project/) - await expect(assetRows.nth(7)).toHaveText(/^a directory/) - - // Sorting should be unset. - await nameHeading.click() - await page.mouse.move(0, 0) - await expectOpacity0(locateSortAscendingIcon(nameHeading)) - await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() - await expect(assetRows.nth(0)).toHaveText(/^a directory/) - await expect(assetRows.nth(1)).toHaveText(/^G directory/) - await expect(assetRows.nth(2)).toHaveText(/^C project/) - await expect(assetRows.nth(3)).toHaveText(/^b project/) - await expect(assetRows.nth(4)).toHaveText(/^d file/) - await expect(assetRows.nth(5)).toHaveText(/^e file/) - await expect(assetRows.nth(6)).toHaveText(/^H secret/) - await expect(assetRows.nth(7)).toHaveText(/^f secret/) - - // Sort by date ascending. - await modifiedHeading.click() - await expectNotOpacity0(locateSortAscendingIcon(modifiedHeading)) - await expect(assetRows.nth(0)).toHaveText(/^b project/) - await expect(assetRows.nth(1)).toHaveText(/^H secret/) - await expect(assetRows.nth(2)).toHaveText(/^f secret/) - await expect(assetRows.nth(3)).toHaveText(/^a directory/) - await expect(assetRows.nth(4)).toHaveText(/^e file/) - await expect(assetRows.nth(5)).toHaveText(/^G directory/) - await expect(assetRows.nth(6)).toHaveText(/^C project/) - await expect(assetRows.nth(7)).toHaveText(/^d file/) - - // Sort by date descending. - await modifiedHeading.click() - await expectNotOpacity0(locateSortDescendingIcon(modifiedHeading)) - await expect(assetRows.nth(0)).toHaveText(/^d file/) - await expect(assetRows.nth(1)).toHaveText(/^C project/) - await expect(assetRows.nth(2)).toHaveText(/^G directory/) - await expect(assetRows.nth(3)).toHaveText(/^e file/) - await expect(assetRows.nth(4)).toHaveText(/^a directory/) - await expect(assetRows.nth(5)).toHaveText(/^f secret/) - await expect(assetRows.nth(6)).toHaveText(/^H secret/) - await expect(assetRows.nth(7)).toHaveText(/^b project/) - - // Sorting should be unset. - await modifiedHeading.click() - await page.mouse.move(0, 0) - await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) - await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() - await expect(assetRows.nth(0)).toHaveText(/^a directory/) - await expect(assetRows.nth(1)).toHaveText(/^G directory/) - await expect(assetRows.nth(2)).toHaveText(/^C project/) - await expect(assetRows.nth(3)).toHaveText(/^b project/) - await expect(assetRows.nth(4)).toHaveText(/^d file/) - await expect(assetRows.nth(5)).toHaveText(/^e file/) - await expect(assetRows.nth(6)).toHaveText(/^H secret/) - await expect(assetRows.nth(7)).toHaveText(/^f secret/) -}) + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectOpacity0(locateSortAscendingIcon(nameHeading)) + await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + }) + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) + await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + }) + .driveTable.withRows(async (rows) => { + // By default, assets should be grouped by type. + // Assets in each group are ordered by insertion order. + await expect(rows.nth(0)).toHaveText(/^a directory/) + await expect(rows.nth(1)).toHaveText(/^G directory/) + await expect(rows.nth(2)).toHaveText(/^C project/) + await expect(rows.nth(3)).toHaveText(/^b project/) + await expect(rows.nth(4)).toHaveText(/^d file/) + await expect(rows.nth(5)).toHaveText(/^e file/) + await expect(rows.nth(6)).toHaveText(/^H secret/) + await expect(rows.nth(7)).toHaveText(/^f secret/) + }) + // Sort by name ascending. + .driveTable.clickNameColumnHeading() + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectNotOpacity0(locateSortAscendingIcon(nameHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows.nth(0)).toHaveText(/^a directory/) + await expect(rows.nth(1)).toHaveText(/^b project/) + await expect(rows.nth(2)).toHaveText(/^C project/) + await expect(rows.nth(3)).toHaveText(/^d file/) + await expect(rows.nth(4)).toHaveText(/^e file/) + await expect(rows.nth(5)).toHaveText(/^f secret/) + await expect(rows.nth(6)).toHaveText(/^G directory/) + await expect(rows.nth(7)).toHaveText(/^H secret/) + }) + // Sort by name descending. + .driveTable.clickNameColumnHeading() + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectNotOpacity0(locateSortDescendingIcon(nameHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows.nth(0)).toHaveText(/^H secret/) + await expect(rows.nth(1)).toHaveText(/^G directory/) + await expect(rows.nth(2)).toHaveText(/^f secret/) + await expect(rows.nth(3)).toHaveText(/^e file/) + await expect(rows.nth(4)).toHaveText(/^d file/) + await expect(rows.nth(5)).toHaveText(/^C project/) + await expect(rows.nth(6)).toHaveText(/^b project/) + await expect(rows.nth(7)).toHaveText(/^a directory/) + }) + // Sorting should be unset. + .driveTable.clickNameColumnHeading() + .do(async (thePage) => { + await thePage.mouse.move(0, 0) + }) + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectOpacity0(locateSortAscendingIcon(nameHeading)) + await test.expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + }) + .driveTable.withRows(async (rows) => { + await expect(rows.nth(0)).toHaveText(/^a directory/) + await expect(rows.nth(1)).toHaveText(/^G directory/) + await expect(rows.nth(2)).toHaveText(/^C project/) + await expect(rows.nth(3)).toHaveText(/^b project/) + await expect(rows.nth(4)).toHaveText(/^d file/) + await expect(rows.nth(5)).toHaveText(/^e file/) + await expect(rows.nth(6)).toHaveText(/^H secret/) + await expect(rows.nth(7)).toHaveText(/^f secret/) + }) + // Sort by date ascending. + .driveTable.clickModifiedColumnHeading() + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectNotOpacity0(locateSortAscendingIcon(modifiedHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows.nth(0)).toHaveText(/^b project/) + await expect(rows.nth(1)).toHaveText(/^H secret/) + await expect(rows.nth(2)).toHaveText(/^f secret/) + await expect(rows.nth(3)).toHaveText(/^a directory/) + await expect(rows.nth(4)).toHaveText(/^e file/) + await expect(rows.nth(5)).toHaveText(/^G directory/) + await expect(rows.nth(6)).toHaveText(/^C project/) + await expect(rows.nth(7)).toHaveText(/^d file/) + }) + // Sort by date descending. + .driveTable.clickModifiedColumnHeading() + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectNotOpacity0(locateSortDescendingIcon(modifiedHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows.nth(0)).toHaveText(/^d file/) + await expect(rows.nth(1)).toHaveText(/^C project/) + await expect(rows.nth(2)).toHaveText(/^G directory/) + await expect(rows.nth(3)).toHaveText(/^e file/) + await expect(rows.nth(4)).toHaveText(/^a directory/) + await expect(rows.nth(5)).toHaveText(/^f secret/) + await expect(rows.nth(6)).toHaveText(/^H secret/) + await expect(rows.nth(7)).toHaveText(/^b project/) + }) + // Sorting should be unset. + .driveTable.clickModifiedColumnHeading() + .do(async (thePage) => { + await thePage.mouse.move(0, 0) + }) + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectOpacity0(locateSortAscendingIcon(modifiedHeading)) + await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible() + }) + .driveTable.withRows(async (rows) => { + await expect(rows.nth(0)).toHaveText(/^a directory/) + await expect(rows.nth(1)).toHaveText(/^G directory/) + await expect(rows.nth(2)).toHaveText(/^C project/) + await expect(rows.nth(3)).toHaveText(/^b project/) + await expect(rows.nth(4)).toHaveText(/^d file/) + await expect(rows.nth(5)).toHaveText(/^e file/) + await expect(rows.nth(6)).toHaveText(/^H secret/) + await expect(rows.nth(7)).toHaveText(/^f secret/) + })) diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index 5f96bd55e7e4..f0e20ea53b45 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -1,7 +1,25 @@ /** @file Test the "change password" modal. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import { locateEditor, locateSamples, mockAllAndLogin } from './actions' +import { mockAllAndLogin } from './actions' + +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} + +/** Find a samples list. */ +function locateSamplesList(page: Page) { + // This has no identifying features. + return page.getByTestId('samples') +} + +/** Find all samples list. */ +function locateSamples(page: Page) { + // This has no identifying features. + return locateSamplesList(page).getByRole('button') +} test('create project from template', ({ page }) => mockAllAndLogin({ page }) From 35387215da1fb7e67f6686d0d0564d5ed4c0e727 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 11 Dec 2024 18:18:01 +1000 Subject: [PATCH 13/27] Fix circular imports in integration tests --- .../dashboard/actions/BaseSettingsTabActions.ts | 11 +---------- .../dashboard/actions/SettingsAccountTabActions.ts | 11 +++++++---- .../actions/SettingsActivityLogTabActions.ts | 13 +++++++++---- .../actions/SettingsBillingAndPlansTabActions.ts | 13 +++++++++---- .../actions/SettingsKeyboardShortcutsTabActions.ts | 13 +++++++++---- .../dashboard/actions/SettingsLocalTabActions.ts | 11 +++++++---- .../dashboard/actions/SettingsMembersTabActions.ts | 11 +++++++---- .../actions/SettingsOrganizationTabActions.ts | 13 +++++++++---- .../actions/SettingsUserGroupsTabActions.ts | 11 +++++++---- 9 files changed, 65 insertions(+), 42 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts index 3fca365215b5..696959dfe633 100644 --- a/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts @@ -1,20 +1,11 @@ /** @file Actions for the "user" tab of the "settings" page. */ import { goToPageActions, type GoToPageActions } from './goToPageActions' -import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' import PageActions from './PageActions' /** Actions common to all settings pages. */ -export default class BaseSettingsTabActions< - CurrentTab extends keyof GoToSettingsTabActions, - Context, -> extends PageActions { +export default class BaseSettingsTabActions extends PageActions { /** Actions for navigating to another page. */ get goToPage(): Omit, 'settings'> { return goToPageActions(this.step.bind(this)) } - - /** Actions for navigating to another settings tab. */ - get goToSettingsTab(): Omit, CurrentTab> { - return goToSettingsTabActions(this.step.bind(this)) - } } diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts index a18864d2bf15..e9201bc8585d 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts @@ -2,12 +2,15 @@ import BaseSettingsTabActions from './BaseSettingsTabActions' import SettingsAccountFormActions from './SettingsAccountFormActions' import SettingsChangePasswordFormActions from './SettingsChangePasswordFormActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "account" tab of the "settings" page. */ -export default class SettingsAccountTabActions extends BaseSettingsTabActions< - 'account', - Context -> { +export default class SettingsAccountTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'account'> { + return goToSettingsTabActions(this.step.bind(this)) + } + /** Manipulate the "account" form. */ accountForm() { return this.into(SettingsAccountFormActions) diff --git a/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts index 95061fa83446..63665ffff627 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts @@ -1,8 +1,13 @@ /** @file Actions for the "activity log" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "activity log" tab of the "settings" page. */ -export default class SettingsActivityLogShortcutsTabActions extends BaseSettingsTabActions< - 'activityLog', - Context -> {} +export default class SettingsActivityLogShortcutsTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'activityLog'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts index 11d0f3d0877b..7a5e1b68d0ed 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts @@ -1,8 +1,13 @@ /** @file Actions for the "billing and plans" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "billing and plans" tab of the "settings" page. */ -export default class SettingsBillingAndPlansTabActions extends BaseSettingsTabActions< - 'billingAndPlans', - Context -> {} +export default class SettingsBillingAndPlansTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'billingAndPlans'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts index e4e8953d3740..efcf2c6d7b01 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts @@ -1,8 +1,13 @@ /** @file Actions for the "keyboard shortcuts" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "keyboard shortcuts" tab of the "settings" page. */ -export default class SettingsKeyboardShortcutsTabActions extends BaseSettingsTabActions< - 'keyboardShortcuts', - Context -> {} +export default class SettingsKeyboardShortcutsTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'keyboardShortcuts'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts index 2b2ca526bae7..c62afd835ccc 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts @@ -1,8 +1,11 @@ /** @file Actions for the "local" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "local" tab of the "settings" page. */ -export default class SettingsLocalTabActions extends BaseSettingsTabActions< - 'local', - Context -> {} +export default class SettingsLocalTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'local'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts index 7206f9f2b6e7..4145174927e8 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts @@ -1,8 +1,11 @@ /** @file Actions for the "members" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "members" tab of the "settings" page. */ -export default class SettingsMembersTabActions extends BaseSettingsTabActions< - 'members', - Context -> {} +export default class SettingsMembersTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'members'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts index d914198e6c6a..499c9a10f780 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts @@ -1,12 +1,17 @@ /** @file Actions for the "organization" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' import SettingsOrganizationFormActions from './SettingsOrganizationFormActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "organization" tab of the "settings" page. */ -export default class SettingsOrganizationTabActions extends BaseSettingsTabActions< - 'organization', - Context -> { +export default class SettingsOrganizationTabActions< + Context, +> extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'organization'> { + return goToSettingsTabActions(this.step.bind(this)) + } + /** Manipulate the "organization" form. */ organizationForm() { return this.into(SettingsOrganizationFormActions) diff --git a/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts index 60354ae84066..afa4ba2970c0 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts @@ -1,8 +1,11 @@ /** @file Actions for the "user groups" tab of the "settings" page. */ import BaseSettingsTabActions from './BaseSettingsTabActions' +import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions' /** Actions for the "user groups" tab of the "settings" page. */ -export default class SettingsUserGroupsTabActions extends BaseSettingsTabActions< - 'userGroups', - Context -> {} +export default class SettingsUserGroupsTabActions extends BaseSettingsTabActions { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'userGroups'> { + return goToSettingsTabActions(this.step.bind(this)) + } +} From 2e086f00bb978835ebaa1eaf339267d1f51f0da1 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 11 Dec 2024 18:53:17 +1000 Subject: [PATCH 14/27] Fix Playwright imports --- .../dashboard/actions/NewDataLinkModalActions.ts | 2 +- .../integration-test/dashboard/actions/SettingsFormActions.ts | 2 +- app/gui/integration-test/dashboard/actions/userMenuActions.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts index 6d2aed9a02f5..f071bfc54acd 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -1,5 +1,5 @@ /** @file Actions for a "new Data Link" modal. */ -import type { Page } from 'playwright/test' +import type { Page } from '@playwright/test' import { TEXT } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' diff --git a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts index 1d69a26a3108..4f5edaef3242 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts @@ -1,5 +1,5 @@ /** @file Actions for the "account" form in settings. */ -import type { Locator, Page } from 'playwright' +import type { Locator, Page } from '@playwright/test' import { TEXT } from '.' import type { BaseActionsClass } from './BaseActions' import PageActions from './PageActions' diff --git a/app/gui/integration-test/dashboard/actions/userMenuActions.ts b/app/gui/integration-test/dashboard/actions/userMenuActions.ts index 3e2b099ba223..87bd8ab394c3 100644 --- a/app/gui/integration-test/dashboard/actions/userMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/userMenuActions.ts @@ -1,5 +1,5 @@ /** @file Actions for the user menu. */ -import type { Download } from 'playwright/test' +import type { Download } from '@playwright/test' import type BaseActions from './BaseActions' import type { PageCallback } from './BaseActions' From 20af42c98e606af6cdfb2b07ff85ff67cf94d49d Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 11 Dec 2024 18:54:49 +1000 Subject: [PATCH 15/27] Formatting --- app/gui/integration-test/dashboard/actions/index.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 4630fe4aada5..b69a0e81d605 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -11,8 +11,6 @@ import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'j import LoginPageActions from './LoginPageActions' import StartModalActions from './StartModalActions' -export { mockApi } from './api' - /** An example password that does not meet validation requirements. */ export const INVALID_PASSWORD = 'password' /** An example password that meets validation requirements. */ @@ -162,9 +160,7 @@ export function mockAllAndLogin({ page, setupAPI }: MockParams) { .into(DrivePageActions) } -/** - * Mock all animations. - */ +/** Mock all animations. */ export async function mockAllAnimations({ page }: MockParams) { await test.step('Mock all animations', async () => { await page.addInitScript({ @@ -196,6 +192,7 @@ async function mockUnneededUrls({ page }: MockParams) { page.route('https://www.googletagmanager.com/gtag/js*', async (route) => { await route.fulfill({ contentType: 'text/javascript', body: 'export {};' }) }), + page.route('https://*.ingest.sentry.io/api/*/envelope/*', async (route) => { await route.fulfill() }), From 9b2d078a9f512520948053fa1fd0b80ab8b212e2 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 11 Dec 2024 18:56:18 +1000 Subject: [PATCH 16/27] Use `page` from `actions.step` --- app/gui/integration-test/dashboard/actions/index.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index b69a0e81d605..1b7d451ff04a 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -125,7 +125,7 @@ interface Context { export function mockAll({ page, setupAPI }: MockParams) { const context: { api: MockApi } = { api: undefined! } return new LoginPageActions(page, context) - .step('Execute all mocks', async () => { + .step('Execute all mocks', async (page) => { await Promise.all([ mockApi({ page, setupAPI }).then((api) => { context.api = api @@ -135,7 +135,7 @@ export function mockAll({ page, setupAPI }: MockParams) { mockUnneededUrls({ page }), ]) }) - .step('Navigate to the root page', async () => { + .step('Navigate to the root page', async (page) => { await page.goto('/') await waitForLoaded(page) }) @@ -145,12 +145,8 @@ export function mockAll({ page, setupAPI }: MockParams) { export function mockAllAndLogin({ page, setupAPI }: MockParams) { const actions = mockAll({ page, setupAPI }) return actions - .step('Login', async () => { - await login({ page }) - }) - .step('Wait for dashboard to load', async (page) => { - await waitForDashboardToLoad(page) - }) + .step('Login', (page) => login({ page })) + .step('Wait for dashboard to load', waitForDashboardToLoad) .step('Check if start modal is shown', async (page) => { // @ts-expect-error This is the only place in which the private member `.context` // should be accessed. From 8179f17d447c0248393d4e36d09fe5ea7f6793ff Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 11 Dec 2024 19:09:13 +1000 Subject: [PATCH 17/27] Fix broken integration tests --- app/gui/integration-test/dashboard/actions/BaseActions.ts | 1 + app/gui/integration-test/dashboard/actions/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 631d5a20eebb..8c14f8de5d1d 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -121,6 +121,7 @@ export default class BaseActions implements Promise { // same parameters as `BaseActions`. return new this.constructor( this.page, + this.context, this.then(() => callback(this.page, this.context)), ) } diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 1b7d451ff04a..531fe5afcfd0 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -153,11 +153,11 @@ export function mockAllAndLogin({ page, setupAPI }: MockParams) { const context = actions.context await new StartModalActions(page, context).close() }) - .into(DrivePageActions) + .into(DrivePageActions) } /** Mock all animations. */ -export async function mockAllAnimations({ page }: MockParams) { +async function mockAllAnimations({ page }: MockParams) { await test.step('Mock all animations', async () => { await page.addInitScript({ content: ` From abcb91b710fa5477769c651070ce37266f47019e Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 11 Dec 2024 19:32:01 +1000 Subject: [PATCH 18/27] Fix integration tests --- .../dashboard/actions/DrivePageActions.ts | 13 ++- .../actions/SettingsAccountTabActions.ts | 2 +- .../SettingsChangePasswordFormActions.ts | 2 +- .../SettingsOrganizationFormActions.ts | 4 +- .../actions/SettingsOrganizationTabActions.ts | 2 +- .../integration-test/dashboard/actions/api.ts | 16 +-- .../dashboard/actions/index.ts | 106 +++++++++--------- .../dashboard/actions/userMenuActions.ts | 21 +++- .../dashboard/assetPanel.spec.ts | 2 +- .../integration-test/dashboard/delete.spec.ts | 7 +- .../dashboard/editAssetName.spec.ts | 6 +- .../dashboard/loginLogout.spec.ts | 2 + .../dashboard/organizationSettings.spec.ts | 4 +- .../dashboard/startModal.spec.ts | 2 +- .../OrganizationProfilePictureInput.tsx | 5 +- .../layouts/Settings/ProfilePictureInput.tsx | 5 +- 16 files changed, 105 insertions(+), 94 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index fe404632e1bd..d0e3163ed252 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -125,7 +125,9 @@ export default class DrivePageActions extends PageActions { /** Interact with the assets search bar. */ withSearchBar(callback: LocatorCallback) { - callback(this.page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/)) + return this.step('Interact with search bar', (page) => + callback(page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/)), + ) } /** Actions specific to the Drive table. */ @@ -164,7 +166,7 @@ export default class DrivePageActions extends PageActions { /** Interact with the column heading for the "modified" column. */ withModifiedColumnHeading(callback: LocatorCallback) { return self.step('Interact with "modified" column heading', (page) => - callback(locateNameColumnHeading(page)), + callback(locateModifiedColumnHeading(page)), ) }, /** Click to select a specific row. */ @@ -299,6 +301,13 @@ export default class DrivePageActions extends PageActions { ).into(StartModalActions) } + /** Expect the "start" modal to be visible. */ + expectStartModal() { + return this.into(StartModalActions).withStartModal(async (startModal) => { + await expect(startModal).toBeVisible() + }) + } + /** Create a new empty project. */ newEmptyProject() { return this.step( diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts index e9201bc8585d..ce8237a97d73 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts @@ -29,7 +29,7 @@ export default class SettingsAccountTabActions extends BaseSettingsTabA ) { return this.step('Upload account profile picture', async (page) => { const fileChooserPromise = page.waitForEvent('filechooser') - await page.locator('label').click() + await page.getByTestId('user-profile-picture-input').click() const fileChooser = await fileChooserPromise await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }]) }) diff --git a/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts index 4ee400d0a047..1799f210b609 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts @@ -36,7 +36,7 @@ export default class SettingsChangePasswordFormActions extends Settings fillNewPassword(name: string) { return this.step("Fill 'new password' input of 'change password' form", (page) => this.locate(page) - .getByLabel(TEXT.userNewPasswordSettingsInput) + .getByLabel(new RegExp('^' + TEXT.userNewPasswordSettingsInput)) .getByRole('textbox') .fill(name), ) diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts index be8f076e465d..9d2e9455fae0 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts @@ -9,14 +9,14 @@ export default class SettingsOrganizationFormActions extends SettingsFo Context, typeof SettingsOrganizationTabActions > { - /** Create a {@link SettingsAccountFormActions}. */ + /** Create a {@link SettingsOrganizationFormActions}. */ constructor(...args: ConstructorParameters>) { super( SettingsOrganizationTabActions, (page) => page .getByRole('heading') - .and(page.getByText(TEXT.userAccountSettingsSection)) + .and(page.getByText(TEXT.organizationSettingsSection)) .locator('..'), ...args, ) diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts index 499c9a10f780..4fa95e58e5ec 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts @@ -25,7 +25,7 @@ export default class SettingsOrganizationTabActions< ) { return this.step('Upload organization profile picture', async (page) => { const fileChooserPromise = page.waitForEvent('filechooser') - await page.locator('label').click() + await page.getByTestId('organization-profile-picture-input').click() const fileChooser = await fileChooserPromise await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }]) }) diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index cad40e4ae555..9e642fabda1b 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -132,20 +132,6 @@ export interface MockApi extends Awaited> {} export const mockApi: (params: MockParams) => Promise = mockApiInternal -export const EULA_JSON = { - path: '/eula.md', - size: 9472, - modified: '2024-05-21T10:47:27.000Z', - hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', -} - -export const PRIVACY_JSON = { - path: '/privacy.md', - size: 1234, - modified: '2024-05-21T10:47:27.000Z', - hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', -} - /** Add route handlers for the mock API to a page. */ async function mockApiInternal({ page, setupAPI }: MockParams) { const defaultEmail = 'email@example.com' as backend.EmailAddress @@ -1145,7 +1131,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { if (!maybeId) return const projectId = backend.ProjectId(maybeId) called('getProjectContent', { projectId }) - const content = readFileSync(join(__dirname, './mock/enso-demo.main'), 'utf8') + const content = readFileSync(join(__dirname, '../mock/enso-demo.main'), 'utf8') return route.fulfill({ body: content, diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 531fe5afcfd0..8ac11fce0ea1 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -5,7 +5,7 @@ import { expect, test, type Page } from '@playwright/test' import { TEXTS } from 'enso-common/src/text' -import { EULA_JSON, PRIVACY_JSON, mockApi, type MockApi, type SetupAPI } from './api' +import { mockApi, type MockApi, type SetupAPI } from './api' import DrivePageActions from './DrivePageActions' import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } import LoginPageActions from './LoginPageActions' @@ -30,7 +30,7 @@ async function login({ page }: MockParams, email = 'email@example.com', password const authFile = getAuthFilePath() await waitForLoaded(page) - const isLoggedIn = (await page.$('[data-testid="before-auth-layout"]')) === null + const isLoggedIn = (await page.getByTestId('before-auth-layout').count()) === 0 if (isLoggedIn) { test.info().annotations.push({ @@ -61,7 +61,7 @@ async function login({ page }: MockParams, email = 'email@example.com', password async function waitForLoaded(page: Page) { await page.waitForLoadState() - await test.expect(page.locator('[data-testid="spinner"]')).toHaveCount(0) + await test.expect(page.getByTestId('spinner')).toHaveCount(0) await test.expect(page.getByTestId('loading-app-message')).not.toBeVisible({ timeout: 30_000 }) } @@ -172,8 +172,18 @@ async function mockAllAnimations({ page }: MockParams) { /** Mock unneeded URLs. */ async function mockUnneededUrls({ page }: MockParams) { - const eulaJsonBody = JSON.stringify(EULA_JSON) - const privacyJsonBody = JSON.stringify(PRIVACY_JSON) + const eulaJsonBody = JSON.stringify({ + path: '/eula.md', + size: 9472, + modified: '2024-05-21T10:47:27.000Z', + hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', + }) + const privacyJsonBody = JSON.stringify({ + path: '/privacy.md', + size: 1234, + modified: '2024-05-21T10:47:27.000Z', + hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8', + }) await test.step('Mock unneeded URLs', async () => { return Promise.all([ @@ -209,53 +219,45 @@ async function mockUnneededUrls({ page }: MockParams) { await route.fulfill({ contentType: 'text/css', body: '' }) }), - ...(process.env.MOCK_ALL_URLS === 'true' ? - [] - : [ - page.route( - 'https://api.github.com/repos/enso-org/enso/releases/latest', - async (route) => { - await route.fulfill({ json: LATEST_GITHUB_RELEASES }) - }, - ), - - page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => { - await route.fulfill({ - status: 302, - headers: { location: 'https://objects.githubusercontent.com/foo/bar' }, - }) - }), - - page.route('https://objects.githubusercontent.com/**', async (route) => { - await route.fulfill({ - status: 200, - headers: { - 'content-type': 'application/octet-stream', - 'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT', - etag: '"0x8DCAC053D058EA5"', - server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', - 'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000', - 'x-ms-version': '2020-10-02', - 'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT', - 'x-ms-lease-status': 'unlocked', - 'x-ms-lease-state': 'available', - 'x-ms-blob-type': 'BlockBlob', - 'content-disposition': - 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage', - 'x-ms-server-encrypted': 'true', - via: '1.1 varnish, 1.1 varnish', - 'accept-ranges': 'bytes', - age: '1217', - date: 'Mon, 29 Jul 2024 09:40:09 GMT', - 'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE', - 'x-cache': 'HIT, HIT', - 'x-cache-hits': '48, 0', - 'x-timer': 'S1722246008.269342,VS0,VE895', - 'content-length': '1030383958', - }, - }) - }), - ]), + page.route('https://api.github.com/repos/enso-org/enso/releases/latest', async (route) => { + await route.fulfill({ json: LATEST_GITHUB_RELEASES }) + }), + + page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => { + await route.fulfill({ + status: 302, + headers: { location: 'https://objects.githubusercontent.com/foo/bar' }, + }) + }), + + page.route('https://objects.githubusercontent.com/**', async (route) => { + await route.fulfill({ + status: 200, + headers: { + 'content-type': 'application/octet-stream', + 'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT', + etag: '"0x8DCAC053D058EA5"', + server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0', + 'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000', + 'x-ms-version': '2020-10-02', + 'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT', + 'x-ms-lease-status': 'unlocked', + 'x-ms-lease-state': 'available', + 'x-ms-blob-type': 'BlockBlob', + 'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage', + 'x-ms-server-encrypted': 'true', + via: '1.1 varnish, 1.1 varnish', + 'accept-ranges': 'bytes', + age: '1217', + date: 'Mon, 29 Jul 2024 09:40:09 GMT', + 'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE', + 'x-cache': 'HIT, HIT', + 'x-cache-hits': '48, 0', + 'x-timer': 'S1722246008.269342,VS0,VE895', + 'content-length': '1030383958', + }, + }) + }), ]) }) } diff --git a/app/gui/integration-test/dashboard/actions/userMenuActions.ts b/app/gui/integration-test/dashboard/actions/userMenuActions.ts index 87bd8ab394c3..7517cd3c2f34 100644 --- a/app/gui/integration-test/dashboard/actions/userMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/userMenuActions.ts @@ -1,6 +1,7 @@ /** @file Actions for the user menu. */ import type { Download } from '@playwright/test' +import { TEXT } from '.' import type BaseActions from './BaseActions' import type { PageCallback } from './BaseActions' import LoginPageActions from './LoginPageActions' @@ -22,20 +23,32 @@ export function userMenuActions, Context>( downloadApp: (callback: (download: Download) => Promise | void) => step('Download app (user menu)', async (page) => { const downloadPromise = page.waitForEvent('download') - await page.getByRole('button', { name: 'Download App' }).getByText('Download App').click() + await page + .getByRole('button', { name: TEXT.downloadAppShortcut }) + .getByText(TEXT.downloadAppShortcut) + .click() await callback(await downloadPromise) }), settings: () => step('Go to Settings (user menu)', async (page) => { - await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click() + await page + .getByRole('button', { name: TEXT.settingsShortcut }) + .getByText(TEXT.settingsShortcut) + .click() }).into(SettingsPageActions), logout: () => step('Logout (user menu)', (page) => - page.getByRole('button', { name: 'Logout' }).getByText('Logout').click(), + page + .getByRole('button', { name: TEXT.signOutShortcut }) + .getByText(TEXT.signOutShortcut) + .click(), ).into(LoginPageActions), goToLoginPage: () => step('Login (user menu)', (page) => - page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click(), + page + .getByRole('button', { name: TEXT.signInShortcut, exact: true }) + .getByText(TEXT.signInShortcut) + .click(), ).into(LoginPageActions), } } diff --git a/app/gui/integration-test/dashboard/assetPanel.spec.ts b/app/gui/integration-test/dashboard/assetPanel.spec.ts index 7c2428ea4b07..e4edb0c17307 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -74,7 +74,7 @@ test('asset panel contents', ({ page }) => // await expect(locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() })) -test('Asset Panel Documentation view', ({ page }) => +test('Asset Panel documentation view', ({ page }) => mockAllAndLogin({ page, setupAPI: (api) => { diff --git a/app/gui/integration-test/dashboard/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index 2cffc54e91ef..8a7f253cc760 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -20,7 +20,7 @@ test.test('delete and restore', ({ page }) => .contextMenu.restoreFromTrash() .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() - .openStartModal() + .expectStartModal() .withStartModal(async (startModal) => { await test.expect(startModal).toBeVisible() }) @@ -50,10 +50,7 @@ test.test('delete and restore (keyboard)', ({ page }) => .press('Mod+R') .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() - .openStartModal() - .withStartModal(async (startModal) => { - await test.expect(startModal).toBeVisible() - }) + .expectStartModal() .close() .driveTable.withRows(async (rows) => { await test.expect(rows).toHaveCount(1) diff --git a/app/gui/integration-test/dashboard/editAssetName.spec.ts b/app/gui/integration-test/dashboard/editAssetName.spec.ts index 68835afc2c3c..89c14f31363c 100644 --- a/app/gui/integration-test/dashboard/editAssetName.spec.ts +++ b/app/gui/integration-test/dashboard/editAssetName.spec.ts @@ -95,13 +95,12 @@ test('cancel editing name (keyboard)', ({ page }) => const row = rows.nth(0) const nameEl = locateAssetRowName(row) const oldName = (await nameEl.textContent()) ?? '' - await nameEl.click() await nameEl.fill(NEW_NAME_2) await nameEl.press('Escape') await test.expect(row).toHaveText(new RegExp('^' + oldName)) })) -test('change to blank name (double click)', ({ page }) => { +test('change to blank name (double click)', ({ page }) => mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { @@ -114,8 +113,7 @@ test('change to blank name (double click)', ({ page }) => { await test.expect(locateEditingTick(row)).not.toBeVisible() await locateEditingCross(row).click() await test.expect(row).toHaveText(new RegExp('^' + oldName)) - }) -}) + })) test('change to blank name (keyboard)', ({ page }) => mockAllAndLogin({ page }) diff --git a/app/gui/integration-test/dashboard/loginLogout.spec.ts b/app/gui/integration-test/dashboard/loginLogout.spec.ts index 2d6ff1cf1b0f..c4660ff68333 100644 --- a/app/gui/integration-test/dashboard/loginLogout.spec.ts +++ b/app/gui/integration-test/dashboard/loginLogout.spec.ts @@ -20,6 +20,8 @@ test.use({ storageState: { cookies: [], origins: [] } }) test('login and logout', ({ page }) => mockAll({ page }) .login() + .expectStartModal() + .close() .withDriveView(async (driveView) => { await expect(driveView).toBeVisible() }) diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index bf296b220375..6c3322e38188 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -22,15 +22,13 @@ test('organization settings', ({ page }) => }, }) .step('Verify initial organization state', (_, { api }) => { + expect(api.defaultUser.isOrganizationAdmin).toBe(true) expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) expect(api.currentOrganization()?.email).toBe(null) expect(api.currentOrganization()?.picture).toBe(null) expect(api.currentOrganization()?.website).toBe(null) expect(api.currentOrganization()?.address).toBe(null) }) - .do(async (page) => { - await expect(page.getByText('Logging in to Enso...')).not.toBeVisible() - }) .goToPage.settings() .goToSettingsTab.organization() .organizationForm() diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index ed51a3da9e01..b8beff422ce3 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -26,7 +26,7 @@ // test('create project from template', ({ page }) => // mockAllAndLogin({ page }) -// .openStartModal() +// .expectStartModal() // .createProjectFromTemplate(0) // .do(async (thePage) => { // await expect(locateEditor(thePage)).toBeAttached() diff --git a/app/gui/src/dashboard/layouts/Settings/OrganizationProfilePictureInput.tsx b/app/gui/src/dashboard/layouts/Settings/OrganizationProfilePictureInput.tsx index 996f98b94a27..d94d8a81407f 100644 --- a/app/gui/src/dashboard/layouts/Settings/OrganizationProfilePictureInput.tsx +++ b/app/gui/src/dashboard/layouts/Settings/OrganizationProfilePictureInput.tsx @@ -52,7 +52,10 @@ export default function OrganizationProfilePictureInput( return ( <> - + - + Date: Thu, 12 Dec 2024 16:56:32 +1000 Subject: [PATCH 19/27] Fix more integration tests --- app/gui/integration-test/dashboard/actions/index.ts | 7 +++++-- app/gui/integration-test/dashboard/auth.setup.ts | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 8ac11fce0ea1..1f96f1a5ee9e 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -225,8 +225,11 @@ async function mockUnneededUrls({ page }: MockParams) { page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => { await route.fulfill({ - status: 302, - headers: { location: 'https://objects.githubusercontent.com/foo/bar' }, + status: 200, + headers: { + 'content-type': 'text/html', + }, + body: '', }) }), diff --git a/app/gui/integration-test/dashboard/auth.setup.ts b/app/gui/integration-test/dashboard/auth.setup.ts index 0ac852e0b97e..1c2fca60f1f9 100644 --- a/app/gui/integration-test/dashboard/auth.setup.ts +++ b/app/gui/integration-test/dashboard/auth.setup.ts @@ -5,6 +5,7 @@ import { test as setup } from '@playwright/test' import { getAuthFilePath, mockAllAndLogin } from './actions' setup('authenticate', ({ page }) => { + setup.slow() const authFilePath = getAuthFilePath() setup.skip(fs.existsSync(authFilePath), 'Already authenticated') return mockAllAndLogin({ page }) From 9f9982a5358ed1a342334517f34bffcb94a4d025 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 17:17:53 +1000 Subject: [PATCH 20/27] Fix more integration tests --- .../integration-test/dashboard/actions/api.ts | 2 +- .../dashboard/editAssetName.spec.ts | 65 ++++++++++++------- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 9e642fabda1b..495df5d7d245 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -199,7 +199,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { usersMap.set(defaultUser.userId, defaultUser) function trackCalls() { - const calls = { ...INITIAL_CALLS_OBJECT } + const calls = structuredClone(INITIAL_CALLS_OBJECT) callsObjects.add(calls) return calls } diff --git a/app/gui/integration-test/dashboard/editAssetName.spec.ts b/app/gui/integration-test/dashboard/editAssetName.spec.ts index 1b6e6e63c55d..bca1059965fa 100644 --- a/app/gui/integration-test/dashboard/editAssetName.spec.ts +++ b/app/gui/integration-test/dashboard/editAssetName.spec.ts @@ -1,5 +1,5 @@ /** @file Test copying, moving, cutting and pasting. */ -import { test, type Locator, type Page } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { TEXT, mockAllAndLogin } from './actions' @@ -30,32 +30,36 @@ function locateEditingCross(page: Locator) { test('edit name (double click)', ({ page }) => mockAllAndLogin({ page }) .createFolder() - .driveTable.withRows(async (rows) => { + .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) await nameEl.click() await nameEl.click() await nameEl.fill(NEW_NAME) + const calls = api.trackCalls() await locateEditingTick(row).click() - await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME }]) })) test('edit name (context menu)', ({ page }) => mockAllAndLogin({ page }) .createFolder() - .driveTable.withRows(async (rows) => { + .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) await locateAssetRowName(row).click({ button: 'right' }) await locateContextMenu(page) .getByText(/Rename/) .click() const nameEl = locateAssetRowName(row) - await test.expect(nameEl).toBeVisible() - await test.expect(nameEl).toBeFocused() + await expect(nameEl).toBeVisible() + await expect(nameEl).toBeFocused() await nameEl.fill(NEW_NAME) - await test.expect(nameEl).toHaveValue(NEW_NAME) + await expect(nameEl).toHaveValue(NEW_NAME) + const calls = api.trackCalls() await nameEl.press('Enter') - await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME }]) })) test('edit name (keyboard)', ({ page }) => @@ -65,54 +69,67 @@ test('edit name (keyboard)', ({ page }) => await locateAssetRowName(rows.nth(0)).click() }) .press('Mod+R') - .driveTable.withRows(async (rows) => { + .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) await nameEl.fill(NEW_NAME_2) + const calls = api.trackCalls() await nameEl.press('Enter') - await test.expect(row).toHaveText(new RegExp('^' + NEW_NAME_2)) + await expect(row).toHaveText(new RegExp('^' + NEW_NAME_2)) + expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME_2 }]) })) test('cancel editing name (double click)', ({ page }) => mockAllAndLogin({ page }) .createFolder() - .driveTable.withRows(async (rows) => { + .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) const oldName = (await nameEl.textContent()) ?? '' await nameEl.click() await nameEl.click() await nameEl.fill(NEW_NAME) + const calls = api.trackCalls() await locateEditingCross(row).click() - await test.expect(row).toHaveText(new RegExp('^' + oldName)) + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) })) -test('cancel editing name (keyboard)', ({ page }) => - mockAllAndLogin({ page }) +test('cancel editing name (keyboard)', ({ page }) => { + let oldName = '' + return mockAllAndLogin({ page }) .createFolder() - .press('Mod+R') .driveTable.withRows(async (rows) => { + await rows.nth(0).click() + }) + .press('Mod+R') + .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) - const oldName = (await nameEl.textContent()) ?? '' + oldName = (await nameEl.textContent()) ?? '' await nameEl.fill(NEW_NAME_2) + const calls = api.trackCalls() await nameEl.press('Escape') - await test.expect(row).toHaveText(new RegExp('^' + oldName)) - })) + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + }) +}) test('change to blank name (double click)', ({ page }) => mockAllAndLogin({ page }) .createFolder() - .driveTable.withRows(async (rows) => { + .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) const oldName = (await nameEl.textContent()) ?? '' await nameEl.click() await nameEl.click() await nameEl.fill('') - await test.expect(locateEditingTick(row)).not.toBeVisible() + await expect(locateEditingTick(row)).not.toBeVisible() + const calls = api.trackCalls() await locateEditingCross(row).click() - await test.expect(row).toHaveText(new RegExp('^' + oldName)) + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) })) test('change to blank name (keyboard)', ({ page }) => @@ -122,11 +139,13 @@ test('change to blank name (keyboard)', ({ page }) => await locateAssetRowName(rows.nth(0)).click() }) .press('Mod+R') - .driveTable.withRows(async (rows) => { + .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) const oldName = (await nameEl.textContent()) ?? '' await nameEl.fill('') + const calls = api.trackCalls() await nameEl.press('Enter') - await test.expect(row).toHaveText(new RegExp('^' + oldName)) + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) })) From cea2aeb8268c81b8b433fdad6eaf476c993c6ce5 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 17:23:56 +1000 Subject: [PATCH 21/27] Fix test errors --- .../dashboard/contextMenus.spec.ts | 64 +++++++++++++------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/app/gui/integration-test/dashboard/contextMenus.spec.ts b/app/gui/integration-test/dashboard/contextMenus.spec.ts index 7d73010b5ee1..b752fb064ab2 100644 --- a/app/gui/integration-test/dashboard/contextMenus.spec.ts +++ b/app/gui/integration-test/dashboard/contextMenus.spec.ts @@ -1,38 +1,64 @@ /** @file Test the drive view. */ -import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { COLORS } from 'enso-common/src/services/Backend' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' const LABEL_NAME = 'aaaa' -test.test('drive view', ({ page }) => - actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addLabel(LABEL_NAME, COLORS[0]) - }, - }) +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} + +/** Find labels in the "Labels" column of the assets table. */ +function locateAssetLabels(page: Page) { + return page.getByTestId('asset-label') +} + +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} + +/** Find all labels in the labels panel. */ +function locateLabelsPanelLabels(page: Page, name?: string) { + return ( + locateLabelsPanel(page) + .getByRole('button') + .filter(name != null ? { has: page.getByText(name) } : {}) + // The delete button is also a `button`. + .and(page.locator(':nth-child(1)')) + ) +} + +test('drive view', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addLabel(LABEL_NAME, COLORS[0]) + }, + }) .driveTable.expectPlaceholderRow() .withDriveView(async (view) => { await view.click({ button: 'right' }) }) .do(async (thePage) => { - await test.expect(actions.locateContextMenu(thePage)).toHaveCount(1) + await expect(locateContextMenu(thePage)).toHaveCount(1) }) .press('Escape') .do(async (thePage) => { - await test.expect(actions.locateContextMenu(thePage)).toHaveCount(0) + await expect(locateContextMenu(thePage)).toHaveCount(0) }) .createFolder() - .driveTable.withRows(async (rows, _, thePage) => { - await actions.locateLabelsPanelLabels(page, LABEL_NAME).dragTo(rows.nth(0)) - await actions.locateAssetLabels(thePage).first().click({ button: 'right' }) - await test.expect(actions.locateContextMenu(thePage)).toHaveCount(1) + .driveTable.withRows(async (rows, _, _context, thePage) => { + await locateLabelsPanelLabels(thePage, LABEL_NAME).dragTo(rows.nth(0)) + await locateAssetLabels(thePage).first().click({ button: 'right' }) + await expect(locateContextMenu(thePage)).toHaveCount(1) }) .press('Escape') .do(async (thePage) => { - await test.expect(actions.locateContextMenu(thePage)).toHaveCount(0) - }), -) + await expect(locateContextMenu(thePage)).toHaveCount(0) + })) From a9f45ead4a4222c78a33591ab8072c7f7e25d437 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 18:24:00 +1000 Subject: [PATCH 22/27] Consistent formatting --- app/gui/integration-test/dashboard/README.md | 43 +++++++------------ .../dashboard/actions/BaseActions.ts | 4 +- .../dashboard/actions/DrivePageActions.ts | 36 ++++++++-------- .../actions/ForgotPasswordPageActions.ts | 6 +-- .../dashboard/actions/LoginPageActions.ts | 8 ++-- .../actions/NewDataLinkModalActions.ts | 6 +-- .../dashboard/actions/RegisterPageActions.ts | 6 +-- .../actions/SettingsAccountFormActions.ts | 11 +++++ .../SettingsOrganizationFormActions.ts | 41 ++++++++++++++++++ .../dashboard/actions/StartModalActions.ts | 6 +-- .../dashboard/actions/index.ts | 6 +-- .../dashboard/assetPanel.spec.ts | 2 +- .../integration-test/dashboard/delete.spec.ts | 26 ++++++----- .../dashboard/organizationSettings.spec.ts | 7 ++- .../integration-test/dashboard/sort.spec.ts | 2 +- 15 files changed, 127 insertions(+), 83 deletions(-) diff --git a/app/gui/integration-test/dashboard/README.md b/app/gui/integration-test/dashboard/README.md index 204492c82da6..9d70f8cc897d 100644 --- a/app/gui/integration-test/dashboard/README.md +++ b/app/gui/integration-test/dashboard/README.md @@ -3,51 +3,38 @@ ## Running tests Execute all commands from the parent directory. +Note that all options can be used in any combination. ```sh # Run tests normally -pnpm run test:integration +pnpm playwright test # Open UI to run tests -pnpm run test:integration:debug +pnpm playwright test --ui # Run tests in a specific file only -pnpm run test:integration -- integration-test/file-name-here.spec.ts -pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts +pnpm playwright test integration-test/dashboard/file-name-here.spec.ts # Compile the entire app before running the tests. # DOES NOT hot reload the tests. # Prefer not using this when you are trying to fix a test; # prefer using this when you just want to know which tests are failing (if any). -PROD=1 pnpm run test:integration -PROD=1 pnpm run test:integration:debug -PROD=1 pnpm run test:integration -- integration-test/file-name-here.spec.ts -PROD=1 pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts +PROD=true pnpm playwright test ``` ## Getting started ```ts -test.test('test name here', ({ page }) => - actions.mockAllAndLogin({ page }).then( - // ONLY chain methods from `pageActions`. - // Using methods not in `pageActions` is UNDEFINED BEHAVIOR. - // If it is absolutely necessary though, please remember to `await` the method chain. - // Note that the `async`/`await` pair is REQUIRED, as `Actions` subclasses are `PromiseLike`s, - // not `Promise`s, which causes Playwright to output a type error. - async ({ pageActions }) => await pageActions.goTo.drive(), - ), -) +// ONLY chain methods from `pageActions`. +// Using methods not in `pageActions` is UNDEFINED BEHAVIOR. +// If it is absolutely necessary though, please remember to `await` the method chain. +test('test name here', ({ page }) => mockAllAndLogin({ page }).goToPage.drive()) ``` ### Perform arbitrary actions (e.g. actions on the API) ```ts -test.test('test name here', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions, api }) => - await pageActions.do(() => { - api.foo() - api.bar() - test.expect(api.baz()?.quux).toEqual('bar') - }), - ), -) +test('test name here', ({ page }) => + mockAllAndLogin({ page }).do((_page, { api }) => { + api.foo() + api.bar() + expect(api.baz()?.quux).toEqual('bar') + })) ``` diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 8c14f8de5d1d..56cfa3562134 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -18,8 +18,8 @@ export interface PageCallback { } /** A callback that performs actions on a {@link Locator}. */ -export interface LocatorCallback { - (input: Locator): Promise | void +export interface LocatorCallback { + (input: Locator, context: Context): Promise | void } export interface BaseActionsClass { diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 7d24c8186f30..174bd9eb619e 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -124,9 +124,9 @@ export default class DrivePageActions extends PageActions { } /** Interact with the assets search bar. */ - withSearchBar(callback: LocatorCallback) { - return this.step('Interact with search bar', (page) => - callback(page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/)), + withSearchBar(callback: LocatorCallback) { + return this.step('Interact with search bar', (page, context) => + callback(page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/), context), ) } @@ -152,9 +152,9 @@ export default class DrivePageActions extends PageActions { ) }, /** Interact with the column heading for the "name" column. */ - withNameColumnHeading(callback: LocatorCallback) { - return self.step('Interact with "name" column heading', (page) => - callback(locateNameColumnHeading(page)), + withNameColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "name" column heading', (page, context) => + callback(locateNameColumnHeading(page), context), ) }, /** Click the column heading for the "modified" column to change its sort order. */ @@ -164,9 +164,9 @@ export default class DrivePageActions extends PageActions { ) }, /** Interact with the column heading for the "modified" column. */ - withModifiedColumnHeading(callback: LocatorCallback) { - return self.step('Interact with "modified" column heading', (page) => - callback(locateModifiedColumnHeading(page)), + withModifiedColumnHeading(callback: LocatorCallback) { + return self.step('Interact with "modified" column heading', (page, context) => + callback(locateModifiedColumnHeading(page), context), ) }, /** Click to select a specific row. */ @@ -319,8 +319,10 @@ export default class DrivePageActions extends PageActions { } /** Interact with the drive view (the main container of this page). */ - withDriveView(callback: LocatorCallback) { - return this.step('Interact with drive view', (page) => callback(locateDriveView(page))) + withDriveView(callback: LocatorCallback) { + return this.step('Interact with drive view', (page, context) => + callback(locateDriveView(page), context), + ) } /** Create a new folder using the icon in the Drive Bar. */ @@ -432,9 +434,9 @@ export default class DrivePageActions extends PageActions { } /** Interact with the Asset Panel. */ - withAssetPanel(callback: LocatorCallback) { - return this.step('Interact with asset panel', async (page) => { - await callback(locateAssetPanel(page)) + withAssetPanel(callback: LocatorCallback) { + return this.step('Interact with asset panel', async (page, context) => { + await callback(locateAssetPanel(page), context) }) } @@ -446,9 +448,9 @@ export default class DrivePageActions extends PageActions { } /** Interact with the context menus (the context menus MUST be visible). */ - withContextMenus(callback: LocatorCallback) { - return this.step('Interact with context menus', async (page) => { - await callback(locateContextMenu(page)) + withContextMenus(callback: LocatorCallback) { + return this.step('Interact with context menus', async (page, context) => { + await callback(locateContextMenu(page), context) }) } } diff --git a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts index 293a93c4a060..4e8d4199737d 100644 --- a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts @@ -32,9 +32,9 @@ export default class ForgotPasswordPageActions extends BaseActions { - await callback(page.getByPlaceholder(TEXT.emailPlaceholder)) + withEmailInput(callback: LocatorCallback) { + return this.step('Interact with email input', async (page, context) => { + await callback(page.getByPlaceholder(TEXT.emailPlaceholder), context) }) } diff --git a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts index 7ed991bad5d1..e8462d5ada0c 100644 --- a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts @@ -79,10 +79,10 @@ export default class LoginPageActions extends BaseActions { } /** Interact with the email input. */ - withEmailInput(callback: LocatorCallback) { - return this.step('Interact with email input', async (page) => { - await callback(page.getByPlaceholder(TEXT.emailPlaceholder)) - }) + withEmailInput(callback: LocatorCallback) { + return this.step('Interact with email input', (page, context) => + callback(page.getByPlaceholder(TEXT.emailPlaceholder), context), + ) } /** Internal login logic shared between all public methods. */ diff --git a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts index f071bfc54acd..b4e8607ecbda 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -20,10 +20,10 @@ export default class NewDataLinkModalActions extends BaseActions { + withNameInput(callback: LocatorCallback) { + return this.step('Interact with "name" input', async (page, context) => { const locator = locateNewDataLinkModal(page).getByPlaceholder(TEXT.datalinkNamePlaceholder) - await callback(locator) + await callback(locator, context) }) } } diff --git a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts index 2945305f799c..00322177bc68 100644 --- a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts @@ -68,9 +68,9 @@ export default class RegisterPageActions extends BaseActions { } /** Interact with the email input. */ - withEmailInput(callback: LocatorCallback) { - return this.step('Interact with email input', async (page) => { - await callback(page.getByPlaceholder(TEXT.emailPlaceholder)) + withEmailInput(callback: LocatorCallback) { + return this.step('Interact with email input', async (page, context) => { + await callback(page.getByPlaceholder(TEXT.emailPlaceholder), context) }) } diff --git a/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts index 6870d948c5de..18bcc3118a74 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts @@ -1,5 +1,6 @@ /** @file Actions for the "account" form in settings. */ import { TEXT } from '.' +import type { LocatorCallback } from './BaseActions' import type PageActions from './PageActions' import SettingsAccountTabActions from './SettingsAccountTabActions' import SettingsFormActions from './SettingsFormActions' @@ -28,4 +29,14 @@ export default class SettingsAccountFormActions extends SettingsFormAct this.locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox').fill(name), ) } + + /** Interact with the "name" input of this form. */ + withName(callback: LocatorCallback) { + return this.step("Interact with 'name' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox'), + context, + ), + ) + } } diff --git a/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts index 9d2e9455fae0..a191e178da6d 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts @@ -1,5 +1,6 @@ /** @file Actions for the "organization" form in settings. */ import { TEXT } from '.' +import type { LocatorCallback } from './BaseActions' import type PageActions from './PageActions' import SettingsFormActions from './SettingsFormActions' import SettingsOrganizationTabActions from './SettingsOrganizationTabActions' @@ -32,6 +33,16 @@ export default class SettingsOrganizationFormActions extends SettingsFo ) } + /** Interact with the "name" input of this form. */ + withName(callback: LocatorCallback) { + return this.step("Interact with 'name' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox'), + context, + ), + ) + } + /** Fill the "email" input of this form. */ fillEmail(name: string) { return this.step("Fill 'email' input of 'organization' form", (page) => @@ -42,6 +53,16 @@ export default class SettingsOrganizationFormActions extends SettingsFo ) } + /** Interact with the "email" input of this form. */ + withEmail(callback: LocatorCallback) { + return this.step("Interact with 'email' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox'), + context, + ), + ) + } + /** Fill the "website" input of this form. */ fillWebsite(name: string) { return this.step("Fill 'website' input of 'organization' form", (page) => @@ -52,6 +73,16 @@ export default class SettingsOrganizationFormActions extends SettingsFo ) } + /** Interact with the "website" input of this form. */ + withWebsite(callback: LocatorCallback) { + return this.step("Interact with 'website' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox'), + context, + ), + ) + } + /** Fill the "location" input of this form. */ fillLocation(name: string) { return this.step("Fill 'location' input of 'organization' form", (page) => @@ -61,4 +92,14 @@ export default class SettingsOrganizationFormActions extends SettingsFo .fill(name), ) } + + /** Interact with the "location" input of this form. */ + withLocation(callback: LocatorCallback) { + return this.step("Interact with 'name' input of 'organization' form", (page, context) => + callback( + this.locate(page).getByLabel(TEXT.organizationLocationSettingsInput).getByRole('textbox'), + context, + ), + ) + } } diff --git a/app/gui/integration-test/dashboard/actions/StartModalActions.ts b/app/gui/integration-test/dashboard/actions/StartModalActions.ts index 8f62ab497b68..2322d6733fa5 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -53,9 +53,9 @@ export default class StartModalActions extends BaseActions { } /** Interact with the "start" modal. */ - withStartModal(callback: LocatorCallback) { - return this.step('Interact with start modal', async (page) => { - await callback(this.locateStartModal(page)) + withStartModal(callback: LocatorCallback) { + return this.step('Interact with start modal', async (page, context) => { + await callback(this.locateStartModal(page), context) }) } } diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 1f96f1a5ee9e..013a3450d971 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -61,14 +61,14 @@ async function login({ page }: MockParams, email = 'email@example.com', password async function waitForLoaded(page: Page) { await page.waitForLoadState() - await test.expect(page.getByTestId('spinner')).toHaveCount(0) - await test.expect(page.getByTestId('loading-app-message')).not.toBeVisible({ timeout: 30_000 }) + await expect(page.getByTestId('spinner')).toHaveCount(0) + await expect(page.getByTestId('loading-app-message')).not.toBeVisible({ timeout: 30_000 }) } /** Wait for the dashboard to load. */ async function waitForDashboardToLoad(page: Page) { await waitForLoaded(page) - await test.expect(page.getByTestId('after-auth-layout')).toBeAttached() + await expect(page.getByTestId('after-auth-layout')).toBeAttached() } /** A placeholder date for visual regression testing. */ diff --git a/app/gui/integration-test/dashboard/assetPanel.spec.ts b/app/gui/integration-test/dashboard/assetPanel.spec.ts index e4edb0c17307..9282cf573724 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -68,7 +68,7 @@ test('asset panel contents', ({ page }) => .driveTable.clickRow(0) .toggleDescriptionAssetPanel() .do(async () => { - await test.expect(locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) + await expect(locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) // `getByText` is required so that this assertion works if there are multiple permissions. // This is not visible; "Shared with" should only be visible on the Enterprise plan. // await expect(locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() diff --git a/app/gui/integration-test/dashboard/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index 8a7f253cc760..c2bfd6d18d24 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -1,20 +1,20 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test } from '@playwright/test' import { mockAllAndLogin, TEXT } from './actions' -test.test('delete and restore', ({ page }) => +test('delete and restore', ({ page }) => mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.rightClickRow(0) .contextMenu.moveFolderToTrash() .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.rightClickRow(0) .contextMenu.restoreFromTrash() @@ -22,19 +22,18 @@ test.test('delete and restore', ({ page }) => .goToCategory.cloud() .expectStartModal() .withStartModal(async (startModal) => { - await test.expect(startModal).toBeVisible() + await expect(startModal).toBeVisible() }) .close() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - }), -) + await expect(rows).toHaveCount(1) + })) -test.test('delete and restore (keyboard)', ({ page }) => +test('delete and restore (keyboard)', ({ page }) => mockAllAndLogin({ page }) .createFolder() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.clickRow(0) .press('Delete') @@ -44,7 +43,7 @@ test.test('delete and restore (keyboard)', ({ page }) => .driveTable.expectPlaceholderRow() .goToCategory.trash() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) + await expect(rows).toHaveCount(1) }) .driveTable.clickRow(0) .press('Mod+R') @@ -53,6 +52,5 @@ test.test('delete and restore (keyboard)', ({ page }) => .expectStartModal() .close() .driveTable.withRows(async (rows) => { - await test.expect(rows).toHaveCount(1) - }), -) + await expect(rows).toHaveCount(1) + })) diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index 6c3322e38188..4537f574f27b 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -45,10 +45,15 @@ test('organization settings', ({ page }) => }) .cancel() .organizationForm() + .withName(async (nameEl) => { + await expect(nameEl).toHaveText(NEW_NAME) + }) + .cancel() + .organizationForm() .fillEmail(INVALID_EMAIL) .save() .step('Setting invalid email should fail', (_, { api }) => { - expect(api.currentOrganization()?.email).toBe('') + expect(api.currentOrganization()?.email).toBe(null) }) .organizationForm() .fillEmail(NEW_EMAIL) diff --git a/app/gui/integration-test/dashboard/sort.spec.ts b/app/gui/integration-test/dashboard/sort.spec.ts index 717788c887d5..ebcfdbdac550 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -131,7 +131,7 @@ test('sort', ({ page }) => }) .driveTable.withNameColumnHeading(async (nameHeading) => { await expectOpacity0(locateSortAscendingIcon(nameHeading)) - await test.expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() }) .driveTable.withRows(async (rows) => { await expect(rows).toHaveText([ From 8581f73323f68bd68fb0fad74b24c0293a4860fc Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 18:24:13 +1000 Subject: [PATCH 23/27] Fix lat integration test --- .../integration-test/dashboard/actions/SettingsFormActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts index 4f5edaef3242..c381ffac4de4 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts @@ -28,7 +28,7 @@ export default class SettingsFormActions< /** Cancel editing this settings section. */ cancel(): InstanceType { return this.step('Cancel editing settings form', (page) => - this.locate(page).getByRole('button', { name: TEXT.save }).getByText(TEXT.save).click(), + this.locate(page).getByRole('button', { name: TEXT.save }).getByText(TEXT.cancel).click(), ).into(this.parentClass) } } From f10d6372f1e27bc98cac5faa2fde3d8f9baf816a Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 18:26:47 +1000 Subject: [PATCH 24/27] Fix --- .../dashboard/actions/SettingsFormActions.ts | 2 +- .../dashboard/organizationSettings.spec.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts index c381ffac4de4..c4c84a6fdfeb 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsFormActions.ts @@ -28,7 +28,7 @@ export default class SettingsFormActions< /** Cancel editing this settings section. */ cancel(): InstanceType { return this.step('Cancel editing settings form', (page) => - this.locate(page).getByRole('button', { name: TEXT.save }).getByText(TEXT.cancel).click(), + this.locate(page).getByRole('button', { name: TEXT.cancel }).getByText(TEXT.cancel).click(), ).into(this.parentClass) } } diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index 4537f574f27b..b61ac95be2fb 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -40,20 +40,17 @@ test('organization settings', ({ page }) => }) .organizationForm() .fillName('') + .save() .step('Unsetting organization name should fail', (_, { api }) => { expect(api.currentOrganization()?.name).toBe(NEW_NAME) }) - .cancel() .organizationForm() - .withName(async (nameEl) => { - await expect(nameEl).toHaveText(NEW_NAME) - }) .cancel() .organizationForm() .fillEmail(INVALID_EMAIL) .save() .step('Setting invalid email should fail', (_, { api }) => { - expect(api.currentOrganization()?.email).toBe(null) + expect(api.currentOrganization()?.email).toBe('') }) .organizationForm() .fillEmail(NEW_EMAIL) From c796cde71b71d0a453ff55717df0d003628f01fe Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 18:46:48 +1000 Subject: [PATCH 25/27] Track backend calls in `organizationSettings.spec` --- app/gui/integration-test/dashboard/actions/api.ts | 10 ++++++++++ .../integration-test/dashboard/actions/index.ts | 14 ++++++++++++-- .../dashboard/organizationSettings.spec.ts | 12 ++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 495df5d7d245..fe156c1b4cce 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -113,6 +113,16 @@ const INITIAL_CALLS_OBJECT = { getProjectAsset: array<{ projectId: backend.ProjectId }>(), } +const READONLY_INITIAL_CALLS_OBJECT: TrackedCallsInternal = INITIAL_CALLS_OBJECT + +export { READONLY_INITIAL_CALLS_OBJECT as INITIAL_CALLS_OBJECT } + +type TrackedCallsInternal = { + [K in keyof typeof INITIAL_CALLS_OBJECT]: Readonly<(typeof INITIAL_CALLS_OBJECT)[K]> +} + +export interface TrackedCalls extends TrackedCallsInternal {} + /** Parameters for {@link mockApi}. */ export interface MockParams { readonly page: test.Page diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 013a3450d971..cda9f1a49627 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -5,7 +5,13 @@ import { expect, test, type Page } from '@playwright/test' import { TEXTS } from 'enso-common/src/text' -import { mockApi, type MockApi, type SetupAPI } from './api' +import { + INITIAL_CALLS_OBJECT, + mockApi, + type MockApi, + type SetupAPI, + type TrackedCalls, +} from './api' import DrivePageActions from './DrivePageActions' import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } import LoginPageActions from './LoginPageActions' @@ -119,11 +125,15 @@ export async function passAgreementsDialog({ page }: MockParams) { interface Context { readonly api: MockApi + calls: TrackedCalls } /** Set up all mocks, without logging in. */ export function mockAll({ page, setupAPI }: MockParams) { - const context: { api: MockApi } = { api: undefined! } + const context: { -readonly [K in keyof Context]: Context[K] } = { + api: undefined!, + calls: INITIAL_CALLS_OBJECT, + } return new LoginPageActions(page, context) .step('Execute all mocks', async (page) => { await Promise.all([ diff --git a/app/gui/integration-test/dashboard/organizationSettings.spec.ts b/app/gui/integration-test/dashboard/organizationSettings.spec.ts index b61ac95be2fb..bc14b55744e8 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -33,16 +33,24 @@ test('organization settings', ({ page }) => .goToSettingsTab.organization() .organizationForm() .fillName(NEW_NAME) + .do((_, context) => { + context.calls = context.api.trackCalls() + }) .save() - .step('Set organization name', (_, { api }) => { + .step('Set organization name', (_, { api, calls }) => { expect(api.currentOrganization()?.name).toBe(NEW_NAME) expect(api.currentUser()?.name).not.toBe(NEW_NAME) + expect(calls.updateOrganization).toMatchObject([{ name: NEW_NAME }]) }) .organizationForm() .fillName('') + .do((_, context) => { + context.calls = context.api.trackCalls() + }) .save() - .step('Unsetting organization name should fail', (_, { api }) => { + .step('Unsetting organization name should fail', (_, { api, calls }) => { expect(api.currentOrganization()?.name).toBe(NEW_NAME) + expect(calls.updateOrganization).toMatchObject([{ name: '' }]) }) .organizationForm() .cancel() From 10ecb07cab43c961cc0770a6baa26d5ee825207c Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 18:58:17 +1000 Subject: [PATCH 26/27] Add more docs for integration tests --- app/gui/integration-test/dashboard/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/gui/integration-test/dashboard/README.md b/app/gui/integration-test/dashboard/README.md index 9d70f8cc897d..04feed3cd434 100644 --- a/app/gui/integration-test/dashboard/README.md +++ b/app/gui/integration-test/dashboard/README.md @@ -38,3 +38,12 @@ test('test name here', ({ page }) => expect(api.baz()?.quux).toEqual('bar') })) ``` + +### Writing new classes extending `BaseActions` + +- Make sure that every method returns either the class itself (`this`) or `.into(AnotherActionsClass)`. +- Avoid constructing `new AnotherActionsClass()` - instead prefer `.into(AnotherActionsClass)` and optionally `.into(ThisClass)` if required. +- Never construct an `ActionsClass` + - In some rare exceptions, it is fine as long as you `await` the `PageActions` class - for example in `index.ts` there is `await new StartModalActions().close()`. +- Methods for locators are fine, but it is not recommended to expose them as it makes it easy to accidentally - i.e. it is fine as long as they are `private`. + - In general, avoid exposing any method that returns a `Promise` rather than a `PageActions`. From 6c0db0708067d67618e8cbea8a39fe27f3cd42a1 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 19:05:31 +1000 Subject: [PATCH 27/27] Uncomment tests and skip them instead --- .../dashboard/actions/DrivePageActions.ts | 12 +++- .../dashboard/pageSwitcher.spec.ts | 64 +++++++++---------- .../dashboard/startModal.spec.ts | 54 ++++++++-------- 3 files changed, 69 insertions(+), 61 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 174bd9eb619e..83122470e5a2 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -4,6 +4,7 @@ import { expect, type Locator, type Page } from '@playwright/test' import { TEXT } from '.' import type { LocatorCallback } from './BaseActions' import { contextMenuActions } from './contextMenuActions' +import EditorPageActions from './EditorPageActions' import { goToPageActions, type GoToPageActions } from './goToPageActions' import NewDataLinkModalActions from './NewDataLinkModalActions' import PageActions from './PageActions' @@ -315,7 +316,16 @@ export default class DrivePageActions extends PageActions { (page) => page.getByText(TEXT.newEmptyProject, { exact: true }).click(), // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. - ) /* .into(EditorPageActions) */ + ) /* .into(EditorPageActions) */ + } + + // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 + // Delete once cloud execution in the browser is re-enabled. + /** Create a new empty project. */ + newEmptyProjectTest() { + return this.step('Create empty project', (page) => + page.getByText(TEXT.newEmptyProject, { exact: true }).click(), + ).into(EditorPageActions) } /** Interact with the drive view (the main container of this page). */ diff --git a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts index cb58c783283d..f59e9cf3da20 100644 --- a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts +++ b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts @@ -1,39 +1,37 @@ /** @file Test the login flow. */ -// import { expect, test, type Page } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -// import { mockAllAndLogin } from './actions' +import { mockAllAndLogin } from './actions' -// /** Find an editor container. */ -// function locateEditor(page: Page) { -// // Test ID of a placeholder editor component used during testing. -// return page.locator('.App') -// } +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} -// /** Find a drive view. */ -// function locateDriveView(page: Page) { -// // This has no identifying features. -// return page.getByTestId('drive-view') -// } +/** Find a drive view. */ +function locateDriveView(page: Page) { + // This has no identifying features. + return page.getByTestId('drive-view') +} // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Uncomment once cloud execution in the browser is re-enabled. -// test('page switcher', ({ page }) => -// actions -// .mockAllAndLogin({ page }) -// // Create a new project so that the editor page can be switched to. -// .newEmptyProject() -// .do(async (thePage) => { -// await expect(actions.locateDriveView(thePage)).not.toBeVisible() -// await expect(actions.locateEditor(thePage)).toBeVisible() -// }) -// .goToPage.drive() -// .do(async (thePage) => { -// await expect(actions.locateDriveView(thePage)).toBeVisible() -// await expect(actions.locateEditor(thePage)).not.toBeVisible() -// }) -// .goToPage.editor() -// .do(async (thePage) => { -// await expect(actions.locateDriveView(thePage)).not.toBeVisible() -// await expect(actions.locateEditor(thePage)).toBeVisible() -// }), -// ) +// Unskip once cloud execution in the browser is re-enabled. +test.skip('page switcher', ({ page }) => + mockAllAndLogin({ page }) + // Create a new project so that the editor page can be switched to. + .newEmptyProjectTest() + .do(async (thePage) => { + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateEditor(thePage)).toBeVisible() + }) + .goToPage.drive() + .do(async (thePage) => { + await expect(locateDriveView(thePage)).toBeVisible() + await expect(locateEditor(thePage)).not.toBeVisible() + }) + .goToPage.editor() + .do(async (thePage) => { + await expect(locateDriveView(thePage)).not.toBeVisible() + await expect(locateEditor(thePage)).toBeVisible() + })) diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index b8beff422ce3..411825adb944 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -1,34 +1,34 @@ /** @file Test the "change password" modal. */ -// import { expect, test, type Page } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -// import { mockAllAndLogin } from './actions' +import { mockAllAndLogin } from './actions' -// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Uncomment once cloud execution in the browser is re-enabled. +/** Find an editor container. */ +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} -// /** Find an editor container. */ -// function locateEditor(page: Page) { -// // Test ID of a placeholder editor component used during testing. -// return page.locator('.App') -// } +/** Find a samples list. */ +function locateSamplesList(page: Page) { + // This has no identifying features. + return page.getByTestId('samples') +} -// /** Find a samples list. */ -// function locateSamplesList(page: Page) { -// // This has no identifying features. -// return page.getByTestId('samples') -// } +/** Find all samples list. */ +function locateSamples(page: Page) { + // This has no identifying features. + return locateSamplesList(page).getByRole('button') +} -// /** Find all samples list. */ -// function locateSamples(page: Page) { -// // This has no identifying features. -// return locateSamplesList(page).getByRole('button') -// } +// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 +// Unskip once cloud execution in the browser is re-enabled. -// test('create project from template', ({ page }) => -// mockAllAndLogin({ page }) -// .expectStartModal() -// .createProjectFromTemplate(0) -// .do(async (thePage) => { -// await expect(locateEditor(thePage)).toBeAttached() -// await expect(locateSamples(page).first()).not.toBeVisible() -// })) +test.skip('create project from template', ({ page }) => + mockAllAndLogin({ page }) + .expectStartModal() + .createProjectFromTemplate(0) + .do(async (thePage) => { + await expect(locateEditor(thePage)).toBeAttached() + await expect(locateSamples(page).first()).not.toBeVisible() + }))