mirror of
https://github.com/enso-org/enso.git
synced 2024-11-29 16:57:44 +03:00
Refactor E2E tests for Dashboard; add E2E tests for User and Organization settings pages (#10031)
- PARTIALLY implements https://github.com/enso-org/cloud-v2/issues/1232 - Partially refactor E2E test actions into a state machine. - The main goal of this is to _disallow_ invalid actions - for example going from a page to itself, which will fail at runtime, or trying to create a new Data Link on a page where that button is not accessible. - An auxiliary goal is to have better namespacing of actions and better clarity: - Previously, everything was a locator at the top level of a single module. This makes it very difficult to comprehend what kinds of actions are available. - Note: There is also older `namespace`-based namespacing for the User and Organization settings pages, which were added before this refactor. They SHOULD be refactored to the new API, but I'm not sure whether it's worth spending the time right now. - Add E2E tests for every input on the "user" settings page and the "organization" settings page. - A skeletal E2E test for the Datalink modal has also been added - it does not actually test anything currently but should be sufficient for building upon. # Important Notes None
This commit is contained in:
parent
c5853e0ffc
commit
83ec24da59
53
app/ide-desktop/lib/dashboard/e2e/README.md
Normal file
53
app/ide-desktop/lib/dashboard/e2e/README.md
Normal file
@ -0,0 +1,53 @@
|
||||
# End-to-end tests
|
||||
|
||||
## Running tests
|
||||
|
||||
Execute all commands from the parent directory.
|
||||
|
||||
```sh
|
||||
# Run tests normally
|
||||
npm run test:e2e
|
||||
# Open UI to run tests
|
||||
npm run test:e2e:debug
|
||||
# Run tests in a specific file only
|
||||
npm run test:e2e -- e2e/file-name-here.spec.ts
|
||||
npm run test:e2e:debug -- e2e/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 npm run test:e2e
|
||||
PROD=1 npm run test:e2e:debug
|
||||
PROD=1 npm run test:e2e -- e2e/file-name-here.spec.ts
|
||||
PROD=1 npm run test:e2e:debug -- e2e/file-name-here.spec.ts
|
||||
```
|
||||
|
||||
## 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.goToHomePage(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### 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");
|
||||
}),
|
||||
),
|
||||
);
|
||||
```
|
@ -1,8 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
/** @file Various actions, locators, and constants used in end-to-end tests. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import DrivePageActions from './actions/DrivePageActions'
|
||||
import LoginPageActions from './actions/LoginPageActions'
|
||||
import * as apiModule from './api'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -35,21 +40,6 @@ export function locateConfirmPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Confirm your password')
|
||||
}
|
||||
|
||||
/** Find a "current password" input (if any) on the current page. */
|
||||
export function locateCurrentPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Enter your current password')
|
||||
}
|
||||
|
||||
/** Find a "new password" input (if any) on the current page. */
|
||||
export function locateNewPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Enter your new password')
|
||||
}
|
||||
|
||||
/** Find a "confirm new password" input (if any) on the current page. */
|
||||
export function locateConfirmNewPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Confirm your new password')
|
||||
}
|
||||
|
||||
/** Find a "username" input (if any) on the current page. */
|
||||
export function locateUsernameInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Enter your username')
|
||||
@ -109,21 +99,6 @@ export function locateRegisterButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Register' }).getByText('Register')
|
||||
}
|
||||
|
||||
/** Find a "change" button (if any) on the current locator. */
|
||||
export function locateChangeButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Change' }).getByText('Change')
|
||||
}
|
||||
|
||||
/** Find a user menu button (if any) on the current locator. */
|
||||
export function locateUserMenuButton(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('User Settings').locator('visible=true')
|
||||
}
|
||||
|
||||
/** Find a "sign out" button (if any) on the current locator. */
|
||||
export function locateLogoutButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Logout' }).getByText('Logout')
|
||||
}
|
||||
|
||||
/** Find a "set username" button (if any) on the current page. */
|
||||
export function locateSetUsernameButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Set Username' }).getByText('Set Username')
|
||||
@ -165,21 +140,6 @@ export function locateLabelsPanelLabels(page: test.Page) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Find a "cloud" category button (if any) on the current page. */
|
||||
export function locateCloudButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Cloud' }).getByText('Cloud')
|
||||
}
|
||||
|
||||
/** Find a "local" category button (if any) on the current page. */
|
||||
export function locateLocalButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Local' }).getByText('Local')
|
||||
}
|
||||
|
||||
/** Find a "trash" button (if any) on the current page. */
|
||||
export function locateTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Trash' }).getByText('Trash')
|
||||
}
|
||||
|
||||
/** Find a tick button (if any) on the current page. */
|
||||
export function locateEditingTick(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Confirm Edit')
|
||||
@ -245,121 +205,7 @@ export function locateTrashCategory(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Trash').locator('visible=true')
|
||||
}
|
||||
|
||||
// === Context menu buttons ===
|
||||
|
||||
/** Find an "open" button (if any) on the current page. */
|
||||
export function locateOpenButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Open' }).getByText('Open')
|
||||
}
|
||||
|
||||
/** Find an "upload to cloud" button (if any) on the current page. */
|
||||
export function locateUploadToCloudButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Upload To Cloud' }).getByText('Upload To Cloud')
|
||||
}
|
||||
|
||||
/** Find a "rename" button (if any) on the current page. */
|
||||
export function locateRenameButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Rename' }).getByText('Rename')
|
||||
}
|
||||
|
||||
/** Find a "snapshot" button (if any) on the current page. */
|
||||
export function locateSnapshotButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Snapshot' }).getByText('Snapshot')
|
||||
}
|
||||
|
||||
/** Find a "move to trash" button (if any) on the current page. */
|
||||
export function locateMoveToTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Move To Trash' }).getByText('Move To Trash')
|
||||
}
|
||||
|
||||
/** Find a "move all to trash" button (if any) on the current page. */
|
||||
export function locateMoveAllToTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Move All To Trash' }).getByText('Move All To Trash')
|
||||
}
|
||||
|
||||
/** Find a "restore from trash" button (if any) on the current page. */
|
||||
export function locateRestoreFromTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Restore From Trash' }).getByText('Restore From Trash')
|
||||
}
|
||||
|
||||
/** Find a "restore all from trash" button (if any) on the current page. */
|
||||
export function locateRestoreAllFromTrashButton(page: test.Locator | test.Page) {
|
||||
return page
|
||||
.getByRole('button', { name: 'Restore All From Trash' })
|
||||
.getByText('Restore All From Trash')
|
||||
}
|
||||
|
||||
/** Find a "share" button (if any) on the current page. */
|
||||
export function locateShareButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Share' }).getByText('Share')
|
||||
}
|
||||
|
||||
/** Find a "label" button (if any) on the current page. */
|
||||
export function locateLabelButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Label' }).getByText('Label')
|
||||
}
|
||||
|
||||
/** Find a "duplicate" button (if any) on the current page. */
|
||||
export function locateDuplicateButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate')
|
||||
}
|
||||
|
||||
/** Find a "copy" button (if any) on the current page. */
|
||||
export function locateCopyButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Copy' }).getByText('Copy')
|
||||
}
|
||||
|
||||
/** Find a "cut" button (if any) on the current page. */
|
||||
export function locateCutButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Cut' }).getByText('Cut')
|
||||
}
|
||||
|
||||
/** Find a "paste" button (if any) on the current page. */
|
||||
export function locatePasteButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Paste' }).getByText('Paste')
|
||||
}
|
||||
|
||||
/** Find a "download" button (if any) on the current page. */
|
||||
export function locateDownloadButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Download' }).getByText('Download')
|
||||
}
|
||||
|
||||
/** Find a "download app" button (if any) on the current page. */
|
||||
export function locateDownloadAppButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Download App' }).getByText('Download App')
|
||||
}
|
||||
|
||||
/** Find an "upload files" button (if any) on the current page. */
|
||||
export function locateUploadFilesButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Upload Files' }).getByText('Upload Files')
|
||||
}
|
||||
|
||||
/** Find a "start modal" button (if any) on the current page. */
|
||||
export function locateStartModalButton(page: test.Locator | test.Page) {
|
||||
return page
|
||||
.getByRole('button', { name: 'Start with a template' })
|
||||
.getByText('Start with a template')
|
||||
}
|
||||
|
||||
/** Find a "new project" button (if any) on the current page. */
|
||||
export function locateNewProjectButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Empty Project' }).getByText('New Empty Project')
|
||||
}
|
||||
|
||||
/** Find a "new folder" button (if any) on the current page. */
|
||||
export function locateNewFolderButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Folder' }).getByText('New Folder')
|
||||
}
|
||||
|
||||
/** Find a "new secret" button (if any) on the current page. */
|
||||
export function locateNewSecretButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Secret' }).getByText('New Secret')
|
||||
}
|
||||
|
||||
/** Find a "new data connector" button (if any) on the current page. */
|
||||
export function locateNewDataConnectorButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Data Connector' }).getByText('New Data Connector')
|
||||
}
|
||||
// === Other buttons ===
|
||||
|
||||
/** Find a "new label" button (if any) on the current page. */
|
||||
export function locateNewLabelButton(page: test.Locator | test.Page) {
|
||||
@ -371,9 +217,7 @@ 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.
|
||||
*/
|
||||
/** 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')
|
||||
}
|
||||
@ -388,21 +232,11 @@ export function locateNewSecretIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button').filter({ has: page.getByAltText('New Secret') })
|
||||
}
|
||||
|
||||
/** Find an "upload files" icon (if any) on the current page. */
|
||||
export function locateUploadFilesIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button').filter({ has: page.getByAltText('Import') })
|
||||
}
|
||||
|
||||
/** Find a "download files" icon (if any) on the current page. */
|
||||
export function locateDownloadFilesIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button').filter({ has: page.getByAltText('Export') })
|
||||
}
|
||||
|
||||
/** Find an icon to open or close the asset panel (if any) on the current page. */
|
||||
export function locateAssetPanelIcon(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Asset Panel').locator('visible=true')
|
||||
}
|
||||
|
||||
/** 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')
|
||||
@ -418,6 +252,11 @@ export function locateSearchBarSuggestions(page: test.Page) {
|
||||
return locateSearchBar(page).getByTestId('asset-search-suggestion')
|
||||
}
|
||||
|
||||
/** Find a "home page" icon (if any) on the current page. */
|
||||
export function locateHomePageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button').filter({ has: page.getByAltText('Home') })
|
||||
}
|
||||
|
||||
// === Icon locators ===
|
||||
|
||||
// These are specifically icons that are not also buttons.
|
||||
@ -433,26 +272,14 @@ export function locateSortDescendingIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Sort Descending')
|
||||
}
|
||||
|
||||
// === Page locators ===
|
||||
|
||||
/** Find a "drive page" icon (if any) on the current page. */
|
||||
export function locateDrivePageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Data Catalog' })
|
||||
}
|
||||
|
||||
/** Find an "editor page" icon (if any) on the current page. */
|
||||
export function locateEditorPageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Spatial Analysis' })
|
||||
}
|
||||
|
||||
/** Find a "settings page" icon (if any) on the current page. */
|
||||
export function locateSettingsPageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button').filter({ has: page.getByAltText('Settings') })
|
||||
}
|
||||
// === 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'))
|
||||
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. */
|
||||
@ -460,6 +287,7 @@ 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 ===
|
||||
@ -582,7 +410,7 @@ export function locateLabelsList(page: test.Page) {
|
||||
/** 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')
|
||||
return page.getByTestId('asset-panel').locator('visible=true')
|
||||
}
|
||||
|
||||
/** Find a search bar (if any) on the current page. */
|
||||
@ -619,6 +447,171 @@ export function locateAssetPanelPermissions(page: test.Page) {
|
||||
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 press(page, 'Mod+,')
|
||||
})
|
||||
}
|
||||
|
||||
/** 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('Name')
|
||||
}
|
||||
}
|
||||
|
||||
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 press(page, 'Mod+,')
|
||||
})
|
||||
}
|
||||
|
||||
/** 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).getByLabel('Current password')
|
||||
}
|
||||
|
||||
/** Find a "new password" input in the "user account" settings section. */
|
||||
export function locateNewPasswordInput(page: test.Page) {
|
||||
return locate(page).getByLabel('New password', { exact: true })
|
||||
}
|
||||
|
||||
/** Find a "confirm new password" input in the "user account" settings section. */
|
||||
export function locateConfirmNewPasswordInput(page: test.Page) {
|
||||
return locate(page).getByLabel('Confirm new password')
|
||||
}
|
||||
|
||||
/** Find a "change" button. */
|
||||
export function locateChangeButton(page: test.Page) {
|
||||
return locate(page).getByRole('button', { name: 'Change' }).getByText('Change')
|
||||
}
|
||||
}
|
||||
|
||||
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 press(page, 'Mod+,')
|
||||
})
|
||||
}
|
||||
|
||||
/** 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 press(page, 'Mod+,')
|
||||
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('Organization display name')
|
||||
}
|
||||
|
||||
/** Find an "email" input in the "organization" settings section. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
export function locateEmailInput(page: test.Page) {
|
||||
return locate(page).getByLabel('Email')
|
||||
}
|
||||
|
||||
/** Find an "website" input in the "organization" settings section. */
|
||||
export function locateWebsiteInput(page: test.Page) {
|
||||
return locate(page).getByLabel('Website')
|
||||
}
|
||||
|
||||
/** Find an "location" input in the "organization" settings section. */
|
||||
export function locateLocationInput(page: test.Page) {
|
||||
return locate(page).getByLabel('Location')
|
||||
}
|
||||
}
|
||||
|
||||
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 press(page, 'Mod+,')
|
||||
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 press(page, 'Mod+,')
|
||||
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 ===
|
||||
// ===============================
|
||||
@ -631,7 +624,7 @@ export function getAssetRowLeftPx(locator: test.Locator) {
|
||||
}
|
||||
|
||||
// ===================================
|
||||
// === expect functions for themes ===
|
||||
// === Expect functions for themes ===
|
||||
// ===================================
|
||||
|
||||
/** A test assertion to confirm that the element has the class `selected`. */
|
||||
@ -641,45 +634,29 @@ export async function expectClassSelected(locator: test.Locator) {
|
||||
})
|
||||
}
|
||||
|
||||
/** A test assertion to confirm that the element has the class `selected`. */
|
||||
export async function expectNotTransparent(locator: test.Locator) {
|
||||
await test.test.step('expect.not.transparent', async () => {
|
||||
await test.expect
|
||||
.poll(() => locator.evaluate(element => getComputedStyle(element).opacity))
|
||||
.not.toBe('0')
|
||||
// ==============================
|
||||
// === 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 has the class `selected`. */
|
||||
export async function expectTransparent(locator: test.Locator) {
|
||||
await test.test.step('expect.transparent', async () => {
|
||||
await test.expect
|
||||
.poll(() => locator.evaluate(element => getComputedStyle(element).opacity))
|
||||
.toBe('0')
|
||||
})
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === expectPlaceholderRow ===
|
||||
// ============================
|
||||
|
||||
/** A test assertion to confirm that there is only one row visible, and that row is the
|
||||
* placeholder row displayed when there are no assets to show. */
|
||||
export async function expectPlaceholderRow(page: test.Page) {
|
||||
const assetRows = locateAssetRows(page)
|
||||
await test.test.step('Expect placeholder row', async () => {
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows).toHaveText(/You have no files/)
|
||||
})
|
||||
}
|
||||
|
||||
/** A test assertion to confirm that there is only one row visible, and that row is the
|
||||
* placeholder row displayed when there are no assets in Trash. */
|
||||
export async function expectTrashPlaceholderRow(page: test.Page) {
|
||||
const assetRows = locateAssetRows(page)
|
||||
await test.test.step('Expect trash placeholder row', async () => {
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows).toHaveText(/Your trash is empty/)
|
||||
/** 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()
|
||||
})
|
||||
}
|
||||
|
||||
@ -688,7 +665,7 @@ export async function expectTrashPlaceholderRow(page: test.Page) {
|
||||
// =======================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
|
||||
export const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
|
||||
|
||||
/** Click an asset row. The center must not be clicked as that is the button for adding a label. */
|
||||
export async function clickAssetRow(assetRow: test.Locator) {
|
||||
@ -727,19 +704,21 @@ export async function modModifier(page: test.Page) {
|
||||
/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
|
||||
* on all other platforms. */
|
||||
export async function press(page: test.Page, keyOrShortcut: string) {
|
||||
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 test.test.step(`Press '${shortcut}'`, () => page.keyboard.press(shortcut))
|
||||
} else {
|
||||
await page.keyboard.press(keyOrShortcut)
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============
|
||||
@ -754,12 +733,31 @@ export async function login(
|
||||
email = 'email@example.com',
|
||||
password = VALID_PASSWORD
|
||||
) {
|
||||
await page.goto('/')
|
||||
await locateEmailInput(page).fill(email)
|
||||
await locatePasswordInput(page).fill(password)
|
||||
await locateLoginButton(page).click()
|
||||
await locateToastCloseButton(page).click()
|
||||
await passTermsAndConditionsDialog({ page })
|
||||
await test.test.step('Login', async () => {
|
||||
await page.goto('/')
|
||||
await locateEmailInput(page).fill(email)
|
||||
await locatePasswordInput(page).fill(password)
|
||||
await locateLoginButton(page).click()
|
||||
await locateToastCloseButton(page).click()
|
||||
await passTermsAndConditionsDialog({ page })
|
||||
})
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// === mockIsInPlaywrightTest ===
|
||||
// ==============================
|
||||
|
||||
/** Inject `isInPlaywrightTest` into the page. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockIsInPlaywrightTest({ page }: MockParams) {
|
||||
await test.test.step('Mock `isInPlaywrightTest`', async () => {
|
||||
await page.evaluate(() => {
|
||||
// @ts-expect-error This is SAFE - it is a mistake for this variable to be written to
|
||||
// from anywhere else.
|
||||
window.isInPlaywrightTest = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ================
|
||||
@ -779,7 +777,8 @@ interface MockParams {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async function mockDate({ page }: MockParams) {
|
||||
// https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728
|
||||
await page.addInitScript(`{
|
||||
await test.test.step('Mock Date', async () => {
|
||||
await page.addInitScript(`{
|
||||
Date = class extends Date {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) {
|
||||
@ -793,20 +792,21 @@ async function mockDate({ page }: MockParams) {
|
||||
const __DateNow = Date.now;
|
||||
Date.now = () => __DateNow() + __DateNowOffset;
|
||||
}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Passes Terms and conditions dialog
|
||||
*/
|
||||
/** Pass the Terms and conditions dialog. */
|
||||
export async function passTermsAndConditionsDialog({ page }: MockParams) {
|
||||
// wait for terms and conditions dialog to appear
|
||||
// but don't fail if it doesn't appear
|
||||
try {
|
||||
// wait for terms and conditions dialog to appear
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
await page.waitForSelector('#terms-of-service-modal', { timeout: 500 })
|
||||
await page.getByRole('checkbox').click()
|
||||
await page.getByRole('button', { name: 'Accept' }).click()
|
||||
await test.test.step('Accept Terms and Conditions', async () => {
|
||||
// wait for terms and conditions dialog to appear
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
await page.waitForSelector('#terms-of-service-modal', { timeout: 500 })
|
||||
await page.getByRole('checkbox').click()
|
||||
await page.getByRole('button', { name: 'Accept' }).click()
|
||||
})
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
@ -828,9 +828,12 @@ export const mockApi = apiModule.mockApi
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockAll({ page }: MockParams) {
|
||||
const api = await mockApi({ page })
|
||||
await mockDate({ page })
|
||||
return { api }
|
||||
return await test.test.step('Execute all mocks', async () => {
|
||||
const api = await mockApi({ page })
|
||||
await mockIsInPlaywrightTest({ page })
|
||||
await mockDate({ page })
|
||||
return { api, pageActions: new LoginPageActions(page) }
|
||||
})
|
||||
}
|
||||
|
||||
// =======================
|
||||
@ -841,8 +844,13 @@ export async function mockAll({ page }: MockParams) {
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockAllAndLogin({ page }: MockParams) {
|
||||
const mocks = await mockAll({ page })
|
||||
await login({ page })
|
||||
await passTermsAndConditionsDialog({ page })
|
||||
return mocks
|
||||
return await test.test.step('Execute all mocks and login', async () => {
|
||||
const mocks = await mockAll({ page })
|
||||
await login({ page })
|
||||
await passTermsAndConditionsDialog({ page })
|
||||
// This MUST run after login because globals are reset when the browser
|
||||
// is navigated to another page.
|
||||
await mockIsInPlaywrightTest({ page })
|
||||
return { ...mocks, pageActions: new DrivePageActions(page) }
|
||||
})
|
||||
}
|
||||
|
110
app/ide-desktop/lib/dashboard/e2e/actions/BaseActions.ts
Normal file
110
app/ide-desktop/lib/dashboard/e2e/actions/BaseActions.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/** @file The base class from which all `Actions` classes are derived. */
|
||||
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
// ====================
|
||||
// === PageCallback ===
|
||||
// ====================
|
||||
|
||||
/** A callback that performs actions on a {@link test.Page}. */
|
||||
export interface PageCallback {
|
||||
(input: test.Page): Promise<void> | void
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === LocatorCallback ===
|
||||
// =======================
|
||||
|
||||
/** A callback that performs actions on a {@link test.Locator}. */
|
||||
export interface LocatorCallback {
|
||||
(input: test.Locator): Promise<void> | void
|
||||
}
|
||||
|
||||
// ===================
|
||||
// === BaseActions ===
|
||||
// ===================
|
||||
|
||||
/** The base class from which all `Actions` classes are derived.
|
||||
* It contains method common to all `Actions` subclasses.
|
||||
* This is a [`thenable`], so it can be used as if it was a {@link Promise}.
|
||||
*
|
||||
* [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables
|
||||
*/
|
||||
export default class BaseActions implements PromiseLike<void> {
|
||||
/** Create a {@link BaseActions}. */
|
||||
constructor(
|
||||
protected readonly page: test.Page,
|
||||
private readonly promise = Promise.resolve()
|
||||
) {}
|
||||
|
||||
/** 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<void> {
|
||||
return 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Proxies the `then` method of the internal {@link Promise}. */
|
||||
async then<T, E>(
|
||||
// The following types are copied almost verbatim from the type definitions for `Promise`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onfulfilled?: (() => PromiseLike<T> | T) | null | undefined,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onrejected?: ((reason: unknown) => E | PromiseLike<E>) | null | undefined
|
||||
) {
|
||||
return await this.promise.then(onfulfilled, onrejected)
|
||||
}
|
||||
|
||||
/** Proxies the `catch` method of the internal {@link Promise}.
|
||||
* This method is not required for this to be a `thenable`, but it is still useful
|
||||
* to treat this class as a {@link Promise}. */
|
||||
// The following types are copied almost verbatim from the type definitions for `Promise`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async catch<T>(onrejected?: ((reason: unknown) => T) | null | undefined) {
|
||||
return await this.promise.catch(onrejected)
|
||||
}
|
||||
|
||||
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
|
||||
into<T extends new (page: test.Page, promise: Promise<void>) => InstanceType<T>>(
|
||||
clazz: T
|
||||
): InstanceType<T> {
|
||||
return new clazz(this.page, this.promise)
|
||||
}
|
||||
|
||||
/** Perform an action on the current page. 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 {
|
||||
// @ts-expect-error This is SAFE, but only when the constructor of this class has the exact
|
||||
// same parameters as `BaseActions`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return new this.constructor(
|
||||
this.page,
|
||||
this.then(() => callback(this.page))
|
||||
)
|
||||
}
|
||||
|
||||
/** Perform an action on the current page. */
|
||||
step(name: string, callback: PageCallback): this {
|
||||
return this.do(() => test.test.step(name, () => callback(this.page)))
|
||||
}
|
||||
|
||||
/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
|
||||
* on all other platforms. */
|
||||
press(keyOrShortcut: string): this {
|
||||
return this.do(page => BaseActions.press(page, keyOrShortcut))
|
||||
}
|
||||
}
|
225
app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts
Normal file
225
app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts
Normal file
@ -0,0 +1,225 @@
|
||||
/** @file Actions for the "drive" page. */
|
||||
import * as test from 'playwright/test'
|
||||
|
||||
import * as actions from '../actions'
|
||||
import type * as baseActions from './BaseActions'
|
||||
import BaseActions from './BaseActions'
|
||||
import * as contextMenuActions from './contextMenuActions'
|
||||
import EditorPageActions from './EditorPageActions'
|
||||
import * as goToPageActions from './goToPageActions'
|
||||
import NewDataLinkModalActions from './NewDataLinkModalActions'
|
||||
import * as openUserMenuAction from './openUserMenuAction'
|
||||
import StartModalActions from './StartModalActions'
|
||||
import * as userMenuActions from './userMenuActions'
|
||||
|
||||
// ========================
|
||||
// === DrivePageActions ===
|
||||
// ========================
|
||||
|
||||
/** Actions for the "drive" page. */
|
||||
export default class DrivePageActions extends BaseActions {
|
||||
/** Actions for navigating to another page. */
|
||||
get goToPage(): Omit<goToPageActions.GoToPageActions, 'drive'> {
|
||||
return goToPageActions.goToPageActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to the User Menu. */
|
||||
get userMenu() {
|
||||
return userMenuActions.userMenuActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to context menus. */
|
||||
get contextMenu() {
|
||||
return contextMenuActions.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
|
||||
return {
|
||||
/** Switch to the "cloud" category. */
|
||||
cloud() {
|
||||
return self.step('Go to "Cloud" category', page =>
|
||||
page.getByRole('button', { name: 'Cloud' }).getByText('Cloud').click()
|
||||
)
|
||||
},
|
||||
/** Switch to the "local" category. */
|
||||
local() {
|
||||
return self.step('Go to "Local" category', page =>
|
||||
page.getByRole('button', { name: 'Local' }).getByText('Local').click()
|
||||
)
|
||||
},
|
||||
/** Switch to the "recent" category. */
|
||||
recent() {
|
||||
return self.step('Go to "Recent" category', page =>
|
||||
page.getByRole('button', { name: 'Recent' }).getByText('Recent').click()
|
||||
)
|
||||
},
|
||||
/** Switch to the "trash" category. */
|
||||
trash() {
|
||||
return self.step('Go to "Trash" category', page =>
|
||||
page.getByRole('button', { name: 'Trash' }).getByText('Trash').click()
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions specific to the Drive table. */
|
||||
get driveTable() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const self: DrivePageActions = this
|
||||
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('Sort by name').or(page.getByLabel('Stop sorting by name')).click()
|
||||
)
|
||||
},
|
||||
/** Click the column heading for the "modified" column to change its sort order. */
|
||||
clickModifiedColumnHeading() {
|
||||
return self.step('Click "modified" column heading', page =>
|
||||
page
|
||||
.getByLabel('Sort by modification date')
|
||||
.or(page.getByLabel('Stop sorting by modification date'))
|
||||
.click()
|
||||
)
|
||||
},
|
||||
/** Click to select a specific row. */
|
||||
clickRow(index: number) {
|
||||
return self.step('Click drive table row', page =>
|
||||
actions
|
||||
.locateAssetRows(page)
|
||||
.nth(index)
|
||||
.click({ position: actions.ASSET_ROW_SAFE_POSITION })
|
||||
)
|
||||
},
|
||||
/** Right click a specific row to bring up its context menu, or the context menu for multiple
|
||||
* assets when right clicking on a selected asset when multiple assets are selected. */
|
||||
rightClickRow(index: number) {
|
||||
return self.step('Click drive table row', page =>
|
||||
actions
|
||||
.locateAssetRows(page)
|
||||
.nth(index)
|
||||
.click({ button: 'right', position: actions.ASSET_ROW_SAFE_POSITION })
|
||||
)
|
||||
},
|
||||
/** Interact with the set of all rows in the Drive table. */
|
||||
withRows(callback: baseActions.LocatorCallback) {
|
||||
return self.step('Interact with drive table rows', async page => {
|
||||
await callback(actions.locateAssetRows(page))
|
||||
})
|
||||
},
|
||||
/** A test assertion to confirm that there is only one row visible, and that row is the
|
||||
* placeholder row displayed when there are no assets to show. */
|
||||
expectPlaceholderRow() {
|
||||
return self.step('Expect placeholder row', async page => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows).toHaveText(/You have no files/)
|
||||
})
|
||||
},
|
||||
/** A test assertion to confirm that there is only one row visible, and that row is the
|
||||
* placeholder row displayed when there are no assets in Trash. */
|
||||
expectTrashPlaceholderRow() {
|
||||
return self.step('Expect trash placeholder row', async page => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows).toHaveText(/Your trash is empty/)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the "start" modal. */
|
||||
openStartModal() {
|
||||
return this.step('Open "start" modal', page =>
|
||||
page.getByText('Start with a template').click()
|
||||
).into(StartModalActions)
|
||||
}
|
||||
|
||||
/** Create a new empty project. */
|
||||
newEmptyProject() {
|
||||
return this.step('Create empty project', page =>
|
||||
page.getByText('New Empty Project').click()
|
||||
).into(EditorPageActions)
|
||||
}
|
||||
|
||||
/** Open the User Menu. */
|
||||
openUserMenu() {
|
||||
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Interact with the drive view (the main container of this page). */
|
||||
withDriveView(callback: baseActions.LocatorCallback) {
|
||||
return this.step('Interact with drive view', page => callback(actions.locateDriveView(page)))
|
||||
}
|
||||
|
||||
/** Create a new folder using the icon in the Drive Bar. */
|
||||
createFolder() {
|
||||
return this.step('Create folder', page =>
|
||||
page
|
||||
.getByRole('button')
|
||||
.filter({ has: page.getByAltText('New Folder') })
|
||||
.click()
|
||||
)
|
||||
}
|
||||
|
||||
/** Upload a file using the icon in the Drive Bar. */
|
||||
uploadFile(
|
||||
name: string,
|
||||
contents: WithImplicitCoercion<Uint8Array | string | readonly number[]>,
|
||||
mimeType = 'text/plain'
|
||||
) {
|
||||
return this.step(`Upload file '${name}'`, async page => {
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page
|
||||
.getByRole('button')
|
||||
.filter({ has: page.getByAltText('Import') })
|
||||
.click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles([{ name, buffer: Buffer.from(contents), mimeType }])
|
||||
})
|
||||
}
|
||||
|
||||
/** Create a new secret using the icon in the Drive Bar. */
|
||||
createSecret(name: string, value: string) {
|
||||
return this.step(`Create secret '${name}' = '${value}'`, async page => {
|
||||
await actions.locateNewSecretIcon(page).click()
|
||||
await actions.locateSecretNameInput(page).fill(name)
|
||||
await actions.locateSecretValueInput(page).fill(value)
|
||||
await actions.locateCreateButton(page).click()
|
||||
})
|
||||
}
|
||||
|
||||
/** Toggle the Asset Panel open or closed. */
|
||||
toggleAssetPanel() {
|
||||
return this.step('Toggle asset panel', page =>
|
||||
page.getByLabel('Asset Panel').locator('visible=true').click()
|
||||
)
|
||||
}
|
||||
|
||||
/** Interact with the Asset Panel. */
|
||||
withAssetPanel(callback: baseActions.LocatorCallback) {
|
||||
return this.step('Interact with asset panel', async page => {
|
||||
await callback(actions.locateAssetPanel(page))
|
||||
})
|
||||
}
|
||||
|
||||
/** Open the Data Link creation modal by clicking on the Data Link icon. */
|
||||
openDataLinkModal() {
|
||||
return this.step('Open "new data link" modal', page =>
|
||||
page
|
||||
.getByRole('button')
|
||||
.filter({ has: page.getByAltText('New Datalink') })
|
||||
.click()
|
||||
).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(actions.locateContextMenus(page))
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/** @file Actions for the "editor" page. */
|
||||
|
||||
import BaseActions from './BaseActions'
|
||||
import * as goToPageActions from './goToPageActions'
|
||||
import * as openUserMenuAction from './openUserMenuAction'
|
||||
import * as userMenuActions from './userMenuActions'
|
||||
|
||||
// =========================
|
||||
// === EditorPageActions ===
|
||||
// =========================
|
||||
|
||||
/** Actions for the "editor" page. */
|
||||
export default class EditorPageActions extends BaseActions {
|
||||
/** Actions for navigating to another page. */
|
||||
get goToPage(): Omit<goToPageActions.GoToPageActions, 'editor'> {
|
||||
return goToPageActions.goToPageActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to the User Menu. */
|
||||
get userMenu() {
|
||||
return userMenuActions.userMenuActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Open the User Menu. */
|
||||
openUserMenu() {
|
||||
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
/** @file Available actions for the login page. */
|
||||
import * as actions from '../actions'
|
||||
import BaseActions from './BaseActions'
|
||||
import DrivePageActions from './DrivePageActions'
|
||||
import SetUsernamePageActions from './SetUsernamePageActions'
|
||||
|
||||
// ========================
|
||||
// === LoginPageActions ===
|
||||
// ========================
|
||||
|
||||
/** Available actions for the login page. */
|
||||
export default class LoginPageActions extends BaseActions {
|
||||
/** Perform a successful login. */
|
||||
login(email = 'email@example.com', password = actions.VALID_PASSWORD) {
|
||||
return this.step('Login', () => this.loginInternal(email, password)).into(DrivePageActions)
|
||||
}
|
||||
|
||||
/** Perform a login as a new user (a user that does not yet have a username). */
|
||||
loginAsNewUser(email = 'email@example.com', password = actions.VALID_PASSWORD) {
|
||||
return this.step('Login', () => this.loginInternal(email, password)).into(
|
||||
SetUsernamePageActions
|
||||
)
|
||||
}
|
||||
|
||||
/** Perform a failing login. */
|
||||
loginThatShouldFail(email = 'email@example.com', password = actions.VALID_PASSWORD) {
|
||||
return this.step('Login', () => this.loginInternal(email, password))
|
||||
}
|
||||
|
||||
/** Internal login logic shared between all public methods. */
|
||||
private async loginInternal(email: string, password: string) {
|
||||
await this.page.goto('/')
|
||||
await actions.locateEmailInput(this.page).fill(email)
|
||||
await actions.locatePasswordInput(this.page).fill(password)
|
||||
await actions.locateLoginButton(this.page).click()
|
||||
await actions.locateToastCloseButton(this.page).click()
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
/** @file Actions for a "new Data Link" modal. */
|
||||
import type * as test from 'playwright/test'
|
||||
|
||||
import type * as baseActions from './BaseActions'
|
||||
import BaseActions from './BaseActions'
|
||||
import DrivePageActions from './DrivePageActions'
|
||||
|
||||
// ==============================
|
||||
// === locateNewDataLinkModal ===
|
||||
// ==============================
|
||||
|
||||
/** Locate the "new data link" modal. */
|
||||
function locateNewDataLinkModal(page: test.Page) {
|
||||
return page.getByRole('heading').and(page.getByText('Create Datalink')).locator('..')
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// === NewDataLinkModalActions ===
|
||||
// ===============================
|
||||
|
||||
/** Actions for a "new Data Link" modal. */
|
||||
export default class NewDataLinkModalActions extends BaseActions {
|
||||
/** Cancel creating the new Data Link (don't submit the form). */
|
||||
cancel() {
|
||||
return this.step('Cancel out of "new data link" modal', async () => {
|
||||
await this.press('Escape')
|
||||
}).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 => {
|
||||
const locator = locateNewDataLinkModal(page).getByLabel('Name')
|
||||
await callback(locator)
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
/** @file Actions for the "set username" page. */
|
||||
import * as actions from '../actions'
|
||||
import BaseActions from './BaseActions'
|
||||
import DrivePageActions from './DrivePageActions'
|
||||
|
||||
// ==============================
|
||||
// === SetUsernamePageActions ===
|
||||
// ==============================
|
||||
|
||||
/** Actions for the "set username" page. */
|
||||
export default class SetUsernamePageActions extends BaseActions {
|
||||
/** Set the userame for a new user that does not yet have a username. */
|
||||
setUsername(username: string) {
|
||||
return this.step(`Set username to '${username}'`, async page => {
|
||||
await actions.locateUsernameInput(page).fill(username)
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
}).into(DrivePageActions)
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/** @file Actions for the "settings" page. */
|
||||
import BaseActions from './BaseActions'
|
||||
import * as goToPageActions from './goToPageActions'
|
||||
import * as openUserMenuAction from './openUserMenuAction'
|
||||
import * as userMenuActions from './userMenuActions'
|
||||
|
||||
// ===========================
|
||||
// === SettingsPageActions ===
|
||||
// ===========================
|
||||
|
||||
// TODO: split settings page actions into different classes for each settings tab.
|
||||
/** Actions for the "settings" page. */
|
||||
export default class SettingsPageActions extends BaseActions {
|
||||
/** Actions for navigating to another page. */
|
||||
get goToPage(): Omit<goToPageActions.GoToPageActions, 'settings'> {
|
||||
return goToPageActions.goToPageActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to the User Menu. */
|
||||
get userMenu() {
|
||||
return userMenuActions.userMenuActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Open the User Menu. */
|
||||
openUserMenu() {
|
||||
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/** @file Actions for the "home" page. */
|
||||
import * as actions from '../actions'
|
||||
import BaseActions from './BaseActions'
|
||||
import DrivePageActions from './DrivePageActions'
|
||||
import EditorPageActions from './EditorPageActions'
|
||||
|
||||
// =========================
|
||||
// === StartModalActions ===
|
||||
// =========================
|
||||
|
||||
/** Actions for the "start" modal. */
|
||||
export default class StartModalActions extends BaseActions {
|
||||
/** Close this modal and go back to the Drive page. */
|
||||
close() {
|
||||
return this.step('Close "start" modal', page => page.getByLabel('Close').click()).into(
|
||||
DrivePageActions
|
||||
)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
.nth(index + 1)
|
||||
.click()
|
||||
).into(EditorPageActions)
|
||||
}
|
||||
}
|
128
app/ide-desktop/lib/dashboard/e2e/actions/contextMenuActions.ts
Normal file
128
app/ide-desktop/lib/dashboard/e2e/actions/contextMenuActions.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/** @file Actions for the context menu. */
|
||||
import type * as baseActions from './BaseActions'
|
||||
import type BaseActions from './BaseActions'
|
||||
|
||||
// ==========================
|
||||
// === ContextMenuActions ===
|
||||
// ==========================
|
||||
|
||||
/** Actions for the context menu. */
|
||||
export interface ContextMenuActions<T extends BaseActions> {
|
||||
readonly open: () => T
|
||||
readonly uploadToCloud: () => T
|
||||
readonly rename: () => T
|
||||
readonly snapshot: () => T
|
||||
readonly moveToTrash: () => T
|
||||
readonly moveAllToTrash: () => T
|
||||
readonly restoreFromTrash: () => T
|
||||
readonly restoreAllFromTrash: () => T
|
||||
readonly share: () => T
|
||||
readonly label: () => T
|
||||
readonly duplicate: () => T
|
||||
readonly copy: () => T
|
||||
readonly cut: () => T
|
||||
readonly paste: () => T
|
||||
readonly download: () => T
|
||||
readonly uploadFiles: () => T
|
||||
readonly newFolder: () => T
|
||||
readonly newSecret: () => T
|
||||
readonly newDataLink: () => T
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === contextMenuActions ===
|
||||
// ==========================
|
||||
|
||||
/** Generate actions for the context menu. */
|
||||
export function contextMenuActions<T extends BaseActions>(
|
||||
step: (name: string, callback: baseActions.PageCallback) => T
|
||||
): ContextMenuActions<T> {
|
||||
return {
|
||||
open: () =>
|
||||
step('Open (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Open' }).getByText('Open').click()
|
||||
),
|
||||
uploadToCloud: () =>
|
||||
step('Upload to cloud (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Upload To Cloud' }).getByText('Upload To Cloud').click()
|
||||
),
|
||||
rename: () =>
|
||||
step('Rename (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Rename' }).getByText('Rename').click()
|
||||
),
|
||||
snapshot: () =>
|
||||
step('Snapshot (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Snapshot' }).getByText('Snapshot').click()
|
||||
),
|
||||
moveToTrash: () =>
|
||||
step('Move to trash (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Move To Trash' }).getByText('Move To Trash').click()
|
||||
),
|
||||
moveAllToTrash: () =>
|
||||
step('Move all to trash (context menu)', page =>
|
||||
page
|
||||
.getByRole('button', { name: 'Move All To Trash' })
|
||||
.getByText('Move All To Trash')
|
||||
.click()
|
||||
),
|
||||
restoreFromTrash: () =>
|
||||
step('Restore from trash (context menu)', page =>
|
||||
page
|
||||
.getByRole('button', { name: 'Restore From Trash' })
|
||||
.getByText('Restore From Trash')
|
||||
.click()
|
||||
),
|
||||
restoreAllFromTrash: () =>
|
||||
step('Restore all from trash (context menu)', page =>
|
||||
page
|
||||
.getByRole('button', { name: 'Restore All From Trash' })
|
||||
.getByText('Restore All From Trash')
|
||||
.click()
|
||||
),
|
||||
share: () =>
|
||||
step('Share (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Share' }).getByText('Share').click()
|
||||
),
|
||||
label: () =>
|
||||
step('Label (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Label' }).getByText('Label').click()
|
||||
),
|
||||
duplicate: () =>
|
||||
step('Duplicate (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click()
|
||||
),
|
||||
copy: () =>
|
||||
step('Copy (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Copy' }).getByText('Copy').click()
|
||||
),
|
||||
cut: () =>
|
||||
step('Cut (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Cut' }).getByText('Cut').click()
|
||||
),
|
||||
paste: () =>
|
||||
step('Paste (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Paste' }).getByText('Paste').click()
|
||||
),
|
||||
download: () =>
|
||||
step('Download (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Download' }).getByText('Download').click()
|
||||
),
|
||||
// TODO: Specify the files in parameters.
|
||||
uploadFiles: () =>
|
||||
step('Upload files (context menu)', page =>
|
||||
page.getByRole('button', { name: 'Upload Files' }).getByText('Upload Files').click()
|
||||
),
|
||||
newFolder: () =>
|
||||
step('New folder (context menu)', page =>
|
||||
page.getByRole('button', { name: 'New Folder' }).getByText('New Folder').click()
|
||||
),
|
||||
newSecret: () =>
|
||||
step('New secret (context menu)', page =>
|
||||
page.getByRole('button', { name: 'New Secret' }).getByText('New Secret').click()
|
||||
),
|
||||
newDataLink: () =>
|
||||
step('New Data Link (context menu)', page =>
|
||||
page.getByRole('button', { name: 'New Data Link' }).getByText('New Data Link').click()
|
||||
),
|
||||
}
|
||||
}
|
47
app/ide-desktop/lib/dashboard/e2e/actions/goToPageActions.ts
Normal file
47
app/ide-desktop/lib/dashboard/e2e/actions/goToPageActions.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/** @file Actions for going to a different page. */
|
||||
import type * as baseActions 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
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === goToPageActions ===
|
||||
// =======================
|
||||
|
||||
/** Generate actions for going to a different page. */
|
||||
export function goToPageActions(
|
||||
step: (name: string, callback: baseActions.PageCallback) => BaseActions
|
||||
): GoToPageActions {
|
||||
return {
|
||||
drive: () =>
|
||||
step('Go to "Data Catalog" page', page =>
|
||||
page
|
||||
.getByRole('button')
|
||||
.filter({ has: page.getByText('Data Catalog') })
|
||||
.click()
|
||||
).into(DrivePageActions),
|
||||
editor: () =>
|
||||
step('Go to "Spatial Analysis" page', page =>
|
||||
page
|
||||
.getByRole('button')
|
||||
.filter({ has: page.getByText('Spatial Analysis') })
|
||||
.click()
|
||||
).into(EditorPageActions),
|
||||
settings: () =>
|
||||
step('Go to "settings" page', page => BaseActions.press(page, 'Mod+,')).into(
|
||||
SettingsPageActions
|
||||
),
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
/** @file An action to open the User Menu. */
|
||||
import type * as baseActions from './BaseActions'
|
||||
import type BaseActions from './BaseActions'
|
||||
|
||||
// ==========================
|
||||
// === openUserMenuAction ===
|
||||
// ==========================
|
||||
|
||||
/** An action to open the User Menu. */
|
||||
export function openUserMenuAction<T extends BaseActions>(
|
||||
step: (name: string, callback: baseActions.PageCallback) => T
|
||||
) {
|
||||
return step('Open user menu', page =>
|
||||
page.getByAltText('User Settings').locator('visible=true').click()
|
||||
)
|
||||
}
|
50
app/ide-desktop/lib/dashboard/e2e/actions/userMenuActions.ts
Normal file
50
app/ide-desktop/lib/dashboard/e2e/actions/userMenuActions.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/** @file Actions for the user menu. */
|
||||
import type * as test from 'playwright/test'
|
||||
|
||||
import type * as baseActions from './BaseActions'
|
||||
import type BaseActions from './BaseActions'
|
||||
import LoginPageActions from './LoginPageActions'
|
||||
import SettingsPageActions from './SettingsPageActions'
|
||||
|
||||
// =======================
|
||||
// === UserMenuActions ===
|
||||
// =======================
|
||||
|
||||
/** Actions for the user menu. */
|
||||
export interface UserMenuActions<T extends BaseActions> {
|
||||
readonly downloadApp: (callback: (download: test.Download) => Promise<void> | void) => T
|
||||
readonly goToSettingsPage: () => SettingsPageActions
|
||||
readonly logout: () => LoginPageActions
|
||||
readonly goToLoginPage: () => LoginPageActions
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === userMenuActions ===
|
||||
// =======================
|
||||
|
||||
/** Generate actions for the user menu. */
|
||||
export function userMenuActions<T extends BaseActions>(
|
||||
step: (name: string, callback: baseActions.PageCallback) => T
|
||||
): UserMenuActions<T> {
|
||||
return {
|
||||
downloadApp: (callback: (download: test.Download) => Promise<void> | void) => {
|
||||
return step('Download app (user menu)', async page => {
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: 'Download App' }).getByText('Download App').click()
|
||||
await callback(await downloadPromise)
|
||||
})
|
||||
},
|
||||
goToSettingsPage: () =>
|
||||
step('Go to Settings (user menu)', page =>
|
||||
page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
|
||||
).into(SettingsPageActions),
|
||||
logout: () =>
|
||||
step('Logout (user menu)', page =>
|
||||
page.getByRole('button', { name: 'Logout' }).getByText('Logout').click()
|
||||
).into(LoginPageActions),
|
||||
goToLoginPage: () =>
|
||||
step('Login (user menu)', page =>
|
||||
page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click()
|
||||
).into(LoginPageActions),
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -7,46 +7,74 @@ import * as permissions from '#/utilities/permissions'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('open and close asset panel', async ({ page }) => {
|
||||
await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await test.expect(actions.locateAssetPanel(page)).not.toBeVisible()
|
||||
await actions.locateAssetPanelIcon(page).click()
|
||||
await test.expect(actions.locateAssetPanel(page)).toBeVisible()
|
||||
await actions.locateAssetPanelIcon(page).click()
|
||||
await test.expect(actions.locateAssetPanel(page)).not.toBeVisible()
|
||||
})
|
||||
/** An example description for the asset selected in the asset panel. */
|
||||
const DESCRIPTION = 'foo bar'
|
||||
/** An example owner username for the asset selected in the asset panel. */
|
||||
const USERNAME = 'baz quux'
|
||||
/** An example owner email for the asset selected in the asset panel. */
|
||||
const EMAIL = 'baz.quux@email.com'
|
||||
|
||||
test.test('asset panel contents', async ({ page }) => {
|
||||
const { api } = await actions.mockAll({ page })
|
||||
const { defaultOrganizationId, defaultUserId } = api
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const description = 'foo bar'
|
||||
const username = 'baz quux'
|
||||
const email = 'baz.quux@email.com'
|
||||
api.addProject('project', {
|
||||
description,
|
||||
permissions: [
|
||||
{
|
||||
permission: permissions.PermissionAction.own,
|
||||
user: {
|
||||
organizationId: defaultOrganizationId,
|
||||
userId: defaultUserId,
|
||||
name: username,
|
||||
email: backend.EmailAddress(email),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await page.goto('/')
|
||||
await actions.login({ page })
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await actions.locateAssetPanelIcon(page).click()
|
||||
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(description)
|
||||
// `getByText` is required so that this assertion works if there are multiple permissions.
|
||||
await test.expect(actions.locateAssetPanelPermissions(page).getByText(username)).toBeVisible()
|
||||
})
|
||||
test.test('open and close asset panel', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
.createFolder()
|
||||
.driveTable.clickRow(0)
|
||||
.withAssetPanel(async assetPanel => {
|
||||
await test.expect(assetPanel).not.toBeVisible()
|
||||
})
|
||||
.toggleAssetPanel()
|
||||
.withAssetPanel(async assetPanel => {
|
||||
await test.expect(assetPanel).toBeVisible()
|
||||
})
|
||||
.toggleAssetPanel()
|
||||
.withAssetPanel(async assetPanel => {
|
||||
await test.expect(assetPanel).not.toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
test.test('asset panel contents', ({ page }) =>
|
||||
actions.mockAll({ page }).then(
|
||||
async ({ pageActions, api }) =>
|
||||
await pageActions
|
||||
.do(() => {
|
||||
const { defaultOrganizationId, defaultUserId } = api
|
||||
api.addProject('project', {
|
||||
description: DESCRIPTION,
|
||||
permissions: [
|
||||
{
|
||||
permission: permissions.PermissionAction.own,
|
||||
user: {
|
||||
organizationId: defaultOrganizationId,
|
||||
userId: defaultUserId,
|
||||
name: USERNAME,
|
||||
email: backend.EmailAddress(EMAIL),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.login()
|
||||
.do(async thePage => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
})
|
||||
.driveTable.clickRow(0)
|
||||
.toggleAssetPanel()
|
||||
.do(async () => {
|
||||
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
|
||||
// `getByText` is required so that this assertion works if there are multiple permissions.
|
||||
await test
|
||||
.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME))
|
||||
.toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -1,58 +0,0 @@
|
||||
/** @file Test the "change password" modal. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('change password', async ({ page }) => {
|
||||
await actions.press(page, 'Mod+,')
|
||||
|
||||
await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||
await test
|
||||
.expect(actions.locateChangeButton(page), 'incomplete form should be rejected')
|
||||
.toBeDisabled()
|
||||
|
||||
// Invalid new password
|
||||
await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||
await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||
test
|
||||
.expect(
|
||||
await actions
|
||||
.locateNewPasswordInput(page)
|
||||
.evaluate((element: HTMLInputElement) => element.validity.valid),
|
||||
'invalid new password should be rejected'
|
||||
)
|
||||
.toBe(false)
|
||||
await test
|
||||
.expect(actions.locateChangeButton(page), 'invalid new password should be rejected')
|
||||
.toBeDisabled()
|
||||
|
||||
// Invalid new password confirmation
|
||||
await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a')
|
||||
test
|
||||
.expect(
|
||||
await actions
|
||||
.locateConfirmNewPasswordInput(page)
|
||||
.evaluate((element: HTMLInputElement) => element.validity.valid),
|
||||
'invalid new password confirmation should be rejected'
|
||||
)
|
||||
.toBe(false)
|
||||
await test
|
||||
.expect(
|
||||
actions.locateChangeButton(page),
|
||||
'invalid new password confirmation should be rejected'
|
||||
)
|
||||
.toBeDisabled()
|
||||
|
||||
// After form submission
|
||||
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateChangeButton(page).click()
|
||||
await test.expect(actions.locateCurrentPasswordInput(page)).toHaveText('')
|
||||
await test.expect(actions.locateNewPasswordInput(page)).toHaveText('')
|
||||
await test.expect(actions.locateConfirmNewPasswordInput(page)).toHaveText('')
|
||||
})
|
@ -3,6 +3,47 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
// =====================
|
||||
// === Local actions ===
|
||||
// =====================
|
||||
|
||||
// These actions have been migrated to the new API, and are included here as a temporary measure
|
||||
// until this file is also migrated to the new API.
|
||||
|
||||
/** Find a "duplicate" button (if any) on the current page. */
|
||||
export function locateDuplicateButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate')
|
||||
}
|
||||
|
||||
/** Find a "copy" button (if any) on the current page. */
|
||||
function locateCopyButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Copy' }).getByText('Copy')
|
||||
}
|
||||
|
||||
/** Find a "cut" button (if any) on the current page. */
|
||||
function locateCutButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Cut' }).getByText('Cut')
|
||||
}
|
||||
|
||||
/** Find a "paste" button (if any) on the current page. */
|
||||
function locatePasteButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Paste' }).getByText('Paste')
|
||||
}
|
||||
|
||||
/** A test assertion to confirm that there is only one row visible, and that row is the
|
||||
* placeholder row displayed when there are no assets to show. */
|
||||
export async function expectPlaceholderRow(page: test.Page) {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
await test.test.step('Expect placeholder row', async () => {
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows).toHaveText(/You have no files/)
|
||||
})
|
||||
}
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('copy', async ({ page }) => {
|
||||
@ -14,12 +55,12 @@ test.test('copy', async ({ page }) => {
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locateCopyButton(page).click()
|
||||
await locateCopyButton(page).click()
|
||||
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
await assetRows.nth(1).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locatePasteButton(page).click()
|
||||
await locatePasteButton(page).click()
|
||||
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(3)
|
||||
await test.expect(assetRows.nth(2)).toBeVisible()
|
||||
@ -59,12 +100,12 @@ test.test('move', async ({ page }) => {
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locateCutButton(page).click()
|
||||
await locateCutButton(page).click()
|
||||
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
await assetRows.nth(1).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locatePasteButton(page).click()
|
||||
await locatePasteButton(page).click()
|
||||
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
@ -103,7 +144,7 @@ test.test('move to trash', async ({ page }) => {
|
||||
// held.
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
await actions.dragAssetRow(assetRows.nth(0), actions.locateTrashCategory(page))
|
||||
await actions.expectPlaceholderRow(page)
|
||||
await expectPlaceholderRow(page)
|
||||
await actions.locateTrashCategory(page).click()
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
@ -156,7 +197,7 @@ test.test('duplicate', async ({ page }) => {
|
||||
// Assets: [0: Folder 1]
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locateDuplicateButton(page).click()
|
||||
await locateDuplicateButton(page).click()
|
||||
// Assets: [0: Folder 1 (copy), 1: Folder 1]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
|
@ -3,52 +3,67 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
test.test('create folder', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
/** The name of the uploaded file. */
|
||||
const FILE_NAME = 'foo.txt'
|
||||
/** The contents of the uploaded file. */
|
||||
const FILE_CONTENTS = 'hello world'
|
||||
/** The name of the created secret. */
|
||||
const SECRET_NAME = 'a secret name'
|
||||
/** The value of the created secret. */
|
||||
const SECRET_VALUE = 'a secret value'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1/)
|
||||
})
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test('create project', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
test.test('create folder', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions.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 actions.locateNewProjectButton(page).click()
|
||||
// Assets: [0: Project 1]
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
test.test('create project', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
.newEmptyProject()
|
||||
.do(async thePage => {
|
||||
await test.expect(actions.locateEditor(thePage)).toBeVisible()
|
||||
})
|
||||
.goToPage.drive()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
test.test('upload file', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
test.test('upload file', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions.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))
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await actions.locateUploadFilesIcon(page).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
const name = 'foo.txt'
|
||||
const content = 'hello world'
|
||||
await fileChooser.setFiles([{ name, buffer: Buffer.from(content), mimeType: 'text/plain' }])
|
||||
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + name))
|
||||
})
|
||||
|
||||
test.test('create secret', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewSecretIcon(page).click()
|
||||
const name = 'a secret name'
|
||||
const value = 'a secret value'
|
||||
await actions.locateSecretNameInput(page).fill(name)
|
||||
await actions.locateSecretValueInput(page).fill(value)
|
||||
await actions.locateCreateButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + name))
|
||||
})
|
||||
test.test('create secret', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions.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))
|
||||
})
|
||||
)
|
||||
)
|
||||
|
15
app/ide-desktop/lib/dashboard/e2e/dataLinkEditor.spec.ts
Normal file
15
app/ide-desktop/lib/dashboard/e2e/dataLinkEditor.spec.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/** @file Test the user settings tab. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
const DATA_LINK_NAME = 'a data link'
|
||||
|
||||
test.test('data link editor', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions.openDataLinkModal().withNameInput(async input => {
|
||||
await input.fill(DATA_LINK_NAME)
|
||||
})
|
||||
)
|
||||
)
|
@ -3,48 +3,52 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
test.test('delete and restore', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
.createFolder()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
.driveTable.rightClickRow(0)
|
||||
.contextMenu.moveToTrash()
|
||||
.driveTable.expectPlaceholderRow()
|
||||
.goToCategory.trash()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
.driveTable.rightClickRow(0)
|
||||
.contextMenu.restoreFromTrash()
|
||||
.driveTable.expectTrashPlaceholderRow()
|
||||
.goToCategory.cloud()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
test.test('delete and restore', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const contextMenu = actions.locateContextMenus(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await actions.locateMoveToTrashButton(contextMenu).click()
|
||||
|
||||
await actions.expectPlaceholderRow(page)
|
||||
|
||||
await actions.locateTrashButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await actions.locateRestoreFromTrashButton(contextMenu).click()
|
||||
await actions.expectTrashPlaceholderRow(page)
|
||||
|
||||
await actions.locateCloudButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
||||
|
||||
test.test('delete and restore (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await actions.press(page, 'Delete')
|
||||
await actions.expectPlaceholderRow(page)
|
||||
|
||||
await actions.locateTrashButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.expectTrashPlaceholderRow(page)
|
||||
|
||||
await actions.locateCloudButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
||||
test.test('delete and restore (keyboard)', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
.createFolder()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
.driveTable.clickRow(0)
|
||||
.press('Delete')
|
||||
.driveTable.expectPlaceholderRow()
|
||||
.goToCategory.trash()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
.driveTable.clickRow(0)
|
||||
.press('Mod+R')
|
||||
.driveTable.expectTrashPlaceholderRow()
|
||||
.goToCategory.cloud()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -3,33 +3,48 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('drive view', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
// Drive view
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
await actions.expectPlaceholderRow(page)
|
||||
// Assets table with one asset
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
// The placeholder row becomes hidden.
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(actions.locateAssetsTable(page)).toBeVisible()
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
// The last opened project needs to be stopped, to remove the toast notification notifying the
|
||||
// user that project creation may take a while. Previously opened projects are stopped when the
|
||||
// new project is created.
|
||||
await actions.locateStopProjectButton(assetRows.nth(0)).click()
|
||||
// Project context menu
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
const contextMenu = actions.locateContextMenus(page)
|
||||
await test.expect(contextMenu).toBeVisible()
|
||||
await actions.locateMoveToTrashButton(contextMenu).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
||||
test.test('drive view', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
.withDriveView(async view => {
|
||||
await test.expect(view).toBeVisible()
|
||||
})
|
||||
.driveTable.expectPlaceholderRow()
|
||||
.newEmptyProject()
|
||||
.do(async () => {
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
.goToPage.drive()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
.do(async () => {
|
||||
await test.expect(actions.locateAssetsTable(page)).toBeVisible()
|
||||
})
|
||||
.newEmptyProject()
|
||||
.do(async () => {
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
.goToPage.drive()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(2)
|
||||
})
|
||||
// The last opened project needs to be stopped, to remove the toast notification notifying the
|
||||
// user that project creation may take a while. Previously opened projects are stopped when the
|
||||
// new project is created.
|
||||
.driveTable.withRows(async rows => {
|
||||
await actions.locateStopProjectButton(rows.nth(0)).click()
|
||||
})
|
||||
// Project context menu
|
||||
.driveTable.rightClickRow(0)
|
||||
.withContextMenus(async menus => {
|
||||
// actions.locateContextMenus(page)
|
||||
await test.expect(menus).toBeVisible()
|
||||
})
|
||||
.contextMenu.moveToTrash()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -3,22 +3,25 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
// Do not login in setup, because this test needs to test login.
|
||||
test.test.beforeEach(actions.mockAll)
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test('login and logout', async ({ page }) => {
|
||||
// After sign in
|
||||
await actions.login({ page })
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
await test.expect(actions.locateLoginButton(page)).not.toBeVisible()
|
||||
|
||||
// After sign out
|
||||
await actions.locateUserMenuButton(page).click()
|
||||
await actions.locateLogoutButton(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateLoginButton(page)).toBeVisible()
|
||||
})
|
||||
test.test('login and logout', ({ page }) =>
|
||||
actions.mockAll({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
.login()
|
||||
.do(async thePage => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
|
||||
await test.expect(actions.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()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
32
app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts
Normal file
32
app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/** @file Test the user settings tab. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('members settings', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const localActions = actions.settings.members
|
||||
|
||||
// Setup
|
||||
api.setCurrentOrganization(api.defaultOrganization)
|
||||
|
||||
await localActions.go(page)
|
||||
await test
|
||||
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
|
||||
.toHaveText([api.currentUser()?.name ?? ''])
|
||||
|
||||
const otherUserName = 'second.user_'
|
||||
const otherUser = api.addUser(otherUserName)
|
||||
await actions.login({ page })
|
||||
await localActions.go(page)
|
||||
await test
|
||||
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
|
||||
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
|
||||
|
||||
api.deleteUser(otherUser.userId)
|
||||
await actions.login({ page })
|
||||
await localActions.go(page)
|
||||
await test
|
||||
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
|
||||
.toHaveText([api.currentUser()?.name ?? ''])
|
||||
})
|
@ -0,0 +1,93 @@
|
||||
/** @file Test the organization settings tab. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('organization settings', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
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 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')
|
||||
test.expect(api.currentOrganization()?.name).toBe(newName)
|
||||
await test.expect(nameInput).toHaveValue(newName)
|
||||
})
|
||||
|
||||
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(null)
|
||||
})
|
||||
|
||||
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.mockAllAndLogin({ page })
|
||||
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)
|
||||
})
|
||||
.toPass()
|
||||
})
|
@ -3,18 +3,21 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('page switcher', async ({ page }) => {
|
||||
// Create a new project so that the editor page can be switched to.
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
// The current page is now the editor page.
|
||||
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).not.toBeVisible()
|
||||
|
||||
await actions.locateEditorPageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
test.test('page switcher', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
// Create a new project so that the editor page can be switched to.
|
||||
.newEmptyProject()
|
||||
.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()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
126
app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts
Normal file
126
app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/** @file Test the login flow. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const EMAIL = 'example.email+1234@testing.org'
|
||||
const NAME = 'a custom user name'
|
||||
const ORGANIZATION_ID = 'some testing organization id'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
// Note: This does not check that the organization ID is sent in the correct format for the backend.
|
||||
// It only checks that the organization ID is sent in certain places.
|
||||
test.test('sign up with organization id', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.goto(
|
||||
'/registration?' + new URLSearchParams([['organization_id', ORGANIZATION_ID]]).toString()
|
||||
)
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateRegisterButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Log in
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Set username
|
||||
await actions.locateUsernameInput(page).fill('arbitrary username')
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
|
||||
test
|
||||
.expect(api.currentUser()?.organizationId, 'new user has correct organization id')
|
||||
.toBe(ORGANIZATION_ID)
|
||||
})
|
||||
|
||||
test.test('sign up without organization id', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.goto('/registration')
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateRegisterButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Log in
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Set username
|
||||
await actions.locateUsernameInput(page).fill('arbitrary username')
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
|
||||
test
|
||||
.expect(api.currentUser()?.organizationId, 'new user has correct organization id')
|
||||
.toBe(api.defaultOrganizationId)
|
||||
})
|
||||
|
||||
test.test('sign up flow', ({ page }) =>
|
||||
actions.mockAll({ page }).then(
|
||||
async ({ pageActions, api }) =>
|
||||
await pageActions
|
||||
.do(() => {
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// These values should be different, otherwise the email and name may come from the defaults.
|
||||
test.expect(EMAIL).not.toStrictEqual(api.defaultEmail)
|
||||
test.expect(NAME).not.toStrictEqual(api.defaultName)
|
||||
})
|
||||
.loginAsNewUser(EMAIL, actions.VALID_PASSWORD)
|
||||
.do(async thePage => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
})
|
||||
.setUsername(NAME)
|
||||
.do(async thePage => {
|
||||
await test.expect(actions.locateUpgradeButton(thePage)).toBeVisible()
|
||||
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
|
||||
})
|
||||
.do(() => {
|
||||
// Logged in, and account enabled
|
||||
const currentUser = api.currentUser()
|
||||
test.expect(currentUser).toBeDefined()
|
||||
if (currentUser != null) {
|
||||
// This is required because `UserOrOrganization` is `readonly`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, no-extra-semi
|
||||
;(currentUser as { isEnabled: boolean }).isEnabled = true
|
||||
}
|
||||
})
|
||||
.openUserMenu()
|
||||
.userMenu.logout()
|
||||
.login(EMAIL, actions.VALID_PASSWORD)
|
||||
.do(async () => {
|
||||
await test.expect(actions.locateNotEnabledStub(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
})
|
||||
.do(() => {
|
||||
test.expect(api.currentUser()?.email, 'new user has correct email').toBe(EMAIL)
|
||||
test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME)
|
||||
})
|
||||
)
|
||||
)
|
@ -1,48 +0,0 @@
|
||||
/** @file Test the login flow. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('sign up flow', async ({ page }) => {
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
await page.goto('/')
|
||||
|
||||
const email = 'example.email+1234@testing.org'
|
||||
const name = 'a custom user name'
|
||||
|
||||
// These values should be different, otherwise the email and name may come from the defaults.
|
||||
test.expect(email).not.toStrictEqual(api.defaultEmail)
|
||||
test.expect(name).not.toStrictEqual(api.defaultName)
|
||||
|
||||
// Set username panel
|
||||
await actions.locateEmailInput(page).fill(email)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
await test.expect(actions.locateSetUsernamePanel(page)).toBeVisible()
|
||||
|
||||
// Logged in, but account disabled
|
||||
await actions.locateUsernameInput(page).fill(name)
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
|
||||
await test.expect(actions.locateUpgradeButton(page)).toBeVisible()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
|
||||
// Logged in, and account enabled
|
||||
const currentUser = api.currentUser
|
||||
test.expect(currentUser).toBeDefined()
|
||||
if (currentUser != null) {
|
||||
// This is required because `UserOrOrganization` is `readonly`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, no-extra-semi
|
||||
;(currentUser as { isEnabled: boolean }).isEnabled = true
|
||||
}
|
||||
await actions.login({ page }, email)
|
||||
await test.expect(actions.locateNotEnabledStub(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
|
||||
test.expect(api.currentUser?.email, 'new user has correct email').toBe(email)
|
||||
test.expect(api.currentUser?.name, 'new user has correct name').toBe(name)
|
||||
})
|
@ -1,44 +0,0 @@
|
||||
/** @file Test the login flow. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
// Note: This does not check that the organization ID is sent in the correct format for the backend.
|
||||
// It only checks that the organization ID is sent in certain places.
|
||||
test.test('sign up with organization id', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const organizationId = 'some testing organization id'
|
||||
await page.goto(
|
||||
'/registration?' + new URLSearchParams([['organization_id', organizationId]]).toString()
|
||||
)
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateRegisterButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Log in
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Set username
|
||||
await actions.locateUsernameInput(page).fill('arbitrary username')
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
|
||||
test
|
||||
.expect(api.currentUser?.organizationId, 'new user has correct organization id')
|
||||
.toBe(organizationId)
|
||||
})
|
@ -1,39 +0,0 @@
|
||||
/** @file Test the login flow. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test('sign up without organization id', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.goto('/registration')
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateRegisterButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Log in
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
|
||||
await actions.passTermsAndConditionsDialog({ page })
|
||||
|
||||
// Set username
|
||||
await actions.locateUsernameInput(page).fill('arbitrary username')
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
|
||||
test
|
||||
.expect(api.currentUser?.organizationId, 'new user has correct organization id')
|
||||
.toBe(api.defaultOrganizationId)
|
||||
})
|
@ -7,10 +7,18 @@ import * as actions from './actions'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers */
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const START_DATE_EPOCH_MS = 1.7e12
|
||||
/** The number of milliseconds in a minute. */
|
||||
const MIN_MS = 60_000
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test('sort', async ({ page }) => {
|
||||
const { api } = await actions.mockAll({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
@ -46,9 +54,9 @@ test.test('sort', async ({ page }) => {
|
||||
|
||||
// By default, assets should be grouped by type.
|
||||
// Assets in each group are ordered by insertion order.
|
||||
await actions.expectTransparent(actions.locateSortAscendingIcon(nameHeading))
|
||||
await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading))
|
||||
await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible()
|
||||
await actions.expectTransparent(actions.locateSortAscendingIcon(modifiedHeading))
|
||||
await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading))
|
||||
await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
@ -61,7 +69,7 @@ test.test('sort', async ({ page }) => {
|
||||
|
||||
// Sort by name ascending.
|
||||
await nameHeading.click()
|
||||
await actions.expectNotTransparent(actions.locateSortAscendingIcon(nameHeading))
|
||||
await actions.expectNotOpacity0(actions.locateSortAscendingIcon(nameHeading))
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^C project/)
|
||||
@ -73,7 +81,7 @@ test.test('sort', async ({ page }) => {
|
||||
|
||||
// Sort by name descending.
|
||||
await nameHeading.click()
|
||||
await actions.expectNotTransparent(actions.locateSortDescendingIcon(nameHeading))
|
||||
await actions.expectNotOpacity0(actions.locateSortDescendingIcon(nameHeading))
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^H secret/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^f secret/)
|
||||
@ -86,7 +94,7 @@ test.test('sort', async ({ page }) => {
|
||||
// Sorting should be unset.
|
||||
await nameHeading.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await actions.expectTransparent(actions.locateSortAscendingIcon(nameHeading))
|
||||
await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading))
|
||||
await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
@ -99,7 +107,7 @@ test.test('sort', async ({ page }) => {
|
||||
|
||||
// Sort by date ascending.
|
||||
await modifiedHeading.click()
|
||||
await actions.expectNotTransparent(actions.locateSortAscendingIcon(modifiedHeading))
|
||||
await actions.expectNotOpacity0(actions.locateSortAscendingIcon(modifiedHeading))
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^H secret/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^f secret/)
|
||||
@ -111,7 +119,7 @@ test.test('sort', async ({ page }) => {
|
||||
|
||||
// Sort by date descending.
|
||||
await modifiedHeading.click()
|
||||
await actions.expectNotTransparent(actions.locateSortDescendingIcon(modifiedHeading))
|
||||
await actions.expectNotOpacity0(actions.locateSortDescendingIcon(modifiedHeading))
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^G directory/)
|
||||
@ -124,7 +132,7 @@ test.test('sort', async ({ page }) => {
|
||||
// Sorting should be unset.
|
||||
await modifiedHeading.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await actions.expectTransparent(actions.locateSortAscendingIcon(modifiedHeading))
|
||||
await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading))
|
||||
await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
|
@ -3,12 +3,15 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('create project from template', async ({ page }) => {
|
||||
await actions.locateStartModalButton(page).click()
|
||||
// The second "sample" is the first template.
|
||||
await actions.locateSamples(page).nth(1).click()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
|
||||
})
|
||||
test.test('create project from template', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
.openStartModal()
|
||||
.createProjectFromTemplate(0)
|
||||
.do(async thePage => {
|
||||
await test.expect(actions.locateEditor(thePage)).toBeVisible()
|
||||
await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -3,19 +3,21 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
test.test('user menu', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions.openUserMenu().do(async thePage => {
|
||||
await test.expect(actions.locateUserMenu(thePage)).toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
test.test('user menu', async ({ page }) => {
|
||||
// User menu
|
||||
await actions.locateUserMenuButton(page).click()
|
||||
await test.expect(actions.locateUserMenu(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test.test('download app', async ({ page }) => {
|
||||
await actions.locateUserMenuButton(page).click()
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await actions.locateDownloadAppButton(page).click()
|
||||
const download = await downloadPromise
|
||||
await download.cancel()
|
||||
test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
|
||||
})
|
||||
test.test('download app', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions.openUserMenu().userMenu.downloadApp(async download => {
|
||||
await download.cancel()
|
||||
test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
97
app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts
Normal file
97
app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/** @file Test the user settings tab. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('user settings', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const localActions = actions.settings.userAccount
|
||||
test.expect(api.currentUser()?.name).toBe(api.defaultName)
|
||||
|
||||
await localActions.go(page)
|
||||
const nameInput = localActions.locateNameInput(page)
|
||||
const newName = 'another user-name'
|
||||
await nameInput.fill(newName)
|
||||
await nameInput.press('Enter')
|
||||
test.expect(api.currentUser()?.name).toBe(newName)
|
||||
test.expect(api.currentOrganization()?.name).not.toBe(newName)
|
||||
})
|
||||
|
||||
test.test('change password form', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ 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
|
||||
.expect(localActions.locateChangeButton(page), 'incomplete form should be rejected')
|
||||
.toBeDisabled()
|
||||
|
||||
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)
|
||||
test
|
||||
.expect(
|
||||
await localActions
|
||||
.locateNewPasswordInput(page)
|
||||
.evaluate((element: HTMLInputElement) => element.validity.valid),
|
||||
'invalid new password should be rejected'
|
||||
)
|
||||
.toBe(false)
|
||||
await test
|
||||
.expect(localActions.locateChangeButton(page), 'invalid new password should be rejected')
|
||||
.toBeDisabled()
|
||||
})
|
||||
|
||||
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')
|
||||
test
|
||||
.expect(
|
||||
await localActions
|
||||
.locateConfirmNewPasswordInput(page)
|
||||
.evaluate((element: HTMLInputElement) => element.validity.valid),
|
||||
'invalid new password confirmation should be rejected'
|
||||
)
|
||||
.toBe(false)
|
||||
await test
|
||||
.expect(
|
||||
localActions.locateChangeButton(page),
|
||||
'invalid new password confirmation should be rejected'
|
||||
)
|
||||
.toBeDisabled()
|
||||
})
|
||||
|
||||
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.locateChangeButton(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.mockAllAndLogin({ page })
|
||||
const localActions = actions.settings.profilePicture
|
||||
|
||||
await localActions.go(page)
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await localActions.locateInput(page).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
const name = 'foo.png'
|
||||
const content = 'a profile picture'
|
||||
await fileChooser.setFiles([{ name, mimeType: 'image/png', buffer: Buffer.from(content) }])
|
||||
await test
|
||||
.expect(() => {
|
||||
test.expect(api.currentProfilePicture()).toEqual(content)
|
||||
})
|
||||
.toPass()
|
||||
})
|
@ -262,12 +262,15 @@ export class Cognito {
|
||||
* password, new password, and repeat new password to change their old password to the new
|
||||
* one. The validation of the repeated new password is handled by the `changePasswordModel`
|
||||
* component. */
|
||||
async changePassword(_oldPassword: string, _newPassword: string) {
|
||||
async changePassword(oldPassword: string, newPassword: string) {
|
||||
const cognitoUserResult = await currentAuthenticatedUser()
|
||||
if (cognitoUserResult.ok) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
// Ignored.
|
||||
})
|
||||
const result = await results.Result.wrapAsync(() =>
|
||||
fetch('https://mock-cognito.com/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ oldPassword, newPassword }),
|
||||
})
|
||||
)
|
||||
return result.mapErr(original.intoAmplifyErrorOrThrow)
|
||||
} else {
|
||||
return results.Err(cognitoUserResult.val)
|
||||
|
@ -107,6 +107,7 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
|
||||
<aria.TextField
|
||||
key={organization?.name ?? 0}
|
||||
defaultValue={organization?.name ?? ''}
|
||||
validate={name => (/\S/.test(name) ? true : '')}
|
||||
className="flex h-row gap-settings-entry"
|
||||
>
|
||||
<aria.Label className="text my-auto w-organization-settings-label">
|
||||
@ -122,28 +123,32 @@ export default function OrganizationSettingsSection(props: OrganizationSettingsS
|
||||
<aria.TextField
|
||||
key={organization?.email ?? 1}
|
||||
defaultValue={organization?.email ?? ''}
|
||||
className="flex h-row gap-settings-entry"
|
||||
validate={email => (isEmail(email) ? true : getText('invalidEmailValidationError'))}
|
||||
className="flex h-row items-start gap-settings-entry"
|
||||
>
|
||||
<aria.Label className="text my-auto w-organization-settings-label">
|
||||
{getText('email')}
|
||||
</aria.Label>
|
||||
<SettingsInput
|
||||
key={organization?.email}
|
||||
ref={emailRef}
|
||||
type="text"
|
||||
onSubmit={value => {
|
||||
if (isEmail(value)) {
|
||||
void doUpdateEmail()
|
||||
} else {
|
||||
emailRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
onChange={() => {
|
||||
emailRef.current?.setCustomValidity(
|
||||
isEmail(emailRef.current.value) ? '' : 'Invalid email.'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="flex grow flex-col">
|
||||
<SettingsInput
|
||||
key={organization?.email}
|
||||
ref={emailRef}
|
||||
type="text"
|
||||
onSubmit={value => {
|
||||
if (isEmail(value)) {
|
||||
void doUpdateEmail()
|
||||
} else {
|
||||
emailRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
onChange={() => {
|
||||
emailRef.current?.setCustomValidity(
|
||||
isEmail(emailRef.current.value) ? '' : 'Invalid email.'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<aria.FieldError className="text-red-700" />
|
||||
</div>
|
||||
</aria.TextField>
|
||||
<aria.TextField
|
||||
key={organization?.website ?? 2}
|
||||
|
@ -117,7 +117,7 @@ export function AddPaymentMethodForm(props: AddPaymentMethodFormProps) {
|
||||
form={form}
|
||||
onSubmit={() => subscribeMutation.mutateAsync()}
|
||||
>
|
||||
<ariaComponents.Form.Field name="card" fullWidth label={getText('BankCardLabel')}>
|
||||
<ariaComponents.Form.Field name="card" fullWidth label={getText('bankCardLabel')}>
|
||||
<stripeReact.CardElement
|
||||
options={{
|
||||
classes: {
|
||||
|
@ -701,16 +701,6 @@ export function useAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// === shouldPreventNavigation ===
|
||||
// ===============================
|
||||
|
||||
/** True if navigation should be prevented, for debugging purposes. */
|
||||
function getShouldPreventNavigation() {
|
||||
const location = router.useLocation()
|
||||
return new URLSearchParams(location.search).get('prevent-navigation') === 'true'
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === ProtectedLayout ===
|
||||
// =======================
|
||||
@ -718,11 +708,10 @@ function getShouldPreventNavigation() {
|
||||
/** A React Router layout route containing routes only accessible by users that are logged in. */
|
||||
export function ProtectedLayout() {
|
||||
const { session } = useAuth()
|
||||
const shouldPreventNavigation = getShouldPreventNavigation()
|
||||
|
||||
if (!shouldPreventNavigation && session == null) {
|
||||
if (session == null) {
|
||||
return <router.Navigate to={appUtils.LOGIN_PATH} />
|
||||
} else if (!shouldPreventNavigation && session?.type === UserSessionType.partial) {
|
||||
} else if (session.type === UserSessionType.partial) {
|
||||
return <router.Navigate to={appUtils.SET_USERNAME_PATH} />
|
||||
} else {
|
||||
return <router.Outlet context={session} />
|
||||
@ -738,9 +727,8 @@ export function ProtectedLayout() {
|
||||
export function SemiProtectedLayout() {
|
||||
const { session } = useAuth()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const shouldPreventNavigation = getShouldPreventNavigation()
|
||||
|
||||
if (!shouldPreventNavigation && session?.type === UserSessionType.full) {
|
||||
if (session?.type === UserSessionType.full) {
|
||||
const redirectTo = localStorage.get('loginRedirect')
|
||||
if (redirectTo != null) {
|
||||
localStorage.delete('loginRedirect')
|
||||
@ -763,11 +751,10 @@ export function SemiProtectedLayout() {
|
||||
export function GuestLayout() {
|
||||
const { session } = useAuth()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const shouldPreventNavigation = getShouldPreventNavigation()
|
||||
|
||||
if (!shouldPreventNavigation && session?.type === UserSessionType.partial) {
|
||||
if (session?.type === UserSessionType.partial) {
|
||||
return <router.Navigate to={appUtils.SET_USERNAME_PATH} />
|
||||
} else if (!shouldPreventNavigation && session?.type === UserSessionType.full) {
|
||||
} else if (session?.type === UserSessionType.full) {
|
||||
const redirectTo = localStorage.get('loginRedirect')
|
||||
if (redirectTo != null) {
|
||||
localStorage.delete('loginRedirect')
|
||||
@ -784,27 +771,19 @@ export function GuestLayout() {
|
||||
/** A React Router layout route containing routes only accessible by users that are not deleted. */
|
||||
export function NotDeletedUserLayout() {
|
||||
const { session, isUserMarkedForDeletion } = useAuth()
|
||||
const shouldPreventNavigation = getShouldPreventNavigation()
|
||||
|
||||
if (shouldPreventNavigation) {
|
||||
return <router.Outlet context={session} />
|
||||
if (isUserMarkedForDeletion()) {
|
||||
return <router.Navigate to={appUtils.RESTORE_USER_PATH} />
|
||||
} else {
|
||||
if (isUserMarkedForDeletion()) {
|
||||
return <router.Navigate to={appUtils.RESTORE_USER_PATH} />
|
||||
} else {
|
||||
return <router.Outlet context={session} />
|
||||
}
|
||||
return <router.Outlet context={session} />
|
||||
}
|
||||
}
|
||||
|
||||
/** A React Router layout route containing routes only accessible by users that are deleted softly. */
|
||||
export function SoftDeletedUserLayout() {
|
||||
const { session, isUserMarkedForDeletion, isUserDeleted, isUserSoftDeleted } = useAuth()
|
||||
const shouldPreventNavigation = getShouldPreventNavigation()
|
||||
|
||||
if (shouldPreventNavigation) {
|
||||
return <router.Outlet context={session} />
|
||||
} else if (isUserMarkedForDeletion()) {
|
||||
if (isUserMarkedForDeletion()) {
|
||||
const isSoftDeleted = isUserSoftDeleted()
|
||||
const isDeleted = isUserDeleted()
|
||||
if (isSoftDeleted) {
|
||||
|
@ -168,7 +168,7 @@ export interface User extends UserInfo {
|
||||
readonly isEnabled: boolean
|
||||
readonly rootDirectoryId: DirectoryId
|
||||
readonly profilePicture?: HttpsUrl
|
||||
readonly userGroups: UserGroupId[] | null
|
||||
readonly userGroups: readonly UserGroupId[] | null
|
||||
readonly removeAt?: dateTime.Rfc3339DateTime | null
|
||||
readonly plan?: Plan
|
||||
}
|
||||
@ -1012,16 +1012,12 @@ export interface InviteUserRequestBody {
|
||||
readonly userEmail: EmailAddress
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request body for the "list invitations" endpoint.
|
||||
*/
|
||||
export interface InvitationListRequestBody {
|
||||
readonly invitations: Invitation[]
|
||||
/** HTTP response body for the "list invitations" endpoint. */
|
||||
export interface ListInvitationsResponseBody {
|
||||
readonly invitations: readonly Invitation[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation to join an organization.
|
||||
*/
|
||||
/** Invitation to join an organization. */
|
||||
export interface Invitation {
|
||||
readonly organizationId: OrganizationId
|
||||
readonly userEmail: EmailAddress
|
||||
@ -1030,7 +1026,7 @@ export interface Invitation {
|
||||
|
||||
/** HTTP request body for the "create permission" endpoint. */
|
||||
export interface CreatePermissionRequestBody {
|
||||
readonly actorsIds: UserPermissionIdentifier[]
|
||||
readonly actorsIds: readonly UserPermissionIdentifier[]
|
||||
readonly resourceId: AssetId
|
||||
readonly action: permissions.PermissionAction | null
|
||||
}
|
||||
@ -1286,7 +1282,7 @@ export default abstract class Backend {
|
||||
/** Invite a new user to the organization by email. */
|
||||
abstract inviteUser(body: InviteUserRequestBody): Promise<void>
|
||||
/** Return a list of invitations to the organization. */
|
||||
abstract listInvitations(): Promise<Invitation[]>
|
||||
abstract listInvitations(): Promise<readonly Invitation[]>
|
||||
/** Delete an invitation. */
|
||||
abstract deleteInvitation(userEmail: EmailAddress): Promise<void>
|
||||
/** Resend an invitation. */
|
||||
|
@ -3,8 +3,6 @@
|
||||
* Each exported function in the {@link LocalBackend} in this module corresponds to an API endpoint.
|
||||
* The functions are asynchronous and return a {@link Promise} that resolves to the response from
|
||||
* the API. */
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import Backend, * as backend from '#/services/Backend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
import ProjectManager from '#/services/ProjectManager'
|
||||
@ -101,11 +99,6 @@ export default class LocalBackend extends Backend {
|
||||
constructor(projectManagerUrl: string, rootDirectory: projectManager.Path) {
|
||||
super()
|
||||
this.projectManager = new ProjectManager(projectManagerUrl, rootDirectory)
|
||||
if (detect.IS_DEV_MODE) {
|
||||
// @ts-expect-error This exists only for debugging purposes. It does not have types
|
||||
// because it MUST NOT be used in this codebase.
|
||||
window.localBackend = this
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the ID of the root directory. */
|
||||
|
@ -134,11 +134,6 @@ export default class RemoteBackend extends Backend {
|
||||
this.logger.error(message)
|
||||
throw new Error(message)
|
||||
} else {
|
||||
if (detect.IS_DEV_MODE) {
|
||||
// @ts-expect-error This exists only for debugging purposes. It does not have types
|
||||
// because it MUST NOT be used in this codebase.
|
||||
window.remoteBackend = this
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -254,8 +249,8 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
|
||||
/** List all invitations. */
|
||||
override async listInvitations(): Promise<backend.Invitation[]> {
|
||||
const response = await this.get<backend.InvitationListRequestBody>(
|
||||
override async listInvitations(): Promise<readonly backend.Invitation[]> {
|
||||
const response = await this.get<backend.ListInvitationsResponseBody>(
|
||||
remoteBackendPaths.INVITATION_PATH
|
||||
)
|
||||
|
||||
|
@ -51,6 +51,8 @@
|
||||
"otherUserIsUsingProjectError": "Someone else is using this project",
|
||||
"localBackendNotDetectedError": "Could not detect the local backend",
|
||||
|
||||
"invalidEmailValidationError": "Invalid email",
|
||||
|
||||
"projectHasNoSourceFilesPhrase": "project has no source files",
|
||||
"fileNotFoundPhrase": "file not found",
|
||||
|
||||
@ -604,6 +606,7 @@
|
||||
"userGroupNamePlaceholder": "Enter the name of the user group",
|
||||
|
||||
"assetSearchFieldLabel": "Search through items",
|
||||
"startModalLabel": "Start modal",
|
||||
"userMenuLabel": "User menu",
|
||||
"infoMenuLabel": "Info menu",
|
||||
"categorySwitcherMenuLabel": "Category switcher",
|
||||
@ -643,10 +646,8 @@
|
||||
"arbitraryFormErrorMessage": "Something went wrong while submitting the form. Please try again or contact the administrators.",
|
||||
|
||||
"subscribeSubmit": "Subscribe",
|
||||
"BankCardLabel": "Bank Card",
|
||||
"bankCardLabel": "Bank Card",
|
||||
"contactSales": "Contact Sales",
|
||||
"contactSalesDescription": "Contact our sales team to learn more about our Enterprise plan.",
|
||||
"ContactSalesButtonLabel": "Contact Us",
|
||||
|
||||
"setOrgNameTitle": "Set your organization name",
|
||||
|
||||
|
@ -107,7 +107,7 @@ export default class HttpClient {
|
||||
method: HttpMethod.put,
|
||||
url,
|
||||
payload,
|
||||
mimetype: 'application/octet-stream',
|
||||
mimetype: payload.type || 'application/octet-stream',
|
||||
})
|
||||
}
|
||||
|
||||
@ -124,11 +124,18 @@ export default class HttpClient {
|
||||
* @throws {Error} if the HTTP request fails. */
|
||||
private async request<T = void>(options: HttpClientRequestOptions) {
|
||||
const headers = new Headers(this.defaultHeaders)
|
||||
if (options.payload != null) {
|
||||
let payload = options.payload
|
||||
if (payload != null) {
|
||||
const contentType = options.mimetype ?? 'application/json'
|
||||
headers.set('Content-Type', contentType)
|
||||
}
|
||||
|
||||
// `Blob` request payloads are NOT VISIBLE in Playwright due to a Chromium bug.
|
||||
// https://github.com/microsoft/playwright/issues/6479#issuecomment-1574627457
|
||||
if (window.isInPlaywrightTest === true && payload instanceof Blob) {
|
||||
payload = await payload.arrayBuffer()
|
||||
}
|
||||
|
||||
try {
|
||||
// This is an UNSAFE type assertion, however this is a HTTP client
|
||||
// and should only be used to query APIs with known response types.
|
||||
@ -137,7 +144,7 @@ export default class HttpClient {
|
||||
method: options.method,
|
||||
headers,
|
||||
keepalive: options.keepalive ?? false,
|
||||
...(options.payload != null ? { body: options.payload } : {}),
|
||||
...(payload != null ? { body: payload } : {}),
|
||||
})) as ResponseWithTypedJson<T>
|
||||
document.dispatchEvent(new Event(FETCH_SUCCESS_EVENT_NAME))
|
||||
return response
|
||||
|
1
app/ide-desktop/lib/types/globals.d.ts
vendored
1
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -108,6 +108,7 @@ declare global {
|
||||
readonly backendApi?: BackendApi
|
||||
readonly authenticationApi: AuthenticationApi
|
||||
readonly navigationApi: NavigationApi
|
||||
readonly isInPlaywrightTest?: boolean
|
||||
readonly menuApi: MenuApi
|
||||
readonly versionInfo?: VersionInfo
|
||||
toggleDevtools: () => void
|
||||
|
Loading…
Reference in New Issue
Block a user