From b83c5a15eb4a23509e1218905755b27b4a4b5045 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 12 Dec 2024 19:49:58 +1000 Subject: [PATCH] Clean up integration tests and add listeners for backend calls (#11847) - Close https://github.com/enso-org/cloud-v2/issues/1604 - Add ability to track backend calls - Remove inconsistent integration test code - Add skeleton classes for settings pages # Important Notes None --- app/gui/integration-test/dashboard/README.md | 52 +- .../dashboard/actions/BaseActions.ts | 85 +- .../actions/BaseSettingsTabActions.ts | 11 + .../dashboard/actions/DrivePageActions.ts | 225 +++-- .../dashboard/actions/EditorPageActions.ts | 14 +- .../actions/ForgotPasswordPageActions.ts | 22 +- .../dashboard/actions/LoginPageActions.ts | 34 +- .../actions/NewDataLinkModalActions.ts | 27 +- .../dashboard/actions/PageActions.ts | 14 +- .../dashboard/actions/RegisterPageActions.ts | 26 +- .../actions/SettingsAccountFormActions.ts | 42 + .../actions/SettingsAccountTabActions.ts | 37 + .../actions/SettingsActivityLogTabActions.ts | 13 + .../SettingsBillingAndPlansTabActions.ts | 13 + .../SettingsChangePasswordFormActions.ts | 54 ++ .../dashboard/actions/SettingsFormActions.ts | 34 + .../SettingsKeyboardShortcutsTabActions.ts | 13 + .../actions/SettingsLocalTabActions.ts | 11 + .../actions/SettingsMembersTabActions.ts | 11 + .../SettingsOrganizationFormActions.ts | 105 ++ .../actions/SettingsOrganizationTabActions.ts | 33 + .../dashboard/actions/SettingsPageActions.ts | 22 +- .../actions/SettingsUserGroupsTabActions.ts | 11 + .../dashboard/actions/SetupDonePageActions.ts | 8 +- .../actions/SetupInvitePageActions.ts | 10 +- .../actions/SetupOrganizationPageActions.ts | 8 +- .../dashboard/actions/SetupPlanPageActions.ts | 14 +- .../dashboard/actions/SetupTeamPageActions.ts | 8 +- .../actions/SetupUsernamePageActions.ts | 8 +- .../dashboard/actions/StartModalActions.ts | 61 +- .../dashboard/{ => actions}/api.ts | 343 ++++--- .../dashboard/actions/contextMenuActions.ts | 22 +- .../dashboard/actions/goToPageActions.ts | 30 +- .../actions/gotoSettingsTabActions.ts | 88 ++ .../dashboard/actions/index.ts | 897 +++--------------- .../{ => actions}/latestGithubReleases.json | 0 .../dashboard/actions/openUserMenuAction.ts | 8 +- .../dashboard/actions/userMenuActions.ts | 57 +- .../dashboard/assetPanel.spec.ts | 99 +- .../dashboard/assetSearchBar.spec.ts | 222 +++-- .../dashboard/assetsTableFeatures.spec.ts | 135 +-- .../integration-test/dashboard/auth.setup.ts | 12 +- .../dashboard/authPreserveEmail.spec.ts | 18 +- .../dashboard/contextMenus.spec.ts | 64 +- .../integration-test/dashboard/copy.spec.ts | 186 ++-- .../dashboard/createAsset.spec.ts | 69 +- .../dashboard/dataLinkEditor.spec.ts | 12 +- .../integration-test/dashboard/delete.spec.ts | 35 +- .../dashboard/driveView.spec.ts | 43 +- .../dashboard/editAssetName.spec.ts | 266 +++--- .../integration-test/dashboard/labels.spec.ts | 132 +-- .../dashboard/labelsPanel.spec.ts | 128 ++- .../dashboard/loginLogout.spec.ts | 40 +- .../dashboard/loginScreen.spec.ts | 15 +- .../dashboard/organizationSettings.spec.ts | 191 ++-- .../dashboard/pageSwitcher.spec.ts | 56 +- .../integration-test/dashboard/setup.spec.ts | 86 +- .../integration-test/dashboard/signUp.spec.ts | 13 +- .../integration-test/dashboard/sort.spec.ts | 307 +++--- .../dashboard/startModal.spec.ts | 43 +- .../dashboard/userSettings.spec.ts | 145 ++- .../OrganizationProfilePictureInput.tsx | 5 +- .../layouts/Settings/ProfilePictureInput.tsx | 5 +- 63 files changed, 2460 insertions(+), 2338 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/SettingsOrganizationFormActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts create mode 100644 app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts rename app/gui/integration-test/dashboard/{ => actions}/api.ts (79%) create mode 100644 app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts rename app/gui/integration-test/dashboard/{ => actions}/latestGithubReleases.json (100%) diff --git a/app/gui/integration-test/dashboard/README.md b/app/gui/integration-test/dashboard/README.md index 204492c82d..04feed3cd4 100644 --- a/app/gui/integration-test/dashboard/README.md +++ b/app/gui/integration-test/dashboard/README.md @@ -3,51 +3,47 @@ ## 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') + })) ``` + +### 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`. diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 1e22ca9a18..56cfa35621 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -1,31 +1,31 @@ /** @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 '.' - -// ==================== -// === PageCallback === -// ==================== - -/** A callback that performs actions on a {@link test.Page}. */ -export interface PageCallback { - (input: test.Page): Promise | void +/** `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' } -// ======================= -// === LocatorCallback === -// ======================= - -/** A callback that performs actions on a {@link test.Locator}. */ -export interface LocatorCallback { - (input: test.Locator): Promise | void +/** A callback that performs actions on a {@link Page}. */ +export interface PageCallback { + (input: Page, context: Context): Promise | void } -// =================== -// === BaseActions === -// =================== +/** A callback that performs actions on a {@link Locator}. */ +export interface LocatorCallback { + (input: Locator, context: Context): 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. @@ -34,10 +34,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 page: Page, + protected readonly context: Context, private readonly promise = Promise.resolve(), ) {} @@ -53,11 +54,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) @@ -99,43 +100,49 @@ 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: 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) } /** - * 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. */ - 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.context, + 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))) + /** Perform an action. */ + step(name: string, callback: PageCallback) { + 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 @@ -152,7 +159,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)) @@ -171,11 +178,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/BaseSettingsTabActions.ts b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts new file mode 100644 index 0000000000..696959dfe6 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/BaseSettingsTabActions.ts @@ -0,0 +1,11 @@ +/** @file Actions for the "user" tab of the "settings" page. */ +import { goToPageActions, type GoToPageActions } from './goToPageActions' +import PageActions from './PageActions' + +/** Actions common to all settings pages. */ +export default class BaseSettingsTabActions extends PageActions { + /** Actions for navigating to another page. */ + get goToPage(): Omit, 'settings'> { + return goToPageActions(this.step.bind(this)) + } +} diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 61b0dbdbc0..83122470e5 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -1,60 +1,92 @@ /** @file Actions for the "drive" page. */ -import * as test from 'playwright/test' +import { expect, type Locator, type Page } from '@playwright/test' -import { - locateAssetPanel, - locateAssetsTable, - locateContextMenu, - locateCreateButton, - locateDriveView, - locateNewSecretIcon, - locateNonAssetRows, - locateSecretNameInput, - locateSecretValueInput, - TEXT, -} from '.' -import type * as baseActions from './BaseActions' -import * as contextMenuActions from './contextMenuActions' -import * as goToPageActions from './goToPageActions' +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' import StartModalActions from './StartModalActions' -// ================= -// === Constants === -// ================= - const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 } -// ======================= -// === locateAssetRows === -// ======================= +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} -/** Find all assets table rows (if any). */ -function locateAssetRows(page: test.Page) { +/** 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') } -// ======================== -// === DrivePageActions === -// ======================== +/** 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 { +export default class DrivePageActions extends PageActions { /** Actions for navigating to another page. */ - get goToPage(): Omit { - 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. */ 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() { @@ -92,24 +124,50 @@ export default class DrivePageActions extends PageActions { } } + /** Interact with the assets search bar. */ + withSearchBar(callback: LocatorCallback) { + return this.step('Interact with search bar', (page, context) => + callback(page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/), context), + ) + } + /** 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 + 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, context) => + callback(locateNameColumnHeading(page), context), ) }, /** 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, context) => + callback(locateModifiedColumnHeading(page), context), ) }, /** Click to select a specific row. */ @@ -138,13 +196,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, - page: test.Page, + 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), page) + await callback(locateAssetRows(page), locateNonAssetRows(page), self.context, page) }) }, /** Drag a row onto another row. */ @@ -158,7 +217,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) @@ -174,10 +233,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/) }) }, /** @@ -186,10 +245,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 +299,14 @@ export default class DrivePageActions extends PageActions { openStartModal() { return this.step('Open "start" modal', (page) => page.getByText(TEXT.startWithATemplate).click(), - ).into(StartModalActions) + ).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. */ @@ -250,19 +316,30 @@ 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). */ - withDriveView(callback: baseActions.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. */ 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') }) } @@ -324,7 +401,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 }) @@ -337,7 +414,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' }) } @@ -358,16 +435,18 @@ export default class DrivePageActions extends PageActions { } /** Interact with the container element of the assets table. */ - withAssetsTable(callback: baseActions.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) }) } /** Interact with the Asset Panel. */ - withAssetPanel(callback: baseActions.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) }) } @@ -375,27 +454,13 @@ 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). */ - withContextMenus(callback: baseActions.LocatorCallback) { - return this.step('Interact with context menus', async (page) => { - await callback(locateContextMenu(page)) - }) - } - - /** Close the "get started" modal. */ - closeGetStartedModal() { - return this.step('Close "get started" modal', async (page) => { - await new StartModalActions(page).close() - }) - } - - /** Interact with the "start" modal. */ - withStartModal(callback: baseActions.LocatorCallback) { - return this.step('Interact with start modal', async (page) => { - await callback(new StartModalActions(page).locateStartModal()) + 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/EditorPageActions.ts b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts index 4df9a30fb5..3bba639a58 100644 --- a/app/gui/integration-test/dashboard/actions/EditorPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/EditorPageActions.ts @@ -1,19 +1,15 @@ /** @file Actions for the "editor" page. */ -import * as goToPageActions from './goToPageActions' +import { goToPageActions, type GoToPageActions } from './goToPageActions' import PageActions from './PageActions' -// ========================= -// === EditorPageActions === -// ========================= - /** 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 { - 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 { + 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 738975c79f..4e8d419973 100644 --- a/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/ForgotPasswordPageActions.ts @@ -1,30 +1,26 @@ /** @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' import LoginPageActions from './LoginPageActions' -// ================================= -// === ForgotPasswordPageActions === -// ================================= - /** 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, ) } @@ -36,9 +32,9 @@ export default class ForgotPasswordPageActions 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) }) } @@ -49,6 +45,6 @@ export default class ForgotPasswordPageActions 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/LoginPageActions.ts b/app/gui/integration-test/dashboard/actions/LoginPageActions.ts index 9d2ca08fa6..e8462d5ada 100644 --- a/app/gui/integration-test/dashboard/actions/LoginPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/LoginPageActions.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, passAgreementsDialog } from '.' import BaseActions, { type LocatorCallback } from './BaseActions' @@ -8,23 +8,19 @@ 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 { +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), } } @@ -33,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). */ @@ -41,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. */ @@ -66,11 +62,11 @@ export default class LoginPageActions 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() }) } } @@ -83,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. */ @@ -97,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 9a58357433..b4e8607ecb 100644 --- a/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/NewDataLinkModalActions.ts @@ -1,38 +1,29 @@ /** @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' -// ============================== -// === locateNewDataLinkModal === -// ============================== - /** 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') }) } -// =============================== -// === NewDataLinkModalActions === -// =============================== - /** 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() { + 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("")`. */ - withNameInput(callback: baseActions.LocatorCallback) { - return this.step('Interact with "name" input', async (page) => { + 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/PageActions.ts b/app/gui/integration-test/dashboard/actions/PageActions.ts index 614c15eeec..a7e55a31f4 100644 --- a/app/gui/integration-test/dashboard/actions/PageActions.ts +++ b/app/gui/integration-test/dashboard/actions/PageActions.ts @@ -1,21 +1,17 @@ /** @file Actions common to all pages. */ import BaseActions from './BaseActions' -import * as openUserMenuAction from './openUserMenuAction' -import * as userMenuActions from './userMenuActions' - -// =================== -// === PageActions === -// =================== +import { openUserMenuAction } from './openUserMenuAction' +import { 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)) + 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 dcdd3d8fc4..00322177bc 100644 --- a/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/RegisterPageActions.ts @@ -1,23 +1,19 @@ /** @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' import LoginPageActions from './LoginPageActions' -// ======================== -// === 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), } } @@ -25,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. */ @@ -55,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() }) } } @@ -72,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) }) } @@ -95,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/SettingsAccountFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts new file mode 100644 index 0000000000..18bcc3118a --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountFormActions.ts @@ -0,0 +1,42 @@ +/** @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' + +/** 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("Fill 'name' input of 'account' form", (page) => + 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/SettingsAccountTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts new file mode 100644 index 0000000000..ce8237a97d --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsAccountTabActions.ts @@ -0,0 +1,37 @@ +/** @file Actions for the "account" tab of the "settings" page. */ +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 { + /** 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) + } + + /** Manipulate the "change password" form. */ + changePasswordForm() { + return this.into(SettingsChangePasswordFormActions) + } + + /** Upload a profile picture. */ + uploadProfilePicture( + name: string, + content: WithImplicitCoercion, + mimeType: string, + ) { + return this.step('Upload account profile picture', async (page) => { + const fileChooserPromise = page.waitForEvent('filechooser') + 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/SettingsActivityLogTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts new file mode 100644 index 0000000000..63665ffff6 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsActivityLogTabActions.ts @@ -0,0 +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< + 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 new file mode 100644 index 0000000000..7a5e1b68d0 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsBillingAndPlansTabActions.ts @@ -0,0 +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< + 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/SettingsChangePasswordFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsChangePasswordFormActions.ts new file mode 100644 index 0000000000..1799f210b6 --- /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("Fill 'current password' input of 'change password' form", (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("Fill 'new password' input of 'change password' form", (page) => + this.locate(page) + .getByLabel(new RegExp('^' + TEXT.userNewPasswordSettingsInput)) + .getByRole('textbox') + .fill(name), + ) + } + + /** Fill the "confirm new password" input of this form. */ + fillConfirmNewPassword(name: string) { + return this.step("Fill 'confirm new password' input of 'change password' form", (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 0000000000..c4c84a6fdf --- /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/test' +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.cancel }).getByText(TEXT.cancel).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 0000000000..efcf2c6d7b --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsKeyboardShortcutsTabActions.ts @@ -0,0 +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< + 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 new file mode 100644 index 0000000000..c62afd835c --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsLocalTabActions.ts @@ -0,0 +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 { + /** 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 new file mode 100644 index 0000000000..4145174927 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsMembersTabActions.ts @@ -0,0 +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 { + /** 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/SettingsOrganizationFormActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts new file mode 100644 index 0000000000..a191e178da --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationFormActions.ts @@ -0,0 +1,105 @@ +/** @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' + +/** Actions for the "organization" form in settings. */ +export default class SettingsOrganizationFormActions extends SettingsFormActions< + Context, + typeof SettingsOrganizationTabActions +> { + /** Create a {@link SettingsOrganizationFormActions}. */ + constructor(...args: ConstructorParameters>) { + super( + SettingsOrganizationTabActions, + (page) => + page + .getByRole('heading') + .and(page.getByText(TEXT.organizationSettingsSection)) + .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), + ) + } + + /** 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) => + this.locate(page) + .getByLabel(TEXT.organizationEmailSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** 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) => + this.locate(page) + .getByLabel(TEXT.organizationWebsiteSettingsInput) + .getByRole('textbox') + .fill(name), + ) + } + + /** 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) => + this.locate(page) + .getByLabel(TEXT.organizationLocationSettingsInput) + .getByRole('textbox') + .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/SettingsOrganizationTabActions.ts b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts new file mode 100644 index 0000000000..4fa95e58e5 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsOrganizationTabActions.ts @@ -0,0 +1,33 @@ +/** @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< + 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) + } + + /** 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.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/SettingsPageActions.ts b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts index 25a250fc4a..fc426eb4c7 100644 --- a/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SettingsPageActions.ts @@ -1,16 +1,10 @@ -/** @file Actions for the "settings" page. */ -import * as goToPageActions from './goToPageActions' -import PageActions from './PageActions' +/** @file Actions for the default tab of the "settings" page. */ +import SettingsAccountTabActions from './SettingsAccountTabActions' -// =========================== -// === SettingsPageActions === -// =========================== +/** Actions for the default tab of the "settings" page. */ +type SettingsPageActions = SettingsAccountTabActions -// TODO: split settings page actions into different classes for each settings tab. -/** Actions for the "settings" page. */ -export default class SettingsPageActions extends PageActions { - /** Actions for navigating to another page. */ - get goToPage(): Omit { - return goToPageActions.goToPageActions(this.step.bind(this)) - } -} +/** 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 0000000000..afa4ba2970 --- /dev/null +++ b/app/gui/integration-test/dashboard/actions/SettingsUserGroupsTabActions.ts @@ -0,0 +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 { + /** Actions for navigating to another settings tab. */ + get goToSettingsTab(): Omit, 'userGroups'> { + return goToSettingsTabActions(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 ca417a883a..22c8949bb2 100644 --- a/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupDonePageActions.ts @@ -3,19 +3,15 @@ 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 { +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 062dce8c5f..f62361aae3 100644 --- a/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupInvitePageActions.ts @@ -3,24 +3,20 @@ 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 { +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 6f1a5eca68..b3ab2bd638 100644 --- a/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupOrganizationPageActions.ts @@ -3,12 +3,8 @@ 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 { +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) => { @@ -17,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 ecd0208a8b..cb6d9f9e63 100644 --- a/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupPlanPageActions.ts @@ -6,12 +6,8 @@ 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 { +export default class SetupPlanPageActions extends BaseActions { /** Select a plan. */ selectSoloPlan() { return this.step(`Select 'solo' plan`, async (page) => { @@ -21,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. */ @@ -38,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 fe2010d9b1..e51c60a74f 100644 --- a/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupTeamPageActions.ts @@ -3,12 +3,8 @@ 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 { +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) => { @@ -17,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 0a91f27837..bdf608370e 100644 --- a/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/SetupUsernamePageActions.ts @@ -3,17 +3,13 @@ import { TEXT } from '.' import BaseActions from './BaseActions' import SetupPlanPageActions from './SetupPlanPageActions' -// ================================ -// === SetupUsernamePageActions === -// ================================ - /** 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 7d51704046..2322d6733f 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -1,36 +1,41 @@ /** @file Actions for the "home" page. */ -import * as test from '@playwright/test' -import * as actions from '.' -import BaseActions from './BaseActions' +import type { Page } from '@playwright/test' +import BaseActions, { type LocatorCallback } from './BaseActions' +import DrivePageActions from './DrivePageActions' import EditorPageActions from './EditorPageActions' -// ========================= -// === StartModalActions === -// ========================= +/** 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 { +export default class StartModalActions extends BaseActions { /** Close this modal and go back to the Drive page. */ - async close() { - const isOnScreen = await this.isStartModalShown() - - if (isOnScreen) { - return test.test.step('Close start modal', async () => { - await this.locateStartModal().getByTestId('close-button').click() - }) - } + close() { + return this.step('Close start modal', async (page) => { + const isOnScreen = await this.isStartModalShown(page) + if (isOnScreen) { + await this.locateStartModal(page).getByTestId('close-button').click() + } + }).into(DrivePageActions) } /** Locate the "start" modal. */ - locateStartModal() { - return this.page.getByTestId('start-modal') + private locateStartModal(page: Page) { + return page.getByTestId('start-modal') } - /** - * Check if the Asset Panel is shown. - */ - isStartModalShown() { - return this.locateStartModal() + /** Check if the Asset Panel is shown. */ + private isStartModalShown(page: Page) { + return this.locateStartModal(page) .isHidden() .then( (result) => !result, @@ -41,10 +46,16 @@ 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) + ).into(EditorPageActions) + } + + /** Interact with the "start" modal. */ + 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/api.ts b/app/gui/integration-test/dashboard/actions/api.ts similarity index 79% rename from app/gui/integration-test/dashboard/api.ts rename to app/gui/integration-test/dashboard/actions/api.ts index 496b3eebcb..fe156c1b4c 100644 --- a/app/gui/integration-test/dashboard/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -10,16 +10,11 @@ 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' import { fileURLToPath } from 'node:url' -import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' } - -// ================= -// === Constants === -// ================= const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -58,9 +53,75 @@ const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*') const BASE_URL = 'https://mock/' const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/' -// =============== -// === mockApi === -// =============== +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 }>(), +} + +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 { @@ -77,24 +138,10 @@ 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 -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 @@ -124,6 +171,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 @@ -160,6 +208,29 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { >() usersMap.set(defaultUser.userId, defaultUser) + function trackCalls() { + const calls = structuredClone(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) @@ -316,7 +387,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)) { @@ -451,56 +522,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, '')}'.`, @@ -519,6 +540,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 }) @@ -538,14 +560,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 @@ -576,18 +599,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 { @@ -596,28 +624,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) { @@ -661,11 +696,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({ @@ -681,6 +717,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 = { @@ -701,22 +738,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 @@ -724,7 +764,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) @@ -740,10 +783,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() }) @@ -752,6 +800,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 { @@ -762,6 +811,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 { @@ -771,6 +821,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({ @@ -782,9 +833,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()}`), ), @@ -792,6 +845,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({ id: backend.FileId(body.uploadId), @@ -804,9 +858,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => { const body: backend.CreateSecretRequestBody = await request.postDataJSON() - const secret = addSecret({ - title: body.name, - }) + called('createSecret', body) + const secret = addSecret({ title: body.name }) return secret.id }) @@ -814,6 +867,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( @@ -825,6 +879,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 } @@ -838,11 +893,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 @@ -854,19 +912,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) @@ -876,16 +937,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, @@ -893,10 +957,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) => { @@ -905,6 +972,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 }) }) @@ -914,6 +982,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, @@ -928,17 +997,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 }) @@ -950,11 +1021,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 { @@ -963,6 +1036,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, @@ -978,6 +1052,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, @@ -985,10 +1060,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 id = backend.ProjectId(`project-${uniqueString.uniqueString()}`) const parentId = body.parentDirectoryId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) @@ -1028,6 +1105,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 id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) const parentId = body.parentId ?? defaultDirectoryId @@ -1058,8 +1136,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }) - await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route) => { - const content = readFileSync(join(__dirname, './mock/enso-demo.main'), 'utf8') + 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({ body: content, @@ -1067,7 +1149,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', @@ -1145,6 +1231,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // deletePermission, addUserGroupToUser, removeUserGroupFromUser, + trackCalls, } as const if (setupAPI) { diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index a9e443b36e..b8c1ef7c25 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -1,15 +1,11 @@ /** @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' -// ========================== -// === ContextMenuActions === -// ========================== - /** Actions for the context menu. */ -export interface ContextMenuActions { +export interface ContextMenuActions, Context> { readonly open: () => T readonly uploadToCloud: () => T readonly rename: () => T @@ -22,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 @@ -34,14 +30,10 @@ export interface ContextMenuActions { readonly newDataLink: () => T } -// ========================== -// === 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: PageCallback) => T, +): ContextMenuActions { return { open: () => step('Open (context menu)', (page) => @@ -131,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 ff054a1a4b..2101676935 100644 --- a/app/gui/integration-test/dashboard/actions/goToPageActions.ts +++ b/app/gui/integration-test/dashboard/actions/goToPageActions.ts @@ -1,29 +1,21 @@ /** @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' import SettingsPageActions from './SettingsPageActions' -// ======================= -// === GoToPageActions === -// ======================= - /** 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 } -// ======================= -// === goToPageActions === -// ======================= - /** 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: PageCallback) => BaseActions, +): GoToPageActions { return { drive: () => step('Go to "Data Catalog" page', (page) => @@ -31,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/gotoSettingsTabActions.ts b/app/gui/integration-test/dashboard/actions/gotoSettingsTabActions.ts new file mode 100644 index 0000000000..ca92c3adf9 --- /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), + } +} diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 1b9b93789b..cda9f1a496 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -1,18 +1,22 @@ /** @file Various actions, locators, and constants used in end-to-end tests. */ -import * as test from '@playwright/test' +import path from 'node:path' + +import { expect, test, type Page } from '@playwright/test' import { TEXTS } from 'enso-common/src/text' -import path from 'node:path' -import * as apiModule 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' import StartModalActions from './StartModalActions' -// ================= -// === Constants === -// ================= - /** An example password that does not meet validation requirements. */ export const INVALID_PASSWORD = 'password' /** An example password that meets validation requirements. */ @@ -21,662 +25,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. */ -export function locateEmailInput(page: test.Locator | test.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) { - return page.getByPlaceholder('Enter your password') -} - -/** Find a "confirm password" input (if any) on the current page. */ -export function locateConfirmPasswordInput(page: test.Locator | test.Page) { - return page.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) { - 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) { - 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: test.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) { - return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder) -} - -/** Find a search bar input (if any) on the current page. */ -export function locateSearchBarInput(page: test.Page) { - return locateSearchBar(page).getByPlaceholder(/(?:)/) -} - -/** Find the name column of the given assets table row. */ -export function locateAssetRowName(locator: test.Locator) { - return locator.getByTestId('asset-row-name') -} - -// === Button locators === - -/** Find a "login" button (if any) on the current locator. */ -export function locateLoginButton(page: test.Locator | test.Page) { - return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') -} - -/** Find a "register" button (if any) on the current locator. */ -export function locateRegisterButton(page: test.Locator | test.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) { - return page.getByRole('button', { name: 'Create' }).getByText('Create') -} - -/** Find a button to open the editor (if any) on the current page. */ -export function locatePlayOrOpenProjectButton(page: test.Locator | test.Page) { - return page.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) { - return page.getByLabel('Stop execution') -} - -/** Close a modal. */ -export function closeModal(page: test.Page) { - return test.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) { - 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: test.Locator | test.Page) { - return page.getByLabel('Confirm Edit') -} - -/** Find a cross button (if any) on the current page. */ -export function locateEditingCross(page: test.Locator | test.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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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) { - return page.getByRole('button', { name: 'new label' }).getByText('new label') -} - -/** Find an "upgrade" button (if any) on the current page. */ -export function locateUpgradeButton(page: test.Locator | test.Page) { - return page.getByRole('link', { name: 'Upgrade', exact: true }).getByText('Upgrade').first() -} - -/** Find a not enabled stub view (if any) on the current page. */ -export function locateNotEnabledStub(page: test.Locator | test.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) { - 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) { - 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) { - 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) { - 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) { - 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) { - return locateSearchBar(page).getByTestId('asset-search-suggestion') -} - -// === Icon locators === - -// These are specifically icons that are not also buttons. -// Icons that *are* buttons belong in the "Button locators" section. - -/** Find a "sort ascending" icon (if any) on the current page. */ -export function locateSortAscendingIcon(page: test.Locator | test.Page) { - return page.getByAltText('Sort Ascending') -} - -/** Find a "sort descending" icon (if any) on the current page. */ -export function locateSortDescendingIcon(page: test.Locator | test.Page) { - return page.getByAltText('Sort Descending') -} - -// === Heading locators === - -/** Find a "name" column heading (if any) on the current page. */ -export function locateNameColumnHeading(page: test.Locator | test.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: test.Locator | test.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: test.Locator | test.Page) { - // This has no identifying features. - return page.getByTestId('drive-view') -} - -/** Find a samples list (if any) on the current page. */ -export function locateSamplesList(page: test.Locator | test.Page) { - // This has no identifying features. - return page.getByTestId('samples') -} - -/** Find all samples list (if any) on the current page. */ -export function locateSamples(page: test.Locator | test.Page) { - // This has no identifying features. - return locateSamplesList(page).getByRole('button') -} - -/** Find an editor container (if any) on the current page. */ -export function locateEditor(page: test.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) { - return locateDriveView(page).getByRole('table') -} - -/** Find assets table rows (if any) on the current page. */ -export function locateAssetRows(page: test.Page) { - return locateAssetsTable(page).getByTestId('asset-row') -} - -/** Find assets table placeholder rows (if any) on the current page. */ -export function locateNonAssetRows(page: test.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) { - return locator.locator('> :nth-child(1)') -} - -/** - * Find assets table rows that represent directories that can be expanded (if any) - * on the current page. - */ -export function locateExpandableDirectories(page: test.Page) { - // 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: test.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) { - // 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) { - // 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) { - 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) { - // 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 locateContextMenu(page: test.Page) { - // This has no identifying features. - return page.getByTestId('context-menu') -} - -/** Find a labels panel (if any) on the current page. */ -export function locateLabelsPanel(page: test.Page) { - // This has no identifying features. - return page.getByTestId('labels') -} - -/** Find a list of labels (if any) on the current page. */ -export function locateLabelsList(page: test.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) { - // 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) { - // 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) { - // 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: test.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: test.Page) { - // This has no identifying features. - return locateAssetPanel(page).getByTestId('asset-panel-description') -} - -/** Find asset permissions in an asset panel (if any) on the current page. */ -export function locateAssetPanelPermissions(page: test.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: test.Page) { - return page.getByRole('button', { name: 'Organization' }).getByText('Organization') - } - } - export namespace members { - /** Find a "members" tab button. */ - export function locate(page: test.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: test.Page) { - await test.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) { - 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) { - 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 () => { - 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) { - 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) { - 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) { - 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) { - return locate(page) - .getByRole('group', { name: /^Confirm new password/, exact: true }) - .getByRole('textbox') - } - - /** Find a "save" button. */ - export function locateSaveButton(page: test.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 () => { - 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) { - return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') - } - - /** Find a "profile picture" input. */ - export function locateInput(page: test.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 () => { - 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: test.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) { - return locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox') - } - - /** Find an "email" input in the "organization" settings section. */ - export function locateEmailInput(page: test.Page) { - return locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox') - } - - /** Find an "website" input in the "organization" settings section. */ - export function locateWebsiteInput(page: test.Page) { - return locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox') - } - - /** Find an "location" input in the "organization" settings section. */ - export function locateLocationInput(page: test.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 () => { - 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: test.Page) { - return page.getByRole('heading').and(page.getByText('Profile Picture')).locator('..') - } - - /** Find a "profile picture" input. */ - export function locateInput(page: test.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 () => { - 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: test.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) { - 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: test.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: test.Locator) { - await test.test.step('Expect `selected`', async () => { - await test.expect(locator).toHaveClass(/(?:^| )selected(?: |$)/) - }) -} - -// ============================== -// === Other expect functions === -// ============================== - -/** 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() - }) -} - -/** 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() - }) -} - -// ========================== -// === Keyboard utilities === -// ========================== - -/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ -export async function modModifier(page: test.Page) { - let userAgent = '' - await test.test.step('Detect browser OS', async () => { - userAgent = await page.evaluate(() => navigator.userAgent) - }) - return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' -} - -/** - * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` - * on all other platforms. - */ -export async function press(page: test.Page, keyOrShortcut: string) { - await test.test.step(`Press '${keyOrShortcut}'`, async () => { - if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { - let userAgent = '' - await test.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 === -// =============================== - /** Get the path to the auth file. */ export function getAuthFilePath() { const __dirname = path.dirname(new URL(import.meta.url).pathname) @@ -684,79 +32,49 @@ export function getAuthFilePath() { } /** Perform a successful login. */ -export async function login( - { page, setupAPI }: MockParams, - email = 'email@example.com', - password = VALID_PASSWORD, -) { +async function login({ page }: MockParams, email = 'email@example.com', password = VALID_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.test.info().annotations.push({ + test.info().annotations.push({ type: 'skip', description: 'Already logged in', }) return } - return test.test.step('Login', async () => { - test.test.info().annotations.push({ + return test.step('Login', async () => { + test.info().annotations.push({ type: 'Login', description: 'Performing login', }) + 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 locateEmailInput(page).fill(email) - await locatePasswordInput(page).fill(password) - await locateLoginButton(page).click() - await passAgreementsDialog({ page, setupAPI }) + await expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible() + + await passAgreementsDialog({ page }) await page.context().storageState({ path: authFile }) }) } -/** - * Wait for the page to load. - */ -export async function waitForLoaded(page: test.Page) { +/** Wait for the page to load. */ +async function waitForLoaded(page: Page) { await page.waitForLoadState() - await test.expect(page.locator('[data-testid="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. - */ -export async function waitForDashboardToLoad(page: test.Page) { +/** Wait for the dashboard to load. */ +async function waitForDashboardToLoad(page: Page) { await waitForLoaded(page) - await test.expect(page.getByTestId('after-auth-layout')).toBeAttached() -} - -/** Reload. */ -export async function reload({ page }: MockParams) { - await test.test.step('Reload', async () => { - await page.reload() - await waitForLoaded(page) - }) -} - -/** Logout and then login again. */ -export async function relog( - { page, setupAPI }: MockParams, - email = 'email@example.com', - password = VALID_PASSWORD, -) { - await test.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) - }) + await expect(page.getByTestId('after-auth-layout')).toBeAttached() } /** A placeholder date for visual regression testing. */ @@ -764,14 +82,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) { @@ -791,7 +109,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 }) @@ -801,24 +119,33 @@ export async function passAgreementsDialog({ page }: MockParams) { .getByRole('group', { name: TEXT.privacyPolicyCheckbox }) .getByText(TEXT.privacyPolicyCheckbox) .click() - await page.getByRole('button', { name: 'Accept' }).click() + await page.getByRole('button', { name: TEXT.accept }).click() }) } -export const mockApi = apiModule.mockApi +interface Context { + readonly api: MockApi + calls: TrackedCalls +} /** Set up all mocks, without logging in. */ export function mockAll({ page, setupAPI }: MockParams) { - return new LoginPageActions(page) - .step('Execute all mocks', async () => { + 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([ - mockApi({ page, setupAPI }), - mockDate({ page, setupAPI }), + mockApi({ page, setupAPI }).then((api) => { + context.api = api + }), + mockDate({ page }), mockAllAnimations({ page }), mockUnneededUrls({ page }), ]) }) - .step('Navigate to the Root page', async () => { + .step('Navigate to the root page', async (page) => { await page.goto('/') await waitForLoaded(page) }) @@ -826,24 +153,22 @@ export function mockAll({ page, setupAPI }: MockParams) { /** Set up all mocks, and log in with dummy credentials. */ export function mockAllAndLogin({ page, setupAPI }: MockParams) { - return mockAll({ page, setupAPI }) - .step('Login', async () => { - await login({ page, setupAPI }) + const actions = mockAll({ page, setupAPI }) + return actions + .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. + const context = actions.context + await new StartModalActions(page, context).close() }) - .step('Wait for dashboard to load', async () => { - await waitForDashboardToLoad(page) - }) - .step('Check if start modal is shown', async () => { - await new StartModalActions(page).close() - }) - .into(DrivePageActions) + .into(DrivePageActions) } -/** - * Mock all animations. - */ -export async function mockAllAnimations({ page }: MockParams) { - await test.test.step('Mock all animations', async () => { +/** Mock all animations. */ +async function mockAllAnimations({ page }: MockParams) { + await test.step('Mock all animations', async () => { await page.addInitScript({ content: ` window.DISABLE_ANIMATIONS = true; @@ -855,15 +180,35 @@ 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) +/** Mock unneeded URLs. */ +async function mockUnneededUrls({ page }: MockParams) { + 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.test.step('Mock unneeded URLs', async () => { + await test.step('Mock unneeded URLs', async () => { 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() }), @@ -873,33 +218,59 @@ 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) => { await route.fulfill({ contentType: 'text/css', body: '' }) }), + + 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: 200, + headers: { + 'content-type': 'text/html', + }, + body: '', + }) + }), + + 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', + }, + }) + }), ]) }) } - -/** - * Set up all mocks, and log in with dummy credentials. - * @deprecated Prefer {@link mockAllAndLogin}. - */ -export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams) { - return await test.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 }) - await waitForDashboardToLoad(page) - await new StartModalActions(page).close() - - 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/actions/openUserMenuAction.ts b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts index 554a4f4225..02e4c73aec 100644 --- a/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts +++ b/app/gui/integration-test/dashboard/actions/openUserMenuAction.ts @@ -3,13 +3,9 @@ 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, +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/actions/userMenuActions.ts b/app/gui/integration-test/dashboard/actions/userMenuActions.ts index ec6f9d0d97..7517cd3c2f 100644 --- a/app/gui/integration-test/dashboard/actions/userMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/userMenuActions.ts @@ -1,49 +1,54 @@ /** @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 { TEXT } from '.' import type BaseActions from './BaseActions' +import type { PageCallback } 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 - readonly settings: () => SettingsPageActions - readonly logout: () => LoginPageActions - readonly goToLoginPage: () => LoginPageActions +export interface UserMenuActions, Context> { + readonly downloadApp: (callback: (download: Download) => Promise | void) => T + readonly settings: () => SettingsPageActions + readonly logout: () => LoginPageActions + readonly goToLoginPage: () => LoginPageActions } -// ======================= -// === userMenuActions === -// ======================= - /** 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: 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() + 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() - }).into(SettingsPageActions), + 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(), - ).into(LoginPageActions), + 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(), - ).into(LoginPageActions), + 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 9f1cc4d572..9282cf5737 100644 --- a/app/gui/integration-test/dashboard/assetPanel.spec.ts +++ b/app/gui/integration-test/dashboard/assetPanel.spec.ts @@ -1,15 +1,30 @@ /** @file Tests for the asset panel. */ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } 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 { mockAllAndLogin } from './actions' -// ================= -// === Constants === -// ================= +/** 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' @@ -18,13 +33,8 @@ 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 }) + mockAllAndLogin({ page }) .withAssetPanel(async (assetPanel) => { await expect(assetPanel).toBeVisible() }) @@ -34,50 +44,47 @@ test('open and close asset panel', ({ page }) => })) test('asset panel contents', ({ page }) => - actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - const { defaultOrganizationId, defaultUserId } = api - api.addProject({ - 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({ + 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 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() + // await expect(locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() })) -test('Asset Panel Documentation view', ({ page }) => { - return actions - .mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addProject({}) - }, - }) +test('Asset Panel documentation view', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({}) + }, + }) .driveTable.clickRow(0) .toggleDocsAssetPanel() .withAssetPanel(async (assetPanel) => { 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 83851365b8..5ca65d3559 100644 --- a/app/gui/integration-test/dashboard/assetSearchBar.spec.ts +++ b/app/gui/integration-test/dashboard/assetSearchBar.spec.ts @@ -1,71 +1,91 @@ /** @file Test the search bar and its suggestions. */ -import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import * as backend from '#/services/Backend' +import { COLORS } from '#/services/Backend' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' -test.test('tags (positive)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const searchBarInput = actions.locateSearchBarInput(page) - const tags = actions.locateSearchBarTags(page) +/** Find a search bar. */ +function locateSearchBar(page: Page) { + // This has no identifying features. + return page.getByTestId('asset-search-bar') +} - await searchBarInput.click() - for (const positiveTag of await tags.all()) { - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - const text = (await positiveTag.textContent()) ?? '' - test.expect(text.length).toBeGreaterThan(0) - await positiveTag.click() - await test.expect(searchBarInput).toHaveValue(text) - } -}) +/** Find a list of tags in the search bar. */ +function locateSearchBarTags(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-tag-names').getByRole('button') +} -test.test('tags (negative)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const searchBarInput = actions.locateSearchBarInput(page) - const tags = actions.locateSearchBarTags(page) +/** Find a list of labels in the search bar. */ +function locateSearchBarLabels(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-labels').getByRole('button') +} - 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()) ?? '' - test.expect(text.length).toBeGreaterThan(0) - await negativeTag.click() - await test.expect(searchBarInput).toHaveValue(text) - } -}) +/** Find a list of labels in the search bar. */ +function locateSearchBarSuggestions(page: Page) { + return locateSearchBar(page).getByTestId('asset-search-suggestion') +} -test.test('labels', async ({ page }) => { - await actions.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', 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) + }).withSearchBar(async (searchBar) => { + 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) - await label.click() - await test.expect(searchBarInput).toHaveValue('label:' + name) - await label.click() - await test.expect(searchBarInput).toHaveValue('-label:' + name) - await label.click() - await test.expect(searchBarInput).toHaveValue('') - } -}) + 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.test('suggestions', async ({ page }) => { - await actions.mockAllAndLogin({ +test('suggestions', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory({ title: 'foo' }) @@ -73,25 +93,23 @@ test.test('suggestions', async ({ page }) => { api.addSecret({ title: 'baz' }) api.addSecret({ title: 'quux' }) }, - }) + }).withSearchBar(async (searchBar) => { + const suggestions = locateSearchBarSuggestions(page) - const searchBarInput = actions.locateSearchBarInput(page) - const suggestions = actions.locateSearchBarSuggestions(page) + await searchBar.click() - 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(searchBar).toHaveValue('name:' + name) + await searchBar.selectText() + await searchBar.press('Backspace') + } + })) - for (const suggestion of await suggestions.all()) { - const name = (await suggestion.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) - await suggestion.click() - await test.expect(searchBarInput).toHaveValue('name:' + name) - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - } -}) - -test.test('suggestions (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ +test('suggestions (keyboard)', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { api.addDirectory({ title: 'foo' }) @@ -99,40 +117,34 @@ test.test('suggestions (keyboard)', async ({ page }) => { api.addSecret({ title: 'baz' }) api.addSecret({ title: 'quux' }) }, - }) + }).withSearchBar(async (searchBar) => { + const suggestions = locateSearchBarSuggestions(page) - const searchBarInput = actions.locateSearchBarInput(page) - const suggestions = actions.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) + } + })) - await searchBarInput.click() - for (const suggestion of await suggestions.all()) { - const name = (await suggestion.textContent()) ?? '' - test.expect(name.length).toBeGreaterThan(0) +test('complex flows', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory({ title: FIRST_ASSET_NAME }) + api.addProject({ title: 'bar' }) + api.addSecret({ title: 'baz' }) + api.addSecret({ title: 'quux' }) + }, + }).withSearchBar(async (searchBar) => { + await searchBar.click() await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + name) - } -}) - -test.test('complex flows', async ({ page }) => { - const firstName = 'foo' - - await actions.mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addDirectory({ title: firstName }) - api.addProject({ title: 'bar' }) - api.addSecret({ title: 'baz' }) - api.addSecret({ title: 'quux' }) - }, - }) - const searchBarInput = actions.locateSearchBarInput(page) - - await searchBarInput.click() - await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + firstName) - await searchBarInput.selectText() - await searchBarInput.press('Backspace') - await test.expect(searchBarInput).toHaveValue('') - await page.press('body', 'ArrowDown') - await test.expect(searchBarInput).toHaveValue('name:' + firstName) -}) + 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) + })) diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index 46ab233b55..2bf8a3bc4a 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -1,13 +1,37 @@ /** @file Test the drive view. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as actions 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 -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 @@ -20,25 +44,21 @@ test.test('extra columns should stick to right side of assets table', ({ page }) scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' }) }) }) - .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 }) - }), -) + .withAssetsTable(async (assetsTable, _, thePage) => { + const extraColumns = locateExtraColumns(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', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => { for (let i = 0; i < 100; i += 1) { @@ -46,25 +66,8 @@ test.test('extra columns should stick to top of scroll container', async ({ page } }, }) - - await actions.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 = 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) => { + .withAssetsTable(async (assetsTable) => { + await assetsTable.evaluate((element) => { let scrollableParent: HTMLElement | SVGElement | null = element while ( scrollableParent != null && @@ -72,29 +75,43 @@ test.test('extra columns should stick to top of scroll container', async ({ page ) { scrollableParent = scrollableParent.parentElement } - return scrollableParent?.getBoundingClientRect().top ?? 0 + scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' }) }) - test.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.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/auth.setup.ts b/app/gui/integration-test/dashboard/auth.setup.ts index 1cd6c08f78..1c2fca60f1 100644 --- a/app/gui/integration-test/dashboard/auth.setup.ts +++ b/app/gui/integration-test/dashboard/auth.setup.ts @@ -1,10 +1,12 @@ -import { test as setup } from '@playwright/test' import fs from 'node:fs' -import * as actions from './actions' + +import { test as setup } from '@playwright/test' + +import { getAuthFilePath, mockAllAndLogin } from './actions' setup('authenticate', ({ page }) => { - const authFilePath = actions.getAuthFilePath() + setup.slow() + const authFilePath = getAuthFilePath() setup.skip(fs.existsSync(authFilePath), 'Already authenticated') - - return actions.mockAllAndLogin({ page }) + return mockAllAndLogin({ page }) }) diff --git a/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts b/app/gui/integration-test/dashboard/authPreserveEmail.spec.ts index bee0e0bdd4..f2ef355a15 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/contextMenus.spec.ts b/app/gui/integration-test/dashboard/contextMenus.spec.ts index 7d73010b5e..b752fb064a 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) + })) diff --git a/app/gui/integration-test/dashboard/copy.spec.ts b/app/gui/integration-test/dashboard/copy.spec.ts index 132c86d874..b12431b9e7 100644 --- a/app/gui/integration-test/dashboard/copy.spec.ts +++ b/app/gui/integration-test/dashboard/copy.spec.ts @@ -1,11 +1,30 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' -test.test('copy', ({ page }) => - actions - .mockAllAndLogin({ page }) +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} + +/** 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 }) // Assets: [0: Folder 1] .createFolder() // Assets: [0: Folder 2, 1: Folder 1] @@ -17,18 +36,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 +57,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 +78,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 +95,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 +112,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 +132,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)', ({ 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() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 @@ -163,16 +166,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.locateContextMenu(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(locateContextMenu(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() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 @@ -183,8 +184,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 be482b8ec6..a9ae229e68 100644 --- a/app/gui/integration-test/dashboard/createAsset.spec.ts +++ b/app/gui/integration-test/dashboard/createAsset.spec.ts @@ -1,11 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ -import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import * as actions from './actions' - -// ================= -// === Constants === -// ================= +import { mockAllAndLogin } from './actions' /** The name of the uploaded file. */ const FILE_NAME = 'foo.txt' @@ -16,50 +12,45 @@ const SECRET_NAME = 'a secret name' /** The value of the created secret. */ const SECRET_VALUE = 'a secret value' -// ============= -// === Tests === -// ============= +/** Find an editor container. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function locateEditor(page: Page) { + // Test ID of a placeholder editor component used during testing. + return page.locator('.App') +} -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() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. - // .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 ed50465d4e..36c66dbd3d 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/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index d7752874af..c2bfd6d18d 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -1,39 +1,39 @@ /** @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() .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() + .expectStartModal() .withStartModal(async (startModal) => { - await test.expect(startModal).toBeVisible() + await expect(startModal).toBeVisible() }) - .closeGetStartedModal() + .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') @@ -43,17 +43,14 @@ 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') .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() - .withStartModal(async (startModal) => { - await test.expect(startModal).toBeVisible() - }) - .closeGetStartedModal() + .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/driveView.spec.ts b/app/gui/integration-test/dashboard/driveView.spec.ts index c0d626621f..883f54ba94 100644 --- a/app/gui/integration-test/dashboard/driveView.spec.ts +++ b/app/gui/integration-test/dashboard/driveView.spec.ts @@ -1,37 +1,49 @@ /** @file Test the drive view. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as actions from './actions' +import { TEXT, mockAllAndLogin } from './actions' -test.test('drive view', ({ page }) => - actions - .mockAllAndLogin({ page }) +/** Find an editor container. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +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. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function locateStopProjectButton(page: Locator) { + return page.getByLabel(TEXT.stopExecution) +} + +test('drive view', ({ page }) => + mockAllAndLogin({ page }) .withDriveView(async (view) => { - await test.expect(view).toBeVisible() + await expect(view).toBeVisible() }) .driveTable.expectPlaceholderRow() .newEmptyProject() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. // .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() + .withAssetsTable(async (assetsTable) => { + await expect(assetsTable).toBeVisible() }) .newEmptyProject() // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. // .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) }) // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // Uncomment once cloud execution in the browser is re-enabled. @@ -39,12 +51,11 @@ test.test('drive view', ({ page }) => // // 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 90f1a7ff95..bca1059965 100644 --- a/app/gui/integration-test/dashboard/editAssetName.spec.ts +++ b/app/gui/integration-test/dashboard/editAssetName.spec.ts @@ -1,129 +1,151 @@ /** @file Test copying, moving, cutting and pasting. */ -import { test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as actions from './actions' +import { TEXT, mockAllAndLogin } from './actions' -test('edit name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' +const NEW_NAME = 'foo bar baz' +const NEW_NAME_2 = 'foo bar baz quux' - 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 test.expect(row).toHaveText(new RegExp('^' + newName)) +/** Find the context menu. */ +function locateContextMenu(page: Page) { + // This has no identifying features. + return page.getByTestId('context-menu') +} + +/** 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, _, { 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 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, _, { api }) => { + const row = rows.nth(0) + await locateAssetRowName(row).click({ button: 'right' }) + await locateContextMenu(page) + .getByText(/Rename/) + .click() + const nameEl = locateAssetRowName(row) + await expect(nameEl).toBeVisible() + await expect(nameEl).toBeFocused() + await nameEl.fill(NEW_NAME) + await expect(nameEl).toHaveValue(NEW_NAME) + const calls = api.trackCalls() + await nameEl.press('Enter') + await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) + expect(calls.updateDirectory).toMatchObject([{ title: 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, _, { 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 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, _, { 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 expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + })) + +test('cancel editing name (keyboard)', ({ page }) => { + let oldName = '' + return mockAllAndLogin({ page }) + .createFolder() + .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) + oldName = (await nameEl.textContent()) ?? '' + await nameEl.fill(NEW_NAME_2) + const calls = api.trackCalls() + await nameEl.press('Escape') + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + }) }) -test('edit name (context menu)', async ({ page }) => { - await actions.mockAllAndLogin({ - page, - setupAPI: (api) => { - api.addAsset(api.createDirectory({ title: 'foo' })) - }, - }) +test('change to blank name (double click)', ({ page }) => + mockAllAndLogin({ page }) + .createFolder() + .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 expect(locateEditingTick(row)).not.toBeVisible() + const calls = api.trackCalls() + await locateEditingCross(row).click() + await expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + })) - const assetRows = actions.locateAssetRows(page) - const row = assetRows.nth(0) - const newName = 'foo bar baz' - - await actions.locateAssetRowName(row).click({ button: 'right' }) - await actions - .locateContextMenu(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 actions.mockAllAndLogin({ page }) - - const assetRows = actions.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 test.expect(row).toHaveText(new RegExp('^' + newName)) -}) - -test('cancel editing name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - - const assetRows = actions.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 actions.locateAssetRowName(row).fill(newName) - await actions.locateEditingCross(row).click() - await test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) - -test('cancel editing name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - - const assetRows = actions.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 test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) - -test('change to blank name (double click)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - - const assetRows = actions.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 test.expect(row).toHaveText(new RegExp('^' + oldName)) -}) - -test('change to blank name (keyboard)', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - - const assetRows = actions.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 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, _, { 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 expect(row).toHaveText(new RegExp('^' + oldName)) + expect(calls.updateDirectory).toMatchObject([]) + })) diff --git a/app/gui/integration-test/dashboard/labels.spec.ts b/app/gui/integration-test/dashboard/labels.spec.ts index aa6f44a096..00c15d1e8b 100644 --- a/app/gui/integration-test/dashboard/labels.spec.ts +++ b/app/gui/integration-test/dashboard/labels.spec.ts @@ -1,80 +1,90 @@ /** @file Test dragging of labels. */ -import * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import * as backend from '#/services/Backend' +import { COLORS } from '#/services/Backend' -import * as actions 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: test.Locator) { +async function clickAssetRow(assetRow: Locator) { await assetRow.click({ position: ASSET_ROW_SAFE_POSITION }) } -test.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({ title: 'foo' }) - api.addSecret({ title: 'bar' }) - api.addFile({ title: 'baz' }) - api.addSecret({ title: 'quux' }) - }, - }) - .do(async () => { - const assetRows = actions.locateAssetRows(page) - const labelEl = actions.locateLabelsPanelLabels(page, label) +/** Find labels in the "Labels" column of the assets table. */ +function locateAssetLabels(page: Locator) { + return page.getByTestId('asset-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() - }) -}) +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} -test.test('drag labels onto multiple rows', async ({ page }) => { - const label = 'aaaa' - await actions.mockAllAndLogin({ +/** 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, 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({ title: 'foo' }) + api.addSecret({ title: 'bar' }) + api.addFile({ title: 'baz' }) + api.addSecret({ title: 'quux' }) + }, + }).driveTable.withRows(async (rows, _, _context, page) => { + const labelEl = locateLabelsPanelLabels(page, LABEL) + await expect(labelEl).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', ({ page }) => + 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({ title: 'foo' }) api.addSecret({ title: 'bar' }) api.addFile({ title: 'baz' }) api.addSecret({ title: 'quux' }) }, }) - - const assetRows = actions.locateAssetRows(page) - const labelEl = actions.locateLabelsPanelLabels(page, label) - - await page.keyboard.down(await actions.modModifier(page)) - await test.expect(assetRows).toHaveCount(4) - await clickAssetRow(assetRows.nth(0)) - await clickAssetRow(assetRows.nth(2)) - await test.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() -}) + .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 bdc6f03e69..f38f220497 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 * as test from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' -import { - locateCreateButton, - locateLabelsPanel, - locateLabelsPanelLabels, - locateNewLabelButton, - locateNewLabelModal, - locateNewLabelModalColorButtons, - locateNewLabelModalNameInput, - mockAllAndLogin, - TEXT, -} from './actions' +import { mockAllAndLogin, TEXT } from './actions' -test.test.beforeEach(({ page }) => mockAllAndLogin({ page })) +/** Find a "new label" button. */ +function locateNewLabelButton(page: Page) { + return page.getByRole('button', { name: 'new label' }).getByText('new label') +} -test.test('labels', async ({ page }) => { - // Empty labels panel - await test.expect(locateLabelsPanel(page)).toBeVisible() +/** Find a labels panel. */ +function locateLabelsPanel(page: Page) { + // This has no identifying features. + return page.getByTestId('labels') +} - // "New Label" modal - await locateNewLabelButton(page).click() - await test.expect(locateNewLabelModal(page)).toBeVisible() +/** Find a "new label" modal. */ +function locateNewLabelModal(page: Page) { + // This has no identifying features. + return page.getByTestId('new-label-modal') +} - // "New Label" modal with name set - await locateNewLabelModalNameInput(page).fill('New Label') - await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/) +/** Find a "name" input for a "new label" modal. */ +function locateNewLabelModalNameInput(page: Page) { + return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox')) +} - await page.press('html', 'Escape') +/** 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]') + ) +} - // "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() - test.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() +/** Find a "create" button. */ +function locateCreateButton(page: Locator) { + return page.getByRole('button', { name: TEXT.create }).getByText(TEXT.create) +} - // "New Label" modal with name and color set - await locateNewLabelModalNameInput(page).fill('New Label') - await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/) +/** 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)')) + ) +} - // Labels panel with one entry - await locateCreateButton(locateNewLabelModal(page)).click() - await test.expect(locateLabelsPanel(page)).toBeVisible() +test('labels', ({ page }) => + mockAllAndLogin({ page }) + .do(async (page) => { + // Empty labels panel + await expect(locateLabelsPanel(page)).toBeVisible() - // Empty labels panel again, after deleting the only entry - await locateLabelsPanelLabels(page).first().hover() + // "New Label" modal + await locateNewLabelButton(page).click() + await expect(locateNewLabelModal(page)).toBeVisible() - 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) -}) + // "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 157865d096..c4660ff683 100644 --- a/app/gui/integration-test/dashboard/loginLogout.spec.ts +++ b/app/gui/integration-test/dashboard/loginLogout.spec.ts @@ -1,26 +1,36 @@ /** @file Test the login flow. */ -import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -import * as actions from './actions' +import { TEXT, mockAll } from './actions' -// ============= -// === Tests === -// ============= +/** 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.test.use({ storageState: { cookies: [], origins: [] } }) +test.use({ storageState: { cookies: [], origins: [] } }) -test.test('login and logout', ({ page }) => - actions - .mockAllAndLogin({ page }) +test('login and logout', ({ page }) => + mockAll({ page }) + .login() + .expectStartModal() + .close() + .withDriveView(async (driveView) => { + await expect(driveView).toBeVisible() + }) .do(async (thePage) => { - await test.expect(actions.locateDriveView(thePage)).toBeVisible() - await test.expect(actions.locateLoginButton(thePage)).not.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 ca0a5fb239..a537ba765f 100644 --- a/app/gui/integration-test/dashboard/loginScreen.spec.ts +++ b/app/gui/integration-test/dashboard/loginScreen.spec.ts @@ -1,16 +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' -// ============= -// === Tests === -// ============= - // 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: { @@ -22,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 6fd4e02160..bc14b55744 100644 --- a/app/gui/integration-test/dashboard/organizationSettings.spec.ts +++ b/app/gui/integration-test/dashboard/organizationSettings.spec.ts @@ -1,106 +1,101 @@ /** @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' -test.test('organization settings', async ({ page }) => { - const api = await actions.mockAllAndLoginAndExposeAPI({ +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' + +test('organization settings', ({ page }) => + mockAllAndLogin({ page, - setupAPI: (theApi) => { - theApi.setPlan(Plan.team) + setupAPI: (api) => { + api.setPlan(Plan.team) + api.setCurrentOrganization(api.defaultOrganization) }, }) - const localActions = actions.settings.organization - - // 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 - - 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) + .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) }) - .toPass() -}) + .goToPage.settings() + .goToSettingsTab.organization() + .organizationForm() + .fillName(NEW_NAME) + .do((_, context) => { + context.calls = context.api.trackCalls() + }) + .save() + .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, calls }) => { + expect(api.currentOrganization()?.name).toBe(NEW_NAME) + expect(calls.updateOrganization).toMatchObject([{ name: '' }]) + }) + .organizationForm() + .cancel() + .organizationForm() + .fillEmail(INVALID_EMAIL) + .save() + .step('Setting invalid email should fail', (_, { api }) => { + expect(api.currentOrganization()?.email).toBe('') + }) + .organizationForm() + .fillEmail(NEW_EMAIL) + .save() + .step('Set email', (_, { api }) => { + 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 }) => { + expect(api.currentOrganization()?.website).toBe(NEW_WEBSITE) + }) + .organizationForm() + .fillLocation(NEW_LOCATION) + .save() + .step('Set website', async (_, { api }) => { + expect(api.currentOrganization()?.address).toBe(NEW_LOCATION) + })) + +test('upload organization profile picture', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (theApi) => { + theApi.setPlan(Plan.team) + }, + }) + .goToPage.settings() + .goToSettingsTab.organization() + .uploadProfilePicture( + PROFILE_PICTURE_FILENAME, + PROFILE_PICTURE_CONTENT, + PROFILE_PICTURE_MIMETYPE, + ) + .step('Profile picture should be updated', async (_, { api }) => { + 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 31fa312001..f59e9cf3da 100644 --- a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts +++ b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts @@ -1,27 +1,37 @@ /** @file Test the login flow. */ -// import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -// import * as actions 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') +} // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Uncomment once cloud execution in the browser is re-enabled. -// test.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 test.expect(actions.locateDriveView(thePage)).not.toBeVisible() -// await test.expect(actions.locateEditor(thePage)).toBeVisible() -// }) -// .goToPage.drive() -// .do(async (thePage) => { -// await test.expect(actions.locateDriveView(thePage)).toBeVisible() -// await test.expect(actions.locateEditor(thePage)).not.toBeVisible() -// }) -// .goToPage.editor() -// .do(async (thePage) => { -// await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() -// await test.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/setup.spec.ts b/app/gui/integration-test/dashboard/setup.spec.ts index 711f419d7b..1ef76f9af1 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 6d9d6b5563..166e58e145 100644 --- a/app/gui/integration-test/dashboard/signUp.spec.ts +++ b/app/gui/integration-test/dashboard/signUp.spec.ts @@ -1,16 +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' -// ============= -// === Tests === -// ============= - // 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, { @@ -37,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 5847e6f6b0..ebcfdbdac5 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -1,43 +1,62 @@ /** @file Test sorting of assets columns. */ -import * as test from '@playwright/test' +import { expect, test, type Locator } from '@playwright/test' -import * as dateTime from '#/utilities/dateTime' +import { toRfc3339 } from '#/utilities/dateTime' -import * as actions from './actions' +import { mockAllAndLogin } from './actions' -// ================= -// === Constants === -// ================= +/** 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 -// ============= -// === Tests === -// ============= - -test.test('sort', async ({ page }) => { - await actions.mockAll({ +test('sort', ({ page }) => + mockAllAndLogin({ 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({ modifiedAt: date4, title: 'a directory' }) api.addDirectory({ modifiedAt: date6, title: 'G directory' }) api.addProject({ modifiedAt: date7, title: 'C project' }) + api.addSecret({ modifiedAt: date2, title: 'H secret' }) api.addProject({ modifiedAt: date1, title: 'b project' }) api.addFile({ modifiedAt: date8, title: 'd file' }) - api.addFile({ modifiedAt: date5, title: 'e file' }) - api.addSecret({ modifiedAt: date2, title: 'H secret' }) api.addSecret({ modifiedAt: date3, title: 'f secret' }) + api.addFile({ modifiedAt: date5, title: 'e file' }) // By date: // b project // h secret @@ -49,113 +68,135 @@ 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 }) - - // 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 Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^b project/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^e file/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.expect(assetRows.nth(7)).toHaveText(/^f secret/), - ]) - - // Sort by name ascending. - await nameHeading.click() - await actions.expectNotOpacity0(actions.locateSortAscendingIcon(nameHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^b project/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^d file/), - test.expect(assetRows.nth(4)).toHaveText(/^e file/), - test.expect(assetRows.nth(5)).toHaveText(/^f secret/), - test.expect(assetRows.nth(6)).toHaveText(/^G directory/), - test.expect(assetRows.nth(7)).toHaveText(/^H secret/), - ]) - - // Sort by name descending. - await nameHeading.click() - await actions.expectNotOpacity0(actions.locateSortDescendingIcon(nameHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^H secret/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^f secret/), - test.expect(assetRows.nth(3)).toHaveText(/^e file/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^C project/), - test.expect(assetRows.nth(6)).toHaveText(/^b project/), - test.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 Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^b project/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^e file/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.expect(assetRows.nth(7)).toHaveText(/^f secret/), - ]) - - // Sort by date ascending. - await modifiedHeading.click() - await actions.expectNotOpacity0(actions.locateSortAscendingIcon(modifiedHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^b project/), - test.expect(assetRows.nth(1)).toHaveText(/^H secret/), - test.expect(assetRows.nth(2)).toHaveText(/^f secret/), - test.expect(assetRows.nth(3)).toHaveText(/^a directory/), - test.expect(assetRows.nth(4)).toHaveText(/^e file/), - test.expect(assetRows.nth(5)).toHaveText(/^G directory/), - test.expect(assetRows.nth(6)).toHaveText(/^C project/), - test.expect(assetRows.nth(7)).toHaveText(/^d file/), - ]) - - // Sort by date descending. - await modifiedHeading.click() - await actions.expectNotOpacity0(actions.locateSortDescendingIcon(modifiedHeading)) - await Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^d file/), - test.expect(assetRows.nth(1)).toHaveText(/^C project/), - test.expect(assetRows.nth(2)).toHaveText(/^G directory/), - test.expect(assetRows.nth(3)).toHaveText(/^e file/), - test.expect(assetRows.nth(4)).toHaveText(/^a directory/), - test.expect(assetRows.nth(5)).toHaveText(/^f secret/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.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 Promise.all([ - test.expect(assetRows.nth(0)).toHaveText(/^a directory/), - test.expect(assetRows.nth(1)).toHaveText(/^G directory/), - test.expect(assetRows.nth(2)).toHaveText(/^C project/), - test.expect(assetRows.nth(3)).toHaveText(/^b project/), - test.expect(assetRows.nth(4)).toHaveText(/^d file/), - test.expect(assetRows.nth(5)).toHaveText(/^e file/), - test.expect(assetRows.nth(6)).toHaveText(/^H secret/), - test.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).toHaveText([ + /^a directory/, + /^G directory/, + /^C project/, + /^b project/, + /^d file/, + /^e file/, + /^H secret/, + /^f secret/, + ]) + }) + // Sort by name ascending. + .driveTable.clickNameColumnHeading() + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectNotOpacity0(locateSortAscendingIcon(nameHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^a directory/, + /^b project/, + /^C project/, + /^d file/, + /^e file/, + /^f secret/, + /^G directory/, + /^H secret/, + ]) + }) + // Sort by name descending. + .driveTable.clickNameColumnHeading() + .driveTable.withNameColumnHeading(async (nameHeading) => { + await expectNotOpacity0(locateSortDescendingIcon(nameHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^H secret/, + /^G directory/, + /^f secret/, + /^e file/, + /^d file/, + /^C project/, + /^b project/, + /^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 expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible() + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^a directory/, + /^G directory/, + /^C project/, + /^b project/, + /^d file/, + /^e file/, + /^H secret/, + /^f secret/, + ]) + }) + // Sort by date ascending. + .driveTable.clickModifiedColumnHeading() + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectNotOpacity0(locateSortAscendingIcon(modifiedHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^b project/, + /^H secret/, + /^f secret/, + /^a directory/, + /^e file/, + /^G directory/, + /^C project/, + /^d file/, + ]) + }) + // Sort by date descending. + .driveTable.clickModifiedColumnHeading() + .driveTable.withModifiedColumnHeading(async (modifiedHeading) => { + await expectNotOpacity0(locateSortDescendingIcon(modifiedHeading)) + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveText([ + /^d file/, + /^C project/, + /^G directory/, + /^e file/, + /^a directory/, + /^f secret/, + /^H secret/, + /^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).toHaveText([ + /^a directory/, + /^G directory/, + /^C project/, + /^b project/, + /^d file/, + /^e file/, + /^H secret/, + /^f secret/, + ]) + })) diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index f8ee96816a..411825adb9 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -1,17 +1,34 @@ /** @file Test the "change password" modal. */ -// import * as test from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' -// import * as actions 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') +} // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Uncomment once cloud execution in the browser is re-enabled. -// test.test('create project from template', ({ page }) => -// actions -// .mockAllAndLogin({ page }) -// .openStartModal() -// .createProjectFromTemplate(0) -// .do(async (thePage) => { -// await test.expect(actions.locateEditor(thePage)).toBeAttached() -// await test.expect(actions.locateSamples(page).first()).not.toBeVisible() -// }), -// ) +// Unskip once cloud execution in the browser is re-enabled. + +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() + })) diff --git a/app/gui/integration-test/dashboard/userSettings.spec.ts b/app/gui/integration-test/dashboard/userSettings.spec.ts index d491940f0f..83a1793721 100644 --- a/app/gui/integration-test/dashboard/userSettings.spec.ts +++ b/app/gui/integration-test/dashboard/userSettings.spec.ts @@ -1,89 +1,80 @@ /** @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' -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!' + VALID_PASSWORD +const PROFILE_PICTURE_FILENAME = 'foo.png' +const PROFILE_PICTURE_CONTENT = 'a profile picture' +const PROFILE_PICTURE_MIMETYPE = 'image/png' - 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('user settings', ({ page }) => + mockAllAndLogin({ page }) + .do((_, { api }) => { + expect(api.currentUser()?.name).toBe(api.defaultName) + }) + .goToPage.settings() + .accountForm() + .fillName(NEW_USERNAME) + .save() + .do((_, { api }) => { + expect(api.currentUser()?.name).toBe(NEW_USERNAME) + 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) +test('change password form', ({ page }) => + mockAllAndLogin({ page }) + .do((_, { api }) => { + expect(api.currentPassword()).toBe(VALID_PASSWORD) + }) + .goToPage.settings() + .changePasswordForm() + .fillCurrentPassword(VALID_PASSWORD) + .fillNewPassword(INVALID_PASSWORD) + .fillConfirmNewPassword(INVALID_PASSWORD) + .save() + .step('Invalid new password should fail', async (page) => { + await expect( + 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) + ).toHaveText(TEXT.passwordValidationError) + }) + .changePasswordForm() + .fillCurrentPassword(VALID_PASSWORD) + .fillNewPassword(VALID_PASSWORD) + .fillConfirmNewPassword(VALID_PASSWORD + 'a') + .save() + .step('Invalid new password confirmation should fail', async (page) => { + await expect( + 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) - }) -}) - -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) + ).toHaveText(TEXT.passwordMismatchError) }) - .toPass() -}) + .changePasswordForm() + .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 }) => { + expect(api.currentPassword()).toBe(NEW_PASSWORD) + })) + +test('upload profile picture', ({ page }) => + mockAllAndLogin({ page }) + .goToPage.settings() + .uploadProfilePicture( + PROFILE_PICTURE_FILENAME, + PROFILE_PICTURE_CONTENT, + PROFILE_PICTURE_MIMETYPE, + ) + .step('Profile picture should be updated', async (_, { api }) => { + await expect(() => { + expect(api.currentProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT) + }).toPass() + })) diff --git a/app/gui/src/dashboard/layouts/Settings/OrganizationProfilePictureInput.tsx b/app/gui/src/dashboard/layouts/Settings/OrganizationProfilePictureInput.tsx index 996f98b94a..d94d8a8140 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 ( <> - + - +