Clean up integration tests and add listeners for backend calls (#11847)

- Close https://github.com/enso-org/cloud-v2/issues/1604
- Add ability to track backend calls
- Remove inconsistent integration test code
- Add skeleton classes for settings pages

# Important Notes
None
This commit is contained in:
somebody1234 2024-12-12 19:49:58 +10:00 committed by GitHub
parent 2964457d48
commit b83c5a15eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 2460 additions and 2338 deletions

View File

@ -3,51 +3,47 @@
## Running tests
Execute all commands from the parent directory.
Note that all options can be used in any combination.
```sh
# Run tests normally
pnpm run test:integration
pnpm playwright test
# Open UI to run tests
pnpm run test:integration:debug
pnpm playwright test --ui
# Run tests in a specific file only
pnpm run test:integration -- integration-test/file-name-here.spec.ts
pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts
pnpm playwright test integration-test/dashboard/file-name-here.spec.ts
# Compile the entire app before running the tests.
# DOES NOT hot reload the tests.
# Prefer not using this when you are trying to fix a test;
# prefer using this when you just want to know which tests are failing (if any).
PROD=1 pnpm run test:integration
PROD=1 pnpm run test:integration:debug
PROD=1 pnpm run test:integration -- integration-test/file-name-here.spec.ts
PROD=1 pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts
PROD=true pnpm playwright test
```
## Getting started
```ts
test.test('test name here', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
// ONLY chain methods from `pageActions`.
// Using methods not in `pageActions` is UNDEFINED BEHAVIOR.
// If it is absolutely necessary though, please remember to `await` the method chain.
// Note that the `async`/`await` pair is REQUIRED, as `Actions` subclasses are `PromiseLike`s,
// not `Promise`s, which causes Playwright to output a type error.
async ({ pageActions }) => await pageActions.goTo.drive(),
),
)
// ONLY chain methods from `pageActions`.
// Using methods not in `pageActions` is UNDEFINED BEHAVIOR.
// If it is absolutely necessary though, please remember to `await` the method chain.
test('test name here', ({ page }) => mockAllAndLogin({ page }).goToPage.drive())
```
### Perform arbitrary actions (e.g. actions on the API)
```ts
test.test('test name here', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions, api }) =>
await pageActions.do(() => {
api.foo()
api.bar()
test.expect(api.baz()?.quux).toEqual('bar')
}),
),
)
test('test name here', ({ page }) =>
mockAllAndLogin({ page }).do((_page, { api }) => {
api.foo()
api.bar()
expect(api.baz()?.quux).toEqual('bar')
}))
```
### Writing new classes extending `BaseActions`
- Make sure that every method returns either the class itself (`this`) or `.into(AnotherActionsClass<Context>)`.
- Avoid constructing `new AnotherActionsClass()` - instead prefer `.into(AnotherActionsClass<Context>)` and optionally `.into(ThisClass<Context>)` if required.
- Never construct an `ActionsClass`
- In some rare exceptions, it is fine as long as you `await` the `PageActions` class - for example in `index.ts` there is `await new StartModalActions().close()`.
- Methods for locators are fine, but it is not recommended to expose them as it makes it easy to accidentally - i.e. it is fine as long as they are `private`.
- In general, avoid exposing any method that returns a `Promise` rather than a `PageActions`.

View File

@ -1,31 +1,31 @@
/** @file The base class from which all `Actions` classes are derived. */
import * as test from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import type * as inputBindings from '#/utilities/inputBindings'
import type { AutocompleteKeybind } from '#/utilities/inputBindings'
import { modModifier } from '.'
// ====================
// === PageCallback ===
// ====================
/** A callback that performs actions on a {@link test.Page}. */
export interface PageCallback {
(input: test.Page): Promise<void> | void
/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */
async function modModifier(page: Page) {
let userAgent = ''
await test.step('Detect browser OS', async () => {
userAgent = await page.evaluate(() => navigator.userAgent)
})
return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control'
}
// =======================
// === LocatorCallback ===
// =======================
/** A callback that performs actions on a {@link test.Locator}. */
export interface LocatorCallback {
(input: test.Locator): Promise<void> | void
/** A callback that performs actions on a {@link Page}. */
export interface PageCallback<Context> {
(input: Page, context: Context): Promise<void> | void
}
// ===================
// === BaseActions ===
// ===================
/** A callback that performs actions on a {@link Locator}. */
export interface LocatorCallback<Context> {
(input: Locator, context: Context): Promise<void> | void
}
export interface BaseActionsClass<Context, Args extends readonly unknown[] = []> {
// The return type should be `InstanceType<this>`, but that results in a circular reference error.
new (page: Page, context: Context, promise: Promise<void>, ...args: Args): any
}
/**
* The base class from which all `Actions` classes are derived.
@ -34,10 +34,11 @@ export interface LocatorCallback {
*
* [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables
*/
export default class BaseActions implements Promise<void> {
export default class BaseActions<Context> implements Promise<void> {
/** Create a {@link BaseActions}. */
constructor(
protected readonly page: test.Page,
protected readonly page: Page,
protected readonly context: Context,
private readonly promise = Promise.resolve(),
) {}
@ -53,11 +54,11 @@ export default class BaseActions implements Promise<void> {
* 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 () => {
static press(page: Page, keyOrShortcut: string): Promise<void> {
return test.step(`Press '${keyOrShortcut}'`, async () => {
if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) {
let userAgent = ''
await test.test.step('Detect browser OS', async () => {
await test.step('Detect browser OS', async () => {
userAgent = await page.evaluate(() => navigator.userAgent)
})
const isMacOS = /\bMac OS\b/i.test(userAgent)
@ -99,43 +100,49 @@ export default class BaseActions implements Promise<void> {
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
into<
T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>,
T extends new (
page: Page,
context: Context,
promise: Promise<void>,
...args: Args
) => InstanceType<T>,
Args extends readonly unknown[],
>(clazz: T, ...args: Args): InstanceType<T> {
return new clazz(this.page, this.promise, ...args)
return new clazz(this.page, this.context, this.promise, ...args)
}
/**
* Perform an action on the current page. This should generally be avoided in favor of using
* Perform an action. This should generally be avoided in favor of using
* specific methods; this is more or less an escape hatch used ONLY when the methods do not
* support desired functionality.
*/
do(callback: PageCallback): this {
do(callback: PageCallback<Context>): this {
// @ts-expect-error This is SAFE, but only when the constructor of this class has the exact
// same parameters as `BaseActions`.
return new this.constructor(
this.page,
this.then(() => callback(this.page)),
this.context,
this.then(() => callback(this.page, this.context)),
)
}
/** Perform an action on the current page. */
step(name: string, callback: PageCallback) {
return this.do(() => test.test.step(name, () => callback(this.page)))
/** Perform an action. */
step(name: string, callback: PageCallback<Context>) {
return this.do(() => test.step(name, () => callback(this.page, this.context)))
}
/**
* Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms.
*/
press<Key extends string>(keyOrShortcut: inputBindings.AutocompleteKeybind<Key>) {
press<Key extends string>(keyOrShortcut: AutocompleteKeybind<Key>) {
return this.do((page) => BaseActions.press(page, keyOrShortcut))
}
/** Perform actions until a predicate passes. */
retry(
callback: (actions: this) => this,
predicate: (page: test.Page) => Promise<boolean>,
predicate: (page: Page) => Promise<boolean>,
options: { retries?: number; delay?: number } = {},
) {
const { retries = 3, delay = 1_000 } = options
@ -152,7 +159,7 @@ export default class BaseActions implements Promise<void> {
}
/** Perform actions with the "Mod" modifier key pressed. */
withModPressed<R extends BaseActions>(callback: (actions: this) => R) {
withModPressed<R extends BaseActions<Context>>(callback: (actions: this) => R) {
return callback(
this.step('Press "Mod"', async (page) => {
await page.keyboard.down(await modModifier(page))
@ -171,11 +178,11 @@ export default class BaseActions implements Promise<void> {
return this
} else if (expected != null) {
return this.step(`Expect ${description} error to be '${expected}'`, async (page) => {
await test.expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected)
await expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected)
})
} else {
return this.step(`Expect no ${description} error`, async (page) => {
await test.expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible()
await expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible()
})
}
}

View File

@ -0,0 +1,11 @@
/** @file Actions for the "user" tab of the "settings" page. */
import { goToPageActions, type GoToPageActions } from './goToPageActions'
import PageActions from './PageActions'
/** Actions common to all settings pages. */
export default class BaseSettingsTabActions<Context> extends PageActions<Context> {
/** Actions for navigating to another page. */
get goToPage(): Omit<GoToPageActions<Context>, 'settings'> {
return goToPageActions(this.step.bind(this))
}
}

View File

@ -1,60 +1,92 @@
/** @file Actions for the "drive" page. */
import * as test from 'playwright/test'
import { expect, type Locator, type Page } from '@playwright/test'
import {
locateAssetPanel,
locateAssetsTable,
locateContextMenu,
locateCreateButton,
locateDriveView,
locateNewSecretIcon,
locateNonAssetRows,
locateSecretNameInput,
locateSecretValueInput,
TEXT,
} from '.'
import type * as baseActions from './BaseActions'
import * as contextMenuActions from './contextMenuActions'
import * as goToPageActions from './goToPageActions'
import { TEXT } from '.'
import type { LocatorCallback } from './BaseActions'
import { contextMenuActions } from './contextMenuActions'
import EditorPageActions from './EditorPageActions'
import { goToPageActions, type GoToPageActions } from './goToPageActions'
import NewDataLinkModalActions from './NewDataLinkModalActions'
import PageActions from './PageActions'
import StartModalActions from './StartModalActions'
// =================
// === Constants ===
// =================
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
// =======================
// === locateAssetRows ===
// =======================
/** Find the context menu. */
function locateContextMenu(page: Page) {
// This has no identifying features.
return page.getByTestId('context-menu')
}
/** Find all assets table rows (if any). */
function locateAssetRows(page: test.Page) {
/** Find a drive view. */
function locateDriveView(page: Page) {
// This has no identifying features.
return page.getByTestId('drive-view')
}
/** Find a "create" button. */
function locateCreateButton(page: Page) {
return page.getByRole('button', { name: TEXT.create }).getByText(TEXT.create)
}
/** Find an assets table. */
function locateAssetsTable(page: Page) {
return page.getByTestId('drive-view').getByRole('table')
}
/** Find all assets table rows. */
function locateAssetRows(page: Page) {
return locateAssetsTable(page).getByTestId('asset-row')
}
// ========================
// === DrivePageActions ===
// ========================
/** Find assets table placeholder rows. */
function locateNonAssetRows(page: Page) {
return locateAssetsTable(page).locator('tbody tr:not([data-testid="asset-row"])')
}
/** Find a "new secret" icon. */
function locateNewSecretIcon(page: Page) {
return page.getByRole('button', { name: 'New Secret' })
}
/** Find an "upsert secret" modal. */
function locateUpsertSecretModal(page: Page) {
// This has no identifying features.
return page.getByTestId('upsert-secret-modal')
}
/** Find a "name" input for an "upsert secret" modal. */
function locateSecretNameInput(page: Page) {
return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretNamePlaceholder)
}
/** Find a "value" input for an "upsert secret" modal. */
function locateSecretValueInput(page: Page) {
return locateUpsertSecretModal(page).getByPlaceholder(TEXT.secretValuePlaceholder)
}
/** Find an asset panel. */
function locateAssetPanel(page: Page) {
// This has no identifying features.
return page.getByTestId('asset-panel').locator('visible=true')
}
/** Actions for the "drive" page. */
export default class DrivePageActions extends PageActions {
export default class DrivePageActions<Context> extends PageActions<Context> {
/** Actions for navigating to another page. */
get goToPage(): Omit<goToPageActions.GoToPageActions, 'drive'> {
return goToPageActions.goToPageActions(this.step.bind(this))
get goToPage(): Omit<GoToPageActions<Context>, 'drive'> {
return goToPageActions(this.step.bind(this))
}
/** Actions related to context menus. */
get contextMenu() {
return contextMenuActions.contextMenuActions(this.step.bind(this))
return contextMenuActions(this.step.bind(this))
}
/** Switch to a different category. */
get goToCategory() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self: DrivePageActions = this
const self: DrivePageActions<Context> = this
return {
/** Switch to the "cloud" category. */
cloud() {
@ -92,24 +124,50 @@ export default class DrivePageActions extends PageActions {
}
}
/** Interact with the assets search bar. */
withSearchBar(callback: LocatorCallback<Context>) {
return this.step('Interact with search bar', (page, context) =>
callback(page.getByTestId('asset-search-bar').getByPlaceholder(/(?:)/), context),
)
}
/** Actions specific to the Drive table. */
get driveTable() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self: DrivePageActions = this
const self: DrivePageActions<Context> = this
const locateNameColumnHeading = (page: Page) =>
page
.getByLabel(TEXT.sortByName)
.or(page.getByLabel(TEXT.sortByNameDescending))
.or(page.getByLabel(TEXT.stopSortingByName))
const locateModifiedColumnHeading = (page: Page) =>
page
.getByLabel(TEXT.sortByModificationDate)
.or(page.getByLabel(TEXT.sortByModificationDateDescending))
.or(page.getByLabel(TEXT.stopSortingByModificationDate))
return {
/** Click the column heading for the "name" column to change its sort order. */
clickNameColumnHeading() {
return self.step('Click "name" column heading', (page) =>
page.getByLabel(TEXT.sortByName).or(page.getByLabel(TEXT.stopSortingByName)).click(),
locateNameColumnHeading(page).click(),
)
},
/** Interact with the column heading for the "name" column. */
withNameColumnHeading(callback: LocatorCallback<Context>) {
return self.step('Interact with "name" column heading', (page, context) =>
callback(locateNameColumnHeading(page), context),
)
},
/** Click the column heading for the "modified" column to change its sort order. */
clickModifiedColumnHeading() {
return self.step('Click "modified" column heading', (page) =>
page
.getByLabel(TEXT.sortByModificationDate)
.or(page.getByLabel(TEXT.stopSortingByModificationDate))
.click(),
locateModifiedColumnHeading(page).click(),
)
},
/** Interact with the column heading for the "modified" column. */
withModifiedColumnHeading(callback: LocatorCallback<Context>) {
return self.step('Interact with "modified" column heading', (page, context) =>
callback(locateModifiedColumnHeading(page), context),
)
},
/** Click to select a specific row. */
@ -138,13 +196,14 @@ export default class DrivePageActions extends PageActions {
/** Interact with the set of all rows in the Drive table. */
withRows(
callback: (
assetRows: test.Locator,
nonAssetRows: test.Locator,
page: test.Page,
assetRows: Locator,
nonAssetRows: Locator,
context: Context,
page: Page,
) => Promise<void> | void,
) {
return self.step('Interact with drive table rows', async (page) => {
await callback(locateAssetRows(page), locateNonAssetRows(page), page)
await callback(locateAssetRows(page), locateNonAssetRows(page), self.context, page)
})
},
/** Drag a row onto another row. */
@ -158,7 +217,7 @@ export default class DrivePageActions extends PageActions {
})
},
/** Drag a row onto another row. */
dragRow(from: number, to: test.Locator, force?: boolean) {
dragRow(from: number, to: Locator, force?: boolean) {
return self.step(`Drag drive table row #${from} to custom locator`, (page) =>
locateAssetRows(page)
.nth(from)
@ -174,10 +233,10 @@ export default class DrivePageActions extends PageActions {
*/
expectPlaceholderRow() {
return self.step('Expect placeholder row', async (page) => {
await test.expect(locateAssetRows(page)).toHaveCount(0)
await expect(locateAssetRows(page)).toHaveCount(0)
const nonAssetRows = locateNonAssetRows(page)
await test.expect(nonAssetRows).toHaveCount(1)
await test.expect(nonAssetRows).toHaveText(/This folder is empty/)
await expect(nonAssetRows).toHaveCount(1)
await expect(nonAssetRows).toHaveText(/This folder is empty/)
})
},
/**
@ -186,10 +245,10 @@ export default class DrivePageActions extends PageActions {
*/
expectTrashPlaceholderRow() {
return self.step('Expect trash placeholder row', async (page) => {
await test.expect(locateAssetRows(page)).toHaveCount(0)
await expect(locateAssetRows(page)).toHaveCount(0)
const nonAssetRows = locateNonAssetRows(page)
await test.expect(nonAssetRows).toHaveCount(1)
await test.expect(nonAssetRows).toHaveText(/Your trash is empty/)
await expect(nonAssetRows).toHaveCount(1)
await expect(nonAssetRows).toHaveText(/Your trash is empty/)
})
},
/** Toggle a column's visibility. */
@ -240,7 +299,14 @@ export default class DrivePageActions extends PageActions {
openStartModal() {
return this.step('Open "start" modal', (page) =>
page.getByText(TEXT.startWithATemplate).click(),
).into(StartModalActions)
).into(StartModalActions<Context>)
}
/** Expect the "start" modal to be visible. */
expectStartModal() {
return this.into(StartModalActions<Context>).withStartModal(async (startModal) => {
await expect(startModal).toBeVisible()
})
}
/** Create a new empty project. */
@ -250,19 +316,30 @@ export default class DrivePageActions extends PageActions {
(page) => page.getByText(TEXT.newEmptyProject, { exact: true }).click(),
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled.
) /* .into(EditorPageActions) */
) /* .into(EditorPageActions<Context>) */
}
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Delete once cloud execution in the browser is re-enabled.
/** Create a new empty project. */
newEmptyProjectTest() {
return this.step('Create empty project', (page) =>
page.getByText(TEXT.newEmptyProject, { exact: true }).click(),
).into(EditorPageActions<Context>)
}
/** Interact with the drive view (the main container of this page). */
withDriveView(callback: baseActions.LocatorCallback) {
return this.step('Interact with drive view', (page) => callback(locateDriveView(page)))
withDriveView(callback: LocatorCallback<Context>) {
return this.step('Interact with drive view', (page, context) =>
callback(locateDriveView(page), context),
)
}
/** Create a new folder using the icon in the Drive Bar. */
createFolder() {
return this.step('Create folder', async (page) => {
await page.getByRole('button', { name: TEXT.newFolder, exact: true }).click()
await test.expect(page.locator('input:focus')).toBeVisible()
await expect(page.locator('input:focus')).toBeVisible()
await page.keyboard.press('Escape')
})
}
@ -324,7 +401,7 @@ export default class DrivePageActions extends PageActions {
/**
* Check if the Asset Panel is shown.
*/
async isAssetPanelShown(page: test.Page) {
async isAssetPanelShown(page: Page) {
return await page
.getByTestId('asset-panel')
.isVisible({ timeout: 0 })
@ -337,7 +414,7 @@ export default class DrivePageActions extends PageActions {
/**
* Wait for the Asset Panel to be shown and visually stable
*/
async waitForAssetPanelShown(page: test.Page) {
async waitForAssetPanelShown(page: Page) {
await page.getByTestId('asset-panel').waitFor({ state: 'visible' })
}
@ -358,16 +435,18 @@ export default class DrivePageActions extends PageActions {
}
/** Interact with the container element of the assets table. */
withAssetsTable(callback: baseActions.LocatorCallback) {
withAssetsTable(
callback: (input: Locator, context: Context, page: Page) => Promise<void> | void,
) {
return this.step('Interact with drive table', async (page) => {
await callback(locateAssetsTable(page))
await callback(locateAssetsTable(page), this.context, page)
})
}
/** Interact with the Asset Panel. */
withAssetPanel(callback: baseActions.LocatorCallback) {
return this.step('Interact with asset panel', async (page) => {
await callback(locateAssetPanel(page))
withAssetPanel(callback: LocatorCallback<Context>) {
return this.step('Interact with asset panel', async (page, context) => {
await callback(locateAssetPanel(page), context)
})
}
@ -375,27 +454,13 @@ export default class DrivePageActions extends PageActions {
openDataLinkModal() {
return this.step('Open "new data link" modal', (page) =>
page.getByRole('button', { name: TEXT.newDatalink }).click(),
).into(NewDataLinkModalActions)
).into(NewDataLinkModalActions<Context>)
}
/** Interact with the context menus (the context menus MUST be visible). */
withContextMenus(callback: baseActions.LocatorCallback) {
return this.step('Interact with context menus', async (page) => {
await callback(locateContextMenu(page))
})
}
/** Close the "get started" modal. */
closeGetStartedModal() {
return this.step('Close "get started" modal', async (page) => {
await new StartModalActions(page).close()
})
}
/** Interact with the "start" modal. */
withStartModal(callback: baseActions.LocatorCallback) {
return this.step('Interact with start modal', async (page) => {
await callback(new StartModalActions(page).locateStartModal())
withContextMenus(callback: LocatorCallback<Context>) {
return this.step('Interact with context menus', async (page, context) => {
await callback(locateContextMenu(page), context)
})
}
}

View File

@ -1,19 +1,15 @@
/** @file Actions for the "editor" page. */
import * as goToPageActions from './goToPageActions'
import { goToPageActions, type GoToPageActions } from './goToPageActions'
import PageActions from './PageActions'
// =========================
// === EditorPageActions ===
// =========================
/** Actions for the "editor" page. */
export default class EditorPageActions extends PageActions {
export default class EditorPageActions<Context> extends PageActions<Context> {
/** Actions for navigating to another page. */
get goToPage(): Omit<goToPageActions.GoToPageActions, 'editor'> {
return goToPageActions.goToPageActions(this.step.bind(this))
get goToPage(): Omit<GoToPageActions<Context>, 'editor'> {
return goToPageActions(this.step.bind(this))
}
/** Waits for the editor to load. */
waitForEditorToLoad(): EditorPageActions {
waitForEditorToLoad(): EditorPageActions<Context> {
return this.step('wait for the editor to load', async () => {
await this.page.waitForSelector('[data-testid=editor]', { state: 'visible' })
})

View File

@ -1,30 +1,26 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import { expect } from '@playwright/test'
import { TEXT, VALID_EMAIL } from '.'
import BaseActions, { type LocatorCallback } from './BaseActions'
import LoginPageActions from './LoginPageActions'
// =================================
// === ForgotPasswordPageActions ===
// =================================
/** Available actions for the login page. */
export default class ForgotPasswordPageActions extends BaseActions {
export default class ForgotPasswordPageActions<Context> extends BaseActions<Context> {
/** Actions for navigating to another page. */
get goToPage() {
return {
login: (): LoginPageActions =>
login: (): LoginPageActions<Context> =>
this.step("Go to 'login' page", async (page) =>
page.getByRole('link', { name: TEXT.goBackToLogin, exact: true }).click(),
).into(LoginPageActions),
).into(LoginPageActions<Context>),
}
}
/** Perform a successful login. */
forgotPassword(email = VALID_EMAIL) {
return this.step('Forgot password', () => this.forgotPasswordInternal(email)).into(
LoginPageActions,
LoginPageActions<Context>,
)
}
@ -36,9 +32,9 @@ export default class ForgotPasswordPageActions extends BaseActions {
}
/** Interact with the email input. */
withEmailInput(callback: LocatorCallback) {
return this.step('Interact with email input', async (page) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder))
withEmailInput(callback: LocatorCallback<Context>) {
return this.step('Interact with email input', async (page, context) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder), context)
})
}
@ -49,6 +45,6 @@ export default class ForgotPasswordPageActions extends BaseActions {
.getByRole('button', { name: TEXT.login, exact: true })
.getByText(TEXT.login)
.click()
await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
}

View File

@ -1,5 +1,5 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import { expect } from '@playwright/test'
import { TEXT, VALID_EMAIL, VALID_PASSWORD, passAgreementsDialog } from '.'
import BaseActions, { type LocatorCallback } from './BaseActions'
@ -8,23 +8,19 @@ import ForgotPasswordPageActions from './ForgotPasswordPageActions'
import RegisterPageActions from './RegisterPageActions'
import SetupUsernamePageActions from './SetupUsernamePageActions'
// ========================
// === LoginPageActions ===
// ========================
/** Available actions for the login page. */
export default class LoginPageActions extends BaseActions {
export default class LoginPageActions<Context> extends BaseActions<Context> {
/** Actions for navigating to another page. */
get goToPage() {
return {
register: (): RegisterPageActions =>
register: (): RegisterPageActions<Context> =>
this.step("Go to 'register' page", async (page) =>
page.getByRole('link', { name: TEXT.dontHaveAnAccount, exact: true }).click(),
).into(RegisterPageActions),
forgotPassword: (): ForgotPasswordPageActions =>
).into(RegisterPageActions<Context>),
forgotPassword: (): ForgotPasswordPageActions<Context> =>
this.step("Go to 'forgot password' page", async (page) =>
page.getByRole('link', { name: TEXT.forgotYourPassword, exact: true }).click(),
).into(ForgotPasswordPageActions),
).into(ForgotPasswordPageActions<Context>),
}
}
@ -33,7 +29,7 @@ export default class LoginPageActions extends BaseActions {
return this.step('Login', async (page) => {
await this.loginInternal(email, password)
await passAgreementsDialog({ page })
}).into(DrivePageActions)
}).into(DrivePageActions<Context>)
}
/** Perform a login as a new user (a user that does not yet have a username). */
@ -41,7 +37,7 @@ export default class LoginPageActions extends BaseActions {
return this.step('Login (as new user)', async (page) => {
await this.loginInternal(email, password)
await passAgreementsDialog({ page })
}).into(SetupUsernamePageActions)
}).into(SetupUsernamePageActions<Context>)
}
/** Perform a failing login. */
@ -66,11 +62,11 @@ export default class LoginPageActions extends BaseActions {
return next
} else if (formError != null) {
return next.step(`Expect form error to be '${formError}'`, async (page) => {
await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError)
await expect(page.getByTestId('form-submit-error')).toHaveText(formError)
})
} else {
return next.step('Expect no form error', async (page) => {
await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible()
await expect(page.getByTestId('form-submit-error')).not.toBeVisible()
})
}
}
@ -83,10 +79,10 @@ export default class LoginPageActions extends BaseActions {
}
/** Interact with the email input. */
withEmailInput(callback: LocatorCallback) {
return this.step('Interact with email input', async (page) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder))
})
withEmailInput(callback: LocatorCallback<Context>) {
return this.step('Interact with email input', (page, context) =>
callback(page.getByPlaceholder(TEXT.emailPlaceholder), context),
)
}
/** Internal login logic shared between all public methods. */
@ -97,6 +93,6 @@ export default class LoginPageActions extends BaseActions {
.getByRole('button', { name: TEXT.login, exact: true })
.getByText(TEXT.login)
.click()
await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
}

View File

@ -1,38 +1,29 @@
/** @file Actions for a "new Data Link" modal. */
import type * as test from 'playwright/test'
import type { Page } from '@playwright/test'
import { TEXT } from '.'
import type * as baseActions from './BaseActions'
import BaseActions from './BaseActions'
import BaseActions, { type LocatorCallback } from './BaseActions'
import DrivePageActions from './DrivePageActions'
// ==============================
// === locateNewDataLinkModal ===
// ==============================
/** Locate the "new data link" modal. */
function locateNewDataLinkModal(page: test.Page) {
function locateNewDataLinkModal(page: Page) {
return page.getByRole('dialog').filter({ has: page.getByText('Create Datalink') })
}
// ===============================
// === NewDataLinkModalActions ===
// ===============================
/** Actions for a "new Data Link" modal. */
export default class NewDataLinkModalActions extends BaseActions {
export default class NewDataLinkModalActions<Context> extends BaseActions<Context> {
/** Cancel creating the new Data Link (don't submit the form). */
cancel() {
cancel(): DrivePageActions<Context> {
return this.step('Cancel out of "new data link" modal', async () => {
await this.press('Escape')
}).into(DrivePageActions)
}).into(DrivePageActions<Context>)
}
/** Interact with the "name" input - for example, to set the name using `.fill("")`. */
withNameInput(callback: baseActions.LocatorCallback) {
return this.step('Interact with "name" input', async (page) => {
withNameInput(callback: LocatorCallback<Context>) {
return this.step('Interact with "name" input', async (page, context) => {
const locator = locateNewDataLinkModal(page).getByPlaceholder(TEXT.datalinkNamePlaceholder)
await callback(locator)
await callback(locator, context)
})
}
}

View File

@ -1,21 +1,17 @@
/** @file Actions common to all pages. */
import BaseActions from './BaseActions'
import * as openUserMenuAction from './openUserMenuAction'
import * as userMenuActions from './userMenuActions'
// ===================
// === PageActions ===
// ===================
import { openUserMenuAction } from './openUserMenuAction'
import { userMenuActions } from './userMenuActions'
/** Actions common to all pages. */
export default class PageActions extends BaseActions {
export default class PageActions<Context> extends BaseActions<Context> {
/** Actions related to the User Menu. */
get userMenu() {
return userMenuActions.userMenuActions(this.step.bind(this))
return userMenuActions(this.step.bind(this))
}
/** Open the User Menu. */
openUserMenu() {
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
return openUserMenuAction(this.step.bind(this))
}
}

View File

@ -1,23 +1,19 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import { expect } from '@playwright/test'
import { TEXT, VALID_EMAIL, VALID_PASSWORD } from '.'
import BaseActions, { type LocatorCallback } from './BaseActions'
import LoginPageActions from './LoginPageActions'
// ========================
// === LoginPageActions ===
// ========================
/** Available actions for the login page. */
export default class RegisterPageActions extends BaseActions {
export default class RegisterPageActions<Context> extends BaseActions<Context> {
/** Actions for navigating to another page. */
get goToPage() {
return {
login: (): LoginPageActions =>
login: (): LoginPageActions<Context> =>
this.step("Go to 'login' page", async (page) =>
page.getByRole('link', { name: TEXT.alreadyHaveAnAccount, exact: true }).click(),
).into(LoginPageActions),
).into(LoginPageActions<Context>),
}
}
@ -25,7 +21,7 @@ export default class RegisterPageActions extends BaseActions {
register(email = VALID_EMAIL, password = VALID_PASSWORD, confirmPassword = password) {
return this.step('Reegister', () =>
this.registerInternal(email, password, confirmPassword),
).into(LoginPageActions)
).into(LoginPageActions<Context>)
}
/** Perform a failing login. */
@ -55,11 +51,11 @@ export default class RegisterPageActions extends BaseActions {
return next
} else if (formError != null) {
return next.step(`Expect form error to be '${formError}'`, async (page) => {
await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError)
await expect(page.getByTestId('form-submit-error')).toHaveText(formError)
})
} else {
return next.step('Expect no form error', async (page) => {
await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible()
await expect(page.getByTestId('form-submit-error')).not.toBeVisible()
})
}
}
@ -72,9 +68,9 @@ export default class RegisterPageActions extends BaseActions {
}
/** Interact with the email input. */
withEmailInput(callback: LocatorCallback) {
return this.step('Interact with email input', async (page) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder))
withEmailInput(callback: LocatorCallback<Context>) {
return this.step('Interact with email input', async (page, context) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder), context)
})
}
@ -95,6 +91,6 @@ export default class RegisterPageActions extends BaseActions {
.getByRole('button', { name: TEXT.register, exact: true })
.getByText(TEXT.register)
.click()
await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
await expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
}

View File

@ -0,0 +1,42 @@
/** @file Actions for the "account" form in settings. */
import { TEXT } from '.'
import type { LocatorCallback } from './BaseActions'
import type PageActions from './PageActions'
import SettingsAccountTabActions from './SettingsAccountTabActions'
import SettingsFormActions from './SettingsFormActions'
/** Actions for the "account" form in settings. */
export default class SettingsAccountFormActions<Context> extends SettingsFormActions<
Context,
typeof SettingsAccountTabActions<Context>
> {
/** Create a {@link SettingsAccountFormActions}. */
constructor(...args: ConstructorParameters<typeof PageActions<Context>>) {
super(
SettingsAccountTabActions<Context>,
(page) =>
page
.getByRole('heading')
.and(page.getByText(TEXT.userAccountSettingsSection))
.locator('..'),
...args,
)
}
/** Fill the "name" input of this form. */
fillName(name: string) {
return this.step("Fill 'name' input of 'account' form", (page) =>
this.locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox').fill(name),
)
}
/** Interact with the "name" input of this form. */
withName(callback: LocatorCallback<Context>) {
return this.step("Interact with 'name' input of 'organization' form", (page, context) =>
callback(
this.locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox'),
context,
),
)
}
}

View File

@ -0,0 +1,37 @@
/** @file Actions for the "account" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import SettingsAccountFormActions from './SettingsAccountFormActions'
import SettingsChangePasswordFormActions from './SettingsChangePasswordFormActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "account" tab of the "settings" page. */
export default class SettingsAccountTabActions<Context> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'account'> {
return goToSettingsTabActions(this.step.bind(this))
}
/** Manipulate the "account" form. */
accountForm() {
return this.into(SettingsAccountFormActions<Context>)
}
/** Manipulate the "change password" form. */
changePasswordForm() {
return this.into(SettingsChangePasswordFormActions<Context>)
}
/** Upload a profile picture. */
uploadProfilePicture(
name: string,
content: WithImplicitCoercion<string | Uint8Array | readonly number[]>,
mimeType: string,
) {
return this.step('Upload account profile picture', async (page) => {
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByTestId('user-profile-picture-input').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }])
})
}
}

View File

@ -0,0 +1,13 @@
/** @file Actions for the "activity log" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "activity log" tab of the "settings" page. */
export default class SettingsActivityLogShortcutsTabActions<
Context,
> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'activityLog'> {
return goToSettingsTabActions(this.step.bind(this))
}
}

View File

@ -0,0 +1,13 @@
/** @file Actions for the "billing and plans" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "billing and plans" tab of the "settings" page. */
export default class SettingsBillingAndPlansTabActions<
Context,
> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'billingAndPlans'> {
return goToSettingsTabActions(this.step.bind(this))
}
}

View File

@ -0,0 +1,54 @@
/** @file Actions for the "change password" form in settings. */
import { TEXT } from '.'
import type PageActions from './PageActions'
import SettingsAccountTabActions from './SettingsAccountTabActions'
import SettingsFormActions from './SettingsFormActions'
/** Actions for the "change password" form in settings. */
export default class SettingsChangePasswordFormActions<Context> extends SettingsFormActions<
Context,
typeof SettingsAccountTabActions<Context>
> {
/** Create a {@link SettingsChangePasswordFormActions}. */
constructor(...args: ConstructorParameters<typeof PageActions<Context>>) {
super(
SettingsAccountTabActions<Context>,
(page) =>
page
.getByRole('heading')
.and(page.getByText(TEXT.changePasswordSettingsSection))
.locator('..'),
...args,
)
}
/** Fill the "current password" input of this form. */
fillCurrentPassword(name: string) {
return this.step("Fill 'current password' input of 'change password' form", (page) =>
this.locate(page)
.getByLabel(TEXT.userCurrentPasswordSettingsInput)
.getByRole('textbox')
.fill(name),
)
}
/** Fill the "new password" input of this form. */
fillNewPassword(name: string) {
return this.step("Fill 'new password' input of 'change password' form", (page) =>
this.locate(page)
.getByLabel(new RegExp('^' + TEXT.userNewPasswordSettingsInput))
.getByRole('textbox')
.fill(name),
)
}
/** Fill the "confirm new password" input of this form. */
fillConfirmNewPassword(name: string) {
return this.step("Fill 'confirm new password' input of 'change password' form", (page) =>
this.locate(page)
.getByLabel(TEXT.userConfirmNewPasswordSettingsInput)
.getByRole('textbox')
.fill(name),
)
}
}

View File

@ -0,0 +1,34 @@
/** @file Actions for the "account" form in settings. */
import type { Locator, Page } from '@playwright/test'
import { TEXT } from '.'
import type { BaseActionsClass } from './BaseActions'
import PageActions from './PageActions'
/** Actions for the "account" form in settings. */
export default class SettingsFormActions<
Context,
ParentClass extends BaseActionsClass<Context>,
> extends PageActions<Context> {
/** Construct a {@link SettingsFormActions}. */
constructor(
private parentClass: ParentClass,
protected locate: (page: Page) => Locator,
...args: ConstructorParameters<typeof PageActions<Context>>
) {
super(...args)
}
/** Save and submit this settings section. */
save(): InstanceType<ParentClass> {
return this.step('Save settings form', (page) =>
this.locate(page).getByRole('button', { name: TEXT.save }).getByText(TEXT.save).click(),
).into(this.parentClass)
}
/** Cancel editing this settings section. */
cancel(): InstanceType<ParentClass> {
return this.step('Cancel editing settings form', (page) =>
this.locate(page).getByRole('button', { name: TEXT.cancel }).getByText(TEXT.cancel).click(),
).into(this.parentClass)
}
}

View File

@ -0,0 +1,13 @@
/** @file Actions for the "keyboard shortcuts" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "keyboard shortcuts" tab of the "settings" page. */
export default class SettingsKeyboardShortcutsTabActions<
Context,
> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'keyboardShortcuts'> {
return goToSettingsTabActions(this.step.bind(this))
}
}

View File

@ -0,0 +1,11 @@
/** @file Actions for the "local" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "local" tab of the "settings" page. */
export default class SettingsLocalTabActions<Context> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'local'> {
return goToSettingsTabActions(this.step.bind(this))
}
}

View File

@ -0,0 +1,11 @@
/** @file Actions for the "members" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "members" tab of the "settings" page. */
export default class SettingsMembersTabActions<Context> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'members'> {
return goToSettingsTabActions(this.step.bind(this))
}
}

View File

@ -0,0 +1,105 @@
/** @file Actions for the "organization" form in settings. */
import { TEXT } from '.'
import type { LocatorCallback } from './BaseActions'
import type PageActions from './PageActions'
import SettingsFormActions from './SettingsFormActions'
import SettingsOrganizationTabActions from './SettingsOrganizationTabActions'
/** Actions for the "organization" form in settings. */
export default class SettingsOrganizationFormActions<Context> extends SettingsFormActions<
Context,
typeof SettingsOrganizationTabActions<Context>
> {
/** Create a {@link SettingsOrganizationFormActions}. */
constructor(...args: ConstructorParameters<typeof PageActions<Context>>) {
super(
SettingsOrganizationTabActions<Context>,
(page) =>
page
.getByRole('heading')
.and(page.getByText(TEXT.organizationSettingsSection))
.locator('..'),
...args,
)
}
/** Fill the "name" input of this form. */
fillName(name: string) {
return this.step("Fill 'name' input of 'organization' form", (page) =>
this.locate(page)
.getByLabel(TEXT.organizationNameSettingsInput)
.getByRole('textbox')
.fill(name),
)
}
/** Interact with the "name" input of this form. */
withName(callback: LocatorCallback<Context>) {
return this.step("Interact with 'name' input of 'organization' form", (page, context) =>
callback(
this.locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox'),
context,
),
)
}
/** Fill the "email" input of this form. */
fillEmail(name: string) {
return this.step("Fill 'email' input of 'organization' form", (page) =>
this.locate(page)
.getByLabel(TEXT.organizationEmailSettingsInput)
.getByRole('textbox')
.fill(name),
)
}
/** Interact with the "email" input of this form. */
withEmail(callback: LocatorCallback<Context>) {
return this.step("Interact with 'email' input of 'organization' form", (page, context) =>
callback(
this.locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox'),
context,
),
)
}
/** Fill the "website" input of this form. */
fillWebsite(name: string) {
return this.step("Fill 'website' input of 'organization' form", (page) =>
this.locate(page)
.getByLabel(TEXT.organizationWebsiteSettingsInput)
.getByRole('textbox')
.fill(name),
)
}
/** Interact with the "website" input of this form. */
withWebsite(callback: LocatorCallback<Context>) {
return this.step("Interact with 'website' input of 'organization' form", (page, context) =>
callback(
this.locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox'),
context,
),
)
}
/** Fill the "location" input of this form. */
fillLocation(name: string) {
return this.step("Fill 'location' input of 'organization' form", (page) =>
this.locate(page)
.getByLabel(TEXT.organizationLocationSettingsInput)
.getByRole('textbox')
.fill(name),
)
}
/** Interact with the "location" input of this form. */
withLocation(callback: LocatorCallback<Context>) {
return this.step("Interact with 'name' input of 'organization' form", (page, context) =>
callback(
this.locate(page).getByLabel(TEXT.organizationLocationSettingsInput).getByRole('textbox'),
context,
),
)
}
}

View File

@ -0,0 +1,33 @@
/** @file Actions for the "organization" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import SettingsOrganizationFormActions from './SettingsOrganizationFormActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "organization" tab of the "settings" page. */
export default class SettingsOrganizationTabActions<
Context,
> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'organization'> {
return goToSettingsTabActions(this.step.bind(this))
}
/** Manipulate the "organization" form. */
organizationForm() {
return this.into(SettingsOrganizationFormActions<Context>)
}
/** Upload a profile picture. */
uploadProfilePicture(
name: string,
content: WithImplicitCoercion<string | Uint8Array | readonly number[]>,
mimeType: string,
) {
return this.step('Upload organization profile picture', async (page) => {
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByTestId('organization-profile-picture-input').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles([{ name, mimeType, buffer: Buffer.from(content) }])
})
}
}

View File

@ -1,16 +1,10 @@
/** @file Actions for the "settings" page. */
import * as goToPageActions from './goToPageActions'
import PageActions from './PageActions'
/** @file Actions for the default tab of the "settings" page. */
import SettingsAccountTabActions from './SettingsAccountTabActions'
// ===========================
// === SettingsPageActions ===
// ===========================
/** Actions for the default tab of the "settings" page. */
type SettingsPageActions<Context> = SettingsAccountTabActions<Context>
// TODO: split settings page actions into different classes for each settings tab.
/** Actions for the "settings" page. */
export default class SettingsPageActions extends PageActions {
/** Actions for navigating to another page. */
get goToPage(): Omit<goToPageActions.GoToPageActions, 'drive'> {
return goToPageActions.goToPageActions(this.step.bind(this))
}
}
/** Actions for the default tab of the "settings" page. */
const SettingsPageActions = SettingsAccountTabActions
export default SettingsPageActions

View File

@ -0,0 +1,11 @@
/** @file Actions for the "user groups" tab of the "settings" page. */
import BaseSettingsTabActions from './BaseSettingsTabActions'
import { goToSettingsTabActions, type GoToSettingsTabActions } from './gotoSettingsTabActions'
/** Actions for the "user groups" tab of the "settings" page. */
export default class SettingsUserGroupsTabActions<Context> extends BaseSettingsTabActions<Context> {
/** Actions for navigating to another settings tab. */
get goToSettingsTab(): Omit<GoToSettingsTabActions<Context>, 'userGroups'> {
return goToSettingsTabActions(this.step.bind(this))
}
}

View File

@ -3,19 +3,15 @@ import { TEXT } from '.'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
// ============================
// === SetupDonePageActions ===
// ============================
/** Actions for the fourth step of the "setup" page. */
export default class SetupDonePageActions extends BaseActions {
export default class SetupDonePageActions<Context> extends BaseActions<Context> {
/** Go to the drive page. */
get goToPage() {
return {
drive: () =>
this.step("Finish setup and go to 'drive' page", async (page) => {
await page.getByText(TEXT.goToDashboard).click()
}).into(DrivePageActions),
}).into(DrivePageActions<Context>),
}
}
}

View File

@ -3,24 +3,20 @@ import { TEXT } from '.'
import BaseActions from './BaseActions'
import SetupTeamPageActions from './SetupTeamPageActions'
// ==============================
// === SetupInvitePageActions ===
// ==============================
/** Actions for the "invite users" step of the "setup" page. */
export default class SetupInvitePageActions extends BaseActions {
export default class SetupInvitePageActions<Context> extends BaseActions<Context> {
/** Invite users by email. */
inviteUsers(emails: string) {
return this.step(`Invite users '${emails.split(/[ ;,]+/).join("', '")}'`, async (page) => {
await page.getByLabel(TEXT.inviteEmailFieldLabel).getByRole('textbox').fill(emails)
await page.getByText(TEXT.inviteSubmit).click()
}).into(SetupTeamPageActions)
}).into(SetupTeamPageActions<Context>)
}
/** Continue to the next step without inviting users. */
skipInvitingUsers() {
return this.step('Skip inviting users in setup', async (page) => {
await page.getByText(TEXT.skip).click()
}).into(SetupTeamPageActions)
}).into(SetupTeamPageActions<Context>)
}
}

View File

@ -3,12 +3,8 @@ import { TEXT } from '.'
import BaseActions from './BaseActions'
import SetupInvitePageActions from './SetupInvitePageActions'
// ====================================
// === SetupOrganizationPageActions ===
// ====================================
/** Actions for the third step of the "setup" page. */
export default class SetupOrganizationPageActions extends BaseActions {
export default class SetupOrganizationPageActions<Context> extends BaseActions<Context> {
/** Set the organization name for this organization. */
setOrganizationName(organizationName: string) {
return this.step(`Set organization name to '${organizationName}'`, async (page) => {
@ -17,6 +13,6 @@ export default class SetupOrganizationPageActions extends BaseActions {
.and(page.getByRole('textbox'))
.fill(organizationName)
await page.getByText(TEXT.next).click()
}).into(SetupInvitePageActions)
}).into(SetupInvitePageActions<Context>)
}
}

View File

@ -6,12 +6,8 @@ import BaseActions from './BaseActions'
import SetupDonePageActions from './SetupDonePageActions'
import SetupOrganizationPageActions from './SetupOrganizationPageActions'
// ============================
// === SetupPlanPageActions ===
// ============================
/** Actions for the "select plan" step of the "setup" page. */
export default class SetupPlanPageActions extends BaseActions {
export default class SetupPlanPageActions<Context> extends BaseActions<Context> {
/** Select a plan. */
selectSoloPlan() {
return this.step(`Select 'solo' plan`, async (page) => {
@ -21,7 +17,7 @@ export default class SetupPlanPageActions extends BaseActions {
.getByText(TEXT.licenseAgreementCheckbox)
.click()
await page.getByText(TEXT.startTrial).click()
}).into(SetupDonePageActions)
}).into(SetupDonePageActions<Context>)
}
/** Select a plan that has teams. */
@ -38,20 +34,20 @@ export default class SetupPlanPageActions extends BaseActions {
.getByText(duration === 12 ? TEXT.billingPeriodOneYear : TEXT.billingPeriodThreeYears)
.click()
await page.getByText(TEXT.startTrial).click()
}).into(SetupOrganizationPageActions)
}).into(SetupOrganizationPageActions<Context>)
}
/** Stay on the current (free) plan. */
stayOnFreePlan() {
return this.step(`Stay on current plan`, async (page) => {
await page.getByText(TEXT.skip).click()
}).into(SetupDonePageActions)
}).into(SetupDonePageActions<Context>)
}
/** Stay on the current (paid) plan. */
stayOnPaidPlan() {
return this.step(`Stay on current plan`, async (page) => {
await page.getByText(TEXT.skip).click()
}).into(SetupOrganizationPageActions)
}).into(SetupOrganizationPageActions<Context>)
}
}

View File

@ -3,12 +3,8 @@ import { TEXT } from '.'
import BaseActions from './BaseActions'
import SetupDonePageActions from './SetupDonePageActions'
// ================================
// === SetupTeamNamePageActions ===
// ================================
/** Actions for the "setup team name" page. */
export default class SetupTeamNamePagePageActions extends BaseActions {
export default class SetupTeamNamePagePageActions<Context> extends BaseActions<Context> {
/** Set the username for a new user that does not yet have a username. */
setTeamName(teamName: string) {
return this.step(`Set team name to '${teamName}'`, async (page) => {
@ -17,6 +13,6 @@ export default class SetupTeamNamePagePageActions extends BaseActions {
.and(page.getByRole('textbox'))
.fill(teamName)
await page.getByText(TEXT.next).click()
}).into(SetupDonePageActions)
}).into(SetupDonePageActions<Context>)
}
}

View File

@ -3,17 +3,13 @@ import { TEXT } from '.'
import BaseActions from './BaseActions'
import SetupPlanPageActions from './SetupPlanPageActions'
// ================================
// === SetupUsernamePageActions ===
// ================================
/** Actions for the "setup" page. */
export default class SetupUsernamePageActions extends BaseActions {
export default class SetupUsernamePageActions<Context> extends BaseActions<Context> {
/** Set the username for a new user that does not yet have a username. */
setUsername(username: string) {
return this.step(`Set username to '${username}'`, async (page) => {
await page.getByPlaceholder(TEXT.usernamePlaceholder).fill(username)
await page.getByText(TEXT.next).click()
}).into(SetupPlanPageActions)
}).into(SetupPlanPageActions<Context>)
}
}

View File

@ -1,36 +1,41 @@
/** @file Actions for the "home" page. */
import * as test from '@playwright/test'
import * as actions from '.'
import BaseActions from './BaseActions'
import type { Page } from '@playwright/test'
import BaseActions, { type LocatorCallback } from './BaseActions'
import DrivePageActions from './DrivePageActions'
import EditorPageActions from './EditorPageActions'
// =========================
// === StartModalActions ===
// =========================
/** Find a samples list. */
function locateSamplesList(page: Page) {
// This has no identifying features.
return page.getByTestId('samples')
}
/** Find all samples list. */
function locateSamples(page: Page) {
// This has no identifying features.
return locateSamplesList(page).getByRole('button')
}
/** Actions for the "start" modal. */
export default class StartModalActions extends BaseActions {
export default class StartModalActions<Context> extends BaseActions<Context> {
/** Close this modal and go back to the Drive page. */
async close() {
const isOnScreen = await this.isStartModalShown()
if (isOnScreen) {
return test.test.step('Close start modal', async () => {
await this.locateStartModal().getByTestId('close-button').click()
})
}
close() {
return this.step('Close start modal', async (page) => {
const isOnScreen = await this.isStartModalShown(page)
if (isOnScreen) {
await this.locateStartModal(page).getByTestId('close-button').click()
}
}).into(DrivePageActions<Context>)
}
/** Locate the "start" modal. */
locateStartModal() {
return this.page.getByTestId('start-modal')
private locateStartModal(page: Page) {
return page.getByTestId('start-modal')
}
/**
* Check if the Asset Panel is shown.
*/
isStartModalShown() {
return this.locateStartModal()
/** Check if the Asset Panel is shown. */
private isStartModalShown(page: Page) {
return this.locateStartModal(page)
.isHidden()
.then(
(result) => !result,
@ -41,10 +46,16 @@ export default class StartModalActions extends BaseActions {
/** Create a project from the template at the given index. */
createProjectFromTemplate(index: number) {
return this.step(`Create project from template #${index}`, (page) =>
actions
.locateSamples(page)
locateSamples(page)
.nth(index + 1)
.click(),
).into(EditorPageActions)
).into(EditorPageActions<Context>)
}
/** Interact with the "start" modal. */
withStartModal(callback: LocatorCallback<Context>) {
return this.step('Interact with start modal', async (page, context) => {
await callback(this.locateStartModal(page), context)
})
}
}

View File

@ -10,16 +10,11 @@ import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import * as actions from './actions'
import * as actions from '.'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
// =================
// === Constants ===
// =================
const __dirname = dirname(fileURLToPath(import.meta.url))
@ -58,9 +53,75 @@ const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*')
const BASE_URL = 'https://mock/'
const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/'
// ===============
// === mockApi ===
// ===============
function array<T>(): Readonly<T>[] {
return []
}
const INITIAL_CALLS_OBJECT = {
changePassword: array<{ oldPassword: string; newPassword: string }>(),
listDirectory: array<{
parent_id?: string
filter_by?: backend.FilterBy
labels?: backend.LabelName[]
recent_projects?: boolean
}>(),
listFiles: array<object>(),
listProjects: array<object>(),
listSecrets: array<object>(),
listTags: array<object>(),
listUsers: array<object>(),
listUserGroups: array<object>(),
listVersions: array<object>(),
getProjectDetails: array<{ projectId: backend.ProjectId }>(),
copyAsset: array<{ assetId: backend.AssetId; parentId: backend.DirectoryId }>(),
listInvitations: array<object>(),
inviteUser: array<object>(),
createPermission: array<object>(),
closeProject: array<{ projectId: backend.ProjectId }>(),
openProject: array<{ projectId: backend.ProjectId }>(),
deleteTag: array<{ tagId: backend.TagId }>(),
postLogEvent: array<object>(),
uploadUserPicture: array<{ content: string }>(),
uploadOrganizationPicture: array<{ content: string }>(),
s3Put: array<object>(),
uploadFileStart: array<{ uploadId: backend.FileId }>(),
uploadFileEnd: array<backend.UploadFileEndRequestBody>(),
createSecret: array<backend.CreateSecretRequestBody>(),
createCheckoutSession: array<backend.CreateCheckoutSessionRequestBody>(),
getCheckoutSession: array<{
body: backend.CreateCheckoutSessionRequestBody
status: backend.CheckoutSessionStatus
}>(),
updateAsset: array<{ assetId: backend.AssetId } & backend.UpdateAssetRequestBody>(),
associateTag: array<{ assetId: backend.AssetId; labels: readonly backend.LabelName[] }>(),
updateDirectory: array<
{ directoryId: backend.DirectoryId } & backend.UpdateDirectoryRequestBody
>(),
deleteAsset: array<{ assetId: backend.AssetId }>(),
undoDeleteAsset: array<{ assetId: backend.AssetId }>(),
createUser: array<backend.CreateUserRequestBody>(),
createUserGroup: array<backend.CreateUserGroupRequestBody>(),
changeUserGroup: array<{ userId: backend.UserId } & backend.ChangeUserGroupRequestBody>(),
updateCurrentUser: array<backend.UpdateUserRequestBody>(),
usersMe: array<object>(),
updateOrganization: array<backend.UpdateOrganizationRequestBody>(),
getOrganization: array<object>(),
createTag: array<backend.CreateTagRequestBody>(),
createProject: array<backend.CreateProjectRequestBody>(),
createDirectory: array<backend.CreateDirectoryRequestBody>(),
getProjectContent: array<{ projectId: backend.ProjectId }>(),
getProjectAsset: array<{ projectId: backend.ProjectId }>(),
}
const READONLY_INITIAL_CALLS_OBJECT: TrackedCallsInternal = INITIAL_CALLS_OBJECT
export { READONLY_INITIAL_CALLS_OBJECT as INITIAL_CALLS_OBJECT }
type TrackedCallsInternal = {
[K in keyof typeof INITIAL_CALLS_OBJECT]: Readonly<(typeof INITIAL_CALLS_OBJECT)[K]>
}
export interface TrackedCalls extends TrackedCallsInternal {}
/** Parameters for {@link mockApi}. */
export interface MockParams {
@ -77,24 +138,10 @@ export interface SetupAPI {
}
/** The return type of {@link mockApi}. */
export type MockApi = Awaited<ReturnType<typeof mockApiInternal>>
export interface MockApi extends Awaited<ReturnType<typeof mockApiInternal>> {}
export const mockApi: (params: MockParams) => Promise<MockApi> = mockApiInternal
export const EULA_JSON = {
path: '/eula.md',
size: 9472,
modified: '2024-05-21T10:47:27.000Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
}
export const PRIVACY_JSON = {
path: '/privacy.md',
size: 1234,
modified: '2024-05-21T10:47:27.000Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
}
/** Add route handlers for the mock API to a page. */
async function mockApiInternal({ page, setupAPI }: MockParams) {
const defaultEmail = 'email@example.com' as backend.EmailAddress
@ -124,6 +171,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
website: null,
subscription: {},
}
const callsObjects = new Set<typeof INITIAL_CALLS_OBJECT>()
let totalSeats = 1
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let subscriptionDuration = 0
@ -160,6 +208,29 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
>()
usersMap.set(defaultUser.userId, defaultUser)
function trackCalls() {
const calls = structuredClone(INITIAL_CALLS_OBJECT)
callsObjects.add(calls)
return calls
}
function pushToKey<Object extends Record<keyof Object, unknown[]>, Key extends keyof Object>(
object: Object,
key: Key,
item: Object[Key][number],
) {
object[key].push(item)
}
function called<Key extends keyof typeof INITIAL_CALLS_OBJECT>(
key: Key,
args: (typeof INITIAL_CALLS_OBJECT)[Key][number],
) {
for (const callsObject of callsObjects) {
pushToKey(callsObject, key, args)
}
}
const addAsset = <T extends backend.AnyAsset>(asset: T) => {
assets.push(asset)
assetMap.set(asset.id, asset)
@ -316,7 +387,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
return label
}
const setLabels = (id: backend.AssetId, newLabels: backend.LabelName[]) => {
const setLabels = (id: backend.AssetId, newLabels: readonly backend.LabelName[]) => {
const ids = new Set<backend.AssetId>([id])
for (const [innerId, asset] of assetMap) {
if (ids.has(asset.parentId)) {
@ -451,56 +522,6 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const patch = method('PATCH')
const delete_ = method('DELETE')
await page.route('https://cdn.enso.org/**', (route) => route.fulfill())
await page.route('https://www.google-analytics.com/**', (route) => route.fulfill())
await page.route('https://www.googletagmanager.com/gtag/js*', (route) =>
route.fulfill({ contentType: 'text/javascript', body: 'export {};' }),
)
if (process.env.MOCK_ALL_URLS === 'true') {
await page.route(
'https://api.github.com/repos/enso-org/enso/releases/latest',
async (route) => {
await route.fulfill({ json: LATEST_GITHUB_RELEASES })
},
)
await page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => {
await route.fulfill({
status: 302,
headers: { location: 'https://objects.githubusercontent.com/foo/bar' },
})
})
await page.route('https://objects.githubusercontent.com/**', async (route) => {
await route.fulfill({
status: 200,
headers: {
'content-type': 'application/octet-stream',
'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT',
etag: '"0x8DCAC053D058EA5"',
server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0',
'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000',
'x-ms-version': '2020-10-02',
'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT',
'x-ms-lease-status': 'unlocked',
'x-ms-lease-state': 'available',
'x-ms-blob-type': 'BlockBlob',
'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage',
'x-ms-server-encrypted': 'true',
via: '1.1 varnish, 1.1 varnish',
'accept-ranges': 'bytes',
age: '1217',
date: 'Mon, 29 Jul 2024 09:40:09 GMT',
'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE',
'x-cache': 'HIT, HIT',
'x-cache-hits': '48, 0',
'x-timer': 'S1722246008.269342,VS0,VE895',
'content-length': '1030383958',
},
})
})
}
await page.route(BASE_URL + '**', (_route, request) => {
throw new Error(
`Missing route handler for '${request.method()} ${request.url().replace(BASE_URL, '')}'.`,
@ -519,6 +540,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly newPassword: string
}
const body: Body = await request.postDataJSON()
called('changePassword', body)
if (body.oldPassword === currentPassword) {
currentPassword = body.newPassword
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
@ -538,14 +560,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly labels?: backend.LabelName[]
readonly recent_projects?: boolean
}
const body = Object.fromEntries(
const query = Object.fromEntries(
new URL(request.url()).searchParams.entries(),
) as unknown as Query
const parentId = body.parent_id ?? defaultDirectoryId
called('listDirectory', query)
const parentId = query.parent_id ?? defaultDirectoryId
let filteredAssets = assets.filter((asset) => asset.parentId === parentId)
// This lint rule is broken; there is clearly a case for `undefined` below.
switch (body.filter_by) {
switch (query.filter_by) {
case backend.FilterBy.active: {
filteredAssets = filteredAssets.filter((asset) => !deletedAssets.has(asset.id))
break
@ -576,18 +599,23 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
return json
})
await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => {
called('listFiles', {})
return { files: [] } satisfies remoteBackend.ListFilesResponseBody
})
await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => {
called('listProjects', {})
return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody
})
await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => {
called('listSecrets', {})
return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody
})
await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => {
called('listTags', {})
return { tags: labels } satisfies remoteBackend.ListTagsResponseBody
})
await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => {
called('listUsers', {})
if (currentUser != null) {
return { users } satisfies remoteBackend.ListUsersResponseBody
} else {
@ -596,28 +624,35 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => {
called('listUserGroups', {})
await route.fulfill({ json: userGroups })
})
await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => ({
versions: [
{
ami: null,
created: dateTime.toRfc3339(new Date()),
number: {
lifecycle:
'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development,
value: '2023.2.1-dev',
},
// eslint-disable-next-line camelcase
version_type: (new URL(request.url()).searchParams.get('version_type') ??
'') as backend.VersionType,
} satisfies backend.Version,
],
}))
await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => {
called('listVersions', {})
return {
versions: [
{
ami: null,
created: dateTime.toRfc3339(new Date()),
number: {
lifecycle:
'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development,
value: '2023.2.1-dev',
},
// eslint-disable-next-line camelcase
version_type: (new URL(request.url()).searchParams.get('version_type') ??
'') as backend.VersionType,
} satisfies backend.Version,
],
}
})
// === Endpoints with dummy implementations ===
await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]
if (!maybeId) return
const projectId = backend.ProjectId(maybeId)
called('getProjectDetails', { projectId })
const project = assetMap.get(projectId)
if (!project) {
@ -661,11 +696,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly parentDirectoryId: backend.DirectoryId
}
const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
const maybeId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
if (!maybeId) return
const assetId = maybeId != null ? backend.DirectoryId(decodeURIComponent(maybeId)) : null
// This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy.
const asset =
assetId != null ? assetMap.get(backend.DirectoryId(decodeURIComponent(assetId))) : null
const asset = assetId != null ? assetMap.get(assetId) : null
if (asset == null) {
if (assetId == null) {
await route.fulfill({
@ -681,6 +717,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} else {
const body: Body = request.postDataJSON()
const parentId = body.parentDirectoryId
called('copyAsset', { assetId: assetId!, parentId })
// Can be any asset ID.
const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`)
const json: backend.CopyAssetResponse = {
@ -701,22 +738,25 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await get(remoteBackendPaths.INVITATION_PATH + '*', (): backend.ListInvitationsResponseBody => {
called('listInvitations', {})
return {
invitations: [],
availableLicenses: totalSeats - usersMap.size,
}
})
await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => {
called('inviteUser', {})
await route.fulfill()
})
await post(remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async (route) => {
await route.fulfill()
})
await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route) => {
called('createPermission', {})
await route.fulfill()
})
await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]
if (!maybeId) return
const projectId = backend.ProjectId(maybeId)
called('closeProject', { projectId })
const project = assetMap.get(projectId)
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.closed
@ -724,7 +764,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await route.fulfill()
})
await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]
if (!maybeId) return
const projectId = backend.ProjectId(maybeId)
called('openProject', { projectId })
const project = assetMap.get(projectId)
@ -740,10 +783,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
route.fulfill()
})
await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route) => {
await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route, request) => {
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]
if (!maybeId) return
const tagId = backend.TagId(maybeId)
called('deleteTag', { tagId })
await route.fulfill()
})
await post(remoteBackendPaths.POST_LOG_EVENT_PATH, async (route) => {
called('postLogEvent', {})
await route.fulfill()
})
@ -752,6 +800,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await put(remoteBackendPaths.UPLOAD_USER_PICTURE_PATH + '*', async (route, request) => {
const content = request.postData()
if (content != null) {
called('uploadUserPicture', { content })
currentProfilePicture = content
return null
} else {
@ -762,6 +811,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await put(remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH + '*', async (route, request) => {
const content = request.postData()
if (content != null) {
called('uploadOrganizationPicture', { content })
currentOrganizationProfilePicture = content
return null
} else {
@ -771,6 +821,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await page.route(MOCK_S3_BUCKET_URL + '**', async (route, request) => {
if (request.method() !== 'PUT') {
called('s3Put', {})
await route.fallback()
} else {
await route.fulfill({
@ -782,9 +833,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await post(remoteBackendPaths.UPLOAD_FILE_START_PATH + '*', () => {
const uploadId = backend.FileId('file-' + uniqueString.uniqueString())
called('uploadFileStart', { uploadId })
return {
sourcePath: backend.S3FilePath(''),
uploadId: 'file-' + uniqueString.uniqueString(),
uploadId,
presignedUrls: Array.from({ length: 10 }, () =>
backend.HttpsUrl(`${MOCK_S3_BUCKET_URL}${uniqueString.uniqueString()}`),
),
@ -792,6 +845,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await post(remoteBackendPaths.UPLOAD_FILE_END_PATH + '*', (_route, request) => {
const body: backend.UploadFileEndRequestBody = request.postDataJSON()
called('uploadFileEnd', body)
const file = addFile({
id: backend.FileId(body.uploadId),
@ -804,9 +858,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => {
const body: backend.CreateSecretRequestBody = await request.postDataJSON()
const secret = addSecret({
title: body.name,
})
called('createSecret', body)
const secret = addSecret({ title: body.name })
return secret.id
})
@ -814,6 +867,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await post(remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH + '*', async (_route, request) => {
const body: backend.CreateCheckoutSessionRequestBody = await request.postDataJSON()
called('createCheckoutSession', body)
return createCheckoutSession(body)
})
await get(
@ -825,6 +879,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} else {
const result = checkoutSessionsMap.get(backend.CheckoutSessionId(checkoutSessionId))
if (result) {
called('getCheckoutSession', result)
if (currentUser) {
object.unsafeMutable(currentUser).plan = result.body.plan
}
@ -838,11 +893,14 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
},
)
await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => {
const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? ''
const body: backend.UpdateAssetRequestBody = request.postDataJSON()
const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1]
if (!maybeId) return
// This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy.
const asset = assetMap.get(backend.DirectoryId(assetId))
const assetId = backend.DirectoryId(maybeId)
const body: backend.UpdateAssetRequestBody = request.postDataJSON()
called('updateAsset', { ...body, assetId })
const asset = assetMap.get(assetId)
if (asset != null) {
if (body.description != null) {
object.unsafeMutable(asset).description = body.description
@ -854,19 +912,22 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => {
const assetId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] ?? ''
const maybeId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1]
if (!maybeId) return
// This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy.
const assetId = backend.DirectoryId(maybeId)
/** The type for the JSON request payload for this endpoint. */
interface Body {
readonly labels: backend.LabelName[]
readonly labels: readonly backend.LabelName[]
}
/** The type for the JSON response payload for this endpoint. */
interface Response {
readonly tags: backend.Label[]
readonly tags: readonly backend.Label[]
}
const body: Body = await request.postDataJSON()
// This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy.
setLabels(backend.DirectoryId(assetId), body.labels)
called('associateTag', { ...body, assetId })
setLabels(assetId, body.labels)
const json: Response = {
tags: body.labels.flatMap((value) => {
const label = labelsByValue.get(value)
@ -876,16 +937,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
return json
})
await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => {
const directoryId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] ?? ''
const maybeId = request.url().match(/[/]directories[/]([^?]+)/)?.[1]
if (!maybeId) return
const directoryId = backend.DirectoryId(maybeId)
const body: backend.UpdateDirectoryRequestBody = request.postDataJSON()
const asset = assetMap.get(backend.DirectoryId(directoryId))
called('updateDirectory', { ...body, directoryId })
const asset = assetMap.get(directoryId)
if (asset == null) {
await route.abort()
} else {
object.unsafeMutable(asset).title = body.title
await route.fulfill({
json: {
id: backend.DirectoryId(directoryId),
id: directoryId,
parentId: asset.parentId,
title: body.title,
} satisfies backend.UpdatedDirectory,
@ -893,10 +957,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => {
const assetId = decodeURIComponent(request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '')
const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1]
if (!maybeId) return
// This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy.
deleteAsset(backend.DirectoryId(assetId))
const assetId = backend.DirectoryId(decodeURIComponent(maybeId))
called('deleteAsset', { assetId })
deleteAsset(assetId)
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
})
await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => {
@ -905,6 +972,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly assetId: backend.AssetId
}
const body: Body = await request.postDataJSON()
called('undoDeleteAsset', body)
undeleteAsset(body.assetId)
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
})
@ -914,6 +982,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const rootDirectoryId = backend.DirectoryId(
organizationId.replace(/^organization-/, 'directory-'),
)
called('createUser', body)
currentUser = {
email: body.userEmail,
name: body.userName,
@ -928,17 +997,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => {
const body: backend.CreateUserGroupRequestBody = await request.postDataJSON()
called('createUserGroup', body)
const userGroup = addUserGroup(body.name)
return userGroup
})
await put(
remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*',
async (route, request) => {
const userId = backend.UserId(
decodeURIComponent(request.url().match(/[/]users[/]([^?/]+)/)?.[1] ?? ''),
)
const maybeId = request.url().match(/[/]users[/]([^?/]+)/)?.[1]
if (!maybeId) return
const userId = backend.UserId(decodeURIComponent(maybeId))
// The type of the body sent by this app is statically known.
const body: backend.ChangeUserGroupRequestBody = await request.postDataJSON()
called('changeUserGroup', { userId, ...body })
const user = usersMap.get(userId)
if (!user) {
await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST })
@ -950,11 +1021,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
)
await put(remoteBackendPaths.UPDATE_CURRENT_USER_PATH + '*', async (_route, request) => {
const body: backend.UpdateUserRequestBody = await request.postDataJSON()
called('updateCurrentUser', body)
if (currentUser && body.username != null) {
currentUser = { ...currentUser, name: body.username }
}
})
await get(remoteBackendPaths.USERS_ME_PATH + '*', (route) => {
called('usersMe', {})
if (currentUser == null) {
return route.fulfill({ status: HTTP_STATUS_NOT_FOUND })
} else {
@ -963,6 +1036,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => {
const body: backend.UpdateOrganizationRequestBody = await request.postDataJSON()
called('updateOrganization', body)
if (body.name === '') {
await route.fulfill({
status: HTTP_STATUS_BAD_REQUEST,
@ -978,6 +1052,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await get(remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async (route) => {
called('getOrganization', {})
await route.fulfill({
json: currentOrganization,
status: currentOrganization == null ? 404 : 200,
@ -985,10 +1060,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => {
const body: backend.CreateTagRequestBody = route.request().postDataJSON()
called('createTag', body)
return addLabel(body.value, body.color)
})
await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => {
const body: backend.CreateProjectRequestBody = request.postDataJSON()
called('createProject', body)
const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`)
const parentId =
body.parentDirectoryId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
@ -1028,6 +1105,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => {
const body: backend.CreateDirectoryRequestBody = request.postDataJSON()
called('createDirectory', body)
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
const parentId = body.parentId ?? defaultDirectoryId
@ -1058,8 +1136,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route) => {
const content = readFileSync(join(__dirname, './mock/enso-demo.main'), 'utf8')
await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route, request) => {
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]
if (!maybeId) return
const projectId = backend.ProjectId(maybeId)
called('getProjectContent', { projectId })
const content = readFileSync(join(__dirname, '../mock/enso-demo.main'), 'utf8')
return route.fulfill({
body: content,
@ -1067,7 +1149,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
})
await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route) => {
await get(remoteBackendPaths.getProjectAssetPath(GLOB_PROJECT_ID, '*'), (route, request) => {
const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1]
if (!maybeId) return
const projectId = backend.ProjectId(maybeId)
called('getProjectAsset', { projectId })
return route.fulfill({
// This is a mock SVG image. Just a square with a black background.
body: '/mock/svg.svg',
@ -1145,6 +1231,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
// deletePermission,
addUserGroupToUser,
removeUserGroupFromUser,
trackCalls,
} as const
if (setupAPI) {

View File

@ -1,15 +1,11 @@
/** @file Actions for the context menu. */
import { TEXT } from '.'
import type * as baseActions from './BaseActions'
import type BaseActions from './BaseActions'
import type { PageCallback } from './BaseActions'
import EditorPageActions from './EditorPageActions'
// ==========================
// === ContextMenuActions ===
// ==========================
/** Actions for the context menu. */
export interface ContextMenuActions<T extends BaseActions> {
export interface ContextMenuActions<T extends BaseActions<Context>, Context> {
readonly open: () => T
readonly uploadToCloud: () => T
readonly rename: () => T
@ -22,7 +18,7 @@ export interface ContextMenuActions<T extends BaseActions> {
readonly share: () => T
readonly label: () => T
readonly duplicate: () => T
readonly duplicateProject: () => EditorPageActions
readonly duplicateProject: () => EditorPageActions<Context>
readonly copy: () => T
readonly cut: () => T
readonly paste: () => T
@ -34,14 +30,10 @@ export interface ContextMenuActions<T extends BaseActions> {
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> {
export function contextMenuActions<T extends BaseActions<Context>, Context>(
step: (name: string, callback: PageCallback<Context>) => T,
): ContextMenuActions<T, Context> {
return {
open: () =>
step('Open (context menu)', (page) =>
@ -131,7 +123,7 @@ export function contextMenuActions<T extends BaseActions>(
.getByRole('button', { name: TEXT.duplicateShortcut })
.getByText(TEXT.duplicateShortcut)
.click(),
).into(EditorPageActions),
).into(EditorPageActions<Context>),
copy: () =>
step('Copy (context menu)', (page) =>
page

View File

@ -1,29 +1,21 @@
/** @file Actions for going to a different page. */
import type * as baseActions from './BaseActions'
import type { PageCallback } from './BaseActions'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
import EditorPageActions from './EditorPageActions'
import SettingsPageActions from './SettingsPageActions'
// =======================
// === GoToPageActions ===
// =======================
/** Actions for going to a different page. */
export interface GoToPageActions {
readonly drive: () => DrivePageActions
readonly editor: () => EditorPageActions
readonly settings: () => SettingsPageActions
export interface GoToPageActions<Context> {
readonly drive: () => DrivePageActions<Context>
readonly editor: () => EditorPageActions<Context>
readonly settings: () => SettingsPageActions<Context>
}
// =======================
// === goToPageActions ===
// =======================
/** Generate actions for going to a different page. */
export function goToPageActions(
step: (name: string, callback: baseActions.PageCallback) => BaseActions,
): GoToPageActions {
export function goToPageActions<Context>(
step: (name: string, callback: PageCallback<Context>) => BaseActions<Context>,
): GoToPageActions<Context> {
return {
drive: () =>
step('Go to "Data Catalog" page', (page) =>
@ -31,14 +23,14 @@ export function goToPageActions(
.getByRole('tab')
.filter({ has: page.getByText('Data Catalog') })
.click(),
).into(DrivePageActions),
).into(DrivePageActions<Context>),
editor: () =>
step('Go to "Spatial Analysis" page', (page) =>
page.getByTestId('editor-tab-button').click(),
).into(EditorPageActions),
).into(EditorPageActions<Context>),
settings: () =>
step('Go to "settings" page', (page) => BaseActions.press(page, 'Mod+,')).into(
SettingsPageActions,
SettingsPageActions<Context>,
),
}
}

View File

@ -0,0 +1,88 @@
/** @file Actions for going to a different page. */
import { TEXT } from '.'
import type { PageCallback } from './BaseActions'
import BaseActions from './BaseActions'
import SettingsAccountTabActions from './SettingsAccountTabActions'
import SettingsActivityLogShortcutsTabActions from './SettingsActivityLogTabActions'
import SettingsBillingAndPlansTabActions from './SettingsBillingAndPlansTabActions'
import SettingsKeyboardShortcutsTabActions from './SettingsKeyboardShortcutsTabActions'
import SettingsLocalTabActions from './SettingsLocalTabActions'
import SettingsMembersTabActions from './SettingsMembersTabActions'
import SettingsOrganizationTabActions from './SettingsOrganizationTabActions'
import SettingsUserGroupsTabActions from './SettingsUserGroupsTabActions'
/** Actions for going to a different settings tab. */
export interface GoToSettingsTabActions<Context> {
readonly account: () => SettingsAccountTabActions<Context>
readonly organization: () => SettingsOrganizationTabActions<Context>
readonly local: () => SettingsLocalTabActions<Context>
readonly billingAndPlans: () => SettingsBillingAndPlansTabActions<Context>
readonly members: () => SettingsMembersTabActions<Context>
readonly userGroups: () => SettingsUserGroupsTabActions<Context>
readonly keyboardShortcuts: () => SettingsKeyboardShortcutsTabActions<Context>
readonly activityLog: () => SettingsActivityLogShortcutsTabActions<Context>
}
/** Generate actions for going to a different page. */
export function goToSettingsTabActions<Context>(
step: (name: string, callback: PageCallback<Context>) => BaseActions<Context>,
): GoToSettingsTabActions<Context> {
return {
account: () =>
step('Go to "account" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.accountSettingsTab })
.getByText(TEXT.accountSettingsTab)
.click(),
).into(SettingsAccountTabActions<Context>),
organization: () =>
step('Go to "organization" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.organizationSettingsTab })
.getByText(TEXT.organizationSettingsTab)
.click(),
).into(SettingsOrganizationTabActions<Context>),
local: () =>
step('Go to "local" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.localSettingsTab })
.getByText(TEXT.localSettingsTab)
.click(),
).into(SettingsLocalTabActions<Context>),
billingAndPlans: () =>
step('Go to "billing and plans" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.billingAndPlansSettingsTab })
.getByText(TEXT.billingAndPlansSettingsTab)
.click(),
).into(SettingsBillingAndPlansTabActions<Context>),
members: () =>
step('Go to "members" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.membersSettingsTab })
.getByText(TEXT.membersSettingsTab)
.click(),
).into(SettingsMembersTabActions<Context>),
userGroups: () =>
step('Go to "user groups" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.userGroupsSettingsTab })
.getByText(TEXT.userGroupsSettingsTab)
.click(),
).into(SettingsUserGroupsTabActions<Context>),
keyboardShortcuts: () =>
step('Go to "keyboard shortcuts" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.keyboardShortcutsSettingsTab })
.getByText(TEXT.keyboardShortcutsSettingsTab)
.click(),
).into(SettingsKeyboardShortcutsTabActions<Context>),
activityLog: () =>
step('Go to "activity log" settings tab', (page) =>
page
.getByRole('button', { name: TEXT.activityLogSettingsTab })
.getByText(TEXT.activityLogSettingsTab)
.click(),
).into(SettingsActivityLogShortcutsTabActions<Context>),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,13 +3,9 @@ import { TEXT } from '.'
import type BaseActions from './BaseActions'
import type { PageCallback } from './BaseActions'
// ==========================
// === openUserMenuAction ===
// ==========================
/** An action to open the User Menu. */
export function openUserMenuAction<T extends BaseActions>(
step: (name: string, callback: PageCallback) => T,
export function openUserMenuAction<T extends BaseActions<Context>, Context>(
step: (name: string, callback: PageCallback<Context>) => T,
) {
return step('Open user menu', (page) =>
page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click(),

View File

@ -1,49 +1,54 @@
/** @file Actions for the user menu. */
import type * as test from 'playwright/test'
import type { Download } from '@playwright/test'
import type * as baseActions from './BaseActions'
import { TEXT } from '.'
import type BaseActions from './BaseActions'
import type { PageCallback } from './BaseActions'
import LoginPageActions from './LoginPageActions'
import SettingsPageActions from './SettingsPageActions'
// =======================
// === UserMenuActions ===
// =======================
/** Actions for the user menu. */
export interface UserMenuActions<T extends BaseActions> {
readonly downloadApp: (callback: (download: test.Download) => Promise<void> | void) => T
readonly settings: () => SettingsPageActions
readonly logout: () => LoginPageActions
readonly goToLoginPage: () => LoginPageActions
export interface UserMenuActions<T extends BaseActions<Context>, Context> {
readonly downloadApp: (callback: (download: Download) => Promise<void> | void) => T
readonly settings: () => SettingsPageActions<Context>
readonly logout: () => LoginPageActions<Context>
readonly goToLoginPage: () => LoginPageActions<Context>
}
// =======================
// === userMenuActions ===
// =======================
/** Generate actions for the user menu. */
export function userMenuActions<T extends BaseActions>(
step: (name: string, callback: baseActions.PageCallback) => T,
): UserMenuActions<T> {
export function userMenuActions<T extends BaseActions<Context>, Context>(
step: (name: string, callback: PageCallback<Context>) => T,
): UserMenuActions<T, Context> {
return {
downloadApp: (callback: (download: test.Download) => Promise<void> | void) =>
downloadApp: (callback: (download: Download) => Promise<void> | void) =>
step('Download app (user menu)', async (page) => {
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Download App' }).getByText('Download App').click()
await page
.getByRole('button', { name: TEXT.downloadAppShortcut })
.getByText(TEXT.downloadAppShortcut)
.click()
await callback(await downloadPromise)
}),
settings: () =>
step('Go to Settings (user menu)', async (page) => {
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
}).into(SettingsPageActions),
await page
.getByRole('button', { name: TEXT.settingsShortcut })
.getByText(TEXT.settingsShortcut)
.click()
}).into(SettingsPageActions<Context>),
logout: () =>
step('Logout (user menu)', (page) =>
page.getByRole('button', { name: 'Logout' }).getByText('Logout').click(),
).into(LoginPageActions),
page
.getByRole('button', { name: TEXT.signOutShortcut })
.getByText(TEXT.signOutShortcut)
.click(),
).into(LoginPageActions<Context>),
goToLoginPage: () =>
step('Login (user menu)', (page) =>
page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click(),
).into(LoginPageActions),
page
.getByRole('button', { name: TEXT.signInShortcut, exact: true })
.getByText(TEXT.signInShortcut)
.click(),
).into(LoginPageActions<Context>),
}
}

View File

@ -1,15 +1,30 @@
/** @file Tests for the asset panel. */
import { expect, test } from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
import * as backend from '#/services/Backend'
import { EmailAddress, UserId } from '#/services/Backend'
import * as permissions from '#/utilities/permissions'
import { PermissionAction } from '#/utilities/permissions'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
// =================
// === Constants ===
// =================
/** Find an asset panel. */
function locateAssetPanel(page: Page) {
// This has no identifying features.
return page.getByTestId('asset-panel').locator('visible=true')
}
/** Find an asset description in an asset panel. */
function locateAssetPanelDescription(page: Page) {
// This has no identifying features.
return locateAssetPanel(page).getByTestId('asset-panel-description')
}
/** Find asset permissions in an asset panel. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function locateAssetPanelPermissions(page: Page) {
// This has no identifying features.
return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button')
}
/** An example description for the asset selected in the asset panel. */
const DESCRIPTION = 'foo bar'
@ -18,13 +33,8 @@ const USERNAME = 'baz quux'
/** An example owner email for the asset selected in the asset panel. */
const EMAIL = 'baz.quux@email.com'
// =============
// === Tests ===
// =============
test('open and close asset panel', ({ page }) =>
actions
.mockAllAndLogin({ page })
mockAllAndLogin({ page })
.withAssetPanel(async (assetPanel) => {
await expect(assetPanel).toBeVisible()
})
@ -34,50 +44,47 @@ test('open and close asset panel', ({ page }) =>
}))
test('asset panel contents', ({ page }) =>
actions
.mockAllAndLogin({
page,
setupAPI: (api) => {
const { defaultOrganizationId, defaultUserId } = api
api.addProject({
description: DESCRIPTION,
permissions: [
{
permission: permissions.PermissionAction.own,
user: {
organizationId: defaultOrganizationId,
// Using the default ID causes the asset to have a dynamic username.
userId: backend.UserId(defaultUserId + '2'),
name: USERNAME,
email: backend.EmailAddress(EMAIL),
},
mockAllAndLogin({
page,
setupAPI: (api) => {
const { defaultOrganizationId, defaultUserId } = api
api.addProject({
description: DESCRIPTION,
permissions: [
{
permission: PermissionAction.own,
user: {
organizationId: defaultOrganizationId,
// Using the default ID causes the asset to have a dynamic username.
userId: UserId(defaultUserId + '2'),
name: USERNAME,
email: EmailAddress(EMAIL),
},
],
})
},
})
},
],
})
},
})
.driveTable.clickRow(0)
.toggleDescriptionAssetPanel()
.do(async () => {
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
await expect(locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
// `getByText` is required so that this assertion works if there are multiple permissions.
// This is not visible; "Shared with" should only be visible on the Enterprise plan.
// await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
// await expect(locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
}))
test('Asset Panel Documentation view', ({ page }) => {
return actions
.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addProject({})
},
})
test('Asset Panel documentation view', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addProject({})
},
})
.driveTable.clickRow(0)
.toggleDocsAssetPanel()
.withAssetPanel(async (assetPanel) => {
await expect(assetPanel.getByTestId('asset-panel-tab-panel-docs')).toBeVisible()
await expect(assetPanel.getByTestId('asset-docs-content')).toBeVisible()
await expect(assetPanel.getByTestId('asset-docs-content')).toHaveText(/Project Goal/)
})
})
}))

View File

@ -1,71 +1,91 @@
/** @file Test the search bar and its suggestions. */
import * as test from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
import * as backend from '#/services/Backend'
import { COLORS } from '#/services/Backend'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
test.test('tags (positive)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const searchBarInput = actions.locateSearchBarInput(page)
const tags = actions.locateSearchBarTags(page)
/** Find a search bar. */
function locateSearchBar(page: Page) {
// This has no identifying features.
return page.getByTestId('asset-search-bar')
}
await searchBarInput.click()
for (const positiveTag of await tags.all()) {
await searchBarInput.selectText()
await searchBarInput.press('Backspace')
const text = (await positiveTag.textContent()) ?? ''
test.expect(text.length).toBeGreaterThan(0)
await positiveTag.click()
await test.expect(searchBarInput).toHaveValue(text)
}
})
/** Find a list of tags in the search bar. */
function locateSearchBarTags(page: Page) {
return locateSearchBar(page).getByTestId('asset-search-tag-names').getByRole('button')
}
test.test('tags (negative)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const searchBarInput = actions.locateSearchBarInput(page)
const tags = actions.locateSearchBarTags(page)
/** Find a list of labels in the search bar. */
function locateSearchBarLabels(page: Page) {
return locateSearchBar(page).getByTestId('asset-search-labels').getByRole('button')
}
await searchBarInput.click()
await page.keyboard.down('Shift')
for (const negativeTag of await tags.all()) {
await searchBarInput.selectText()
await searchBarInput.press('Backspace')
const text = (await negativeTag.textContent()) ?? ''
test.expect(text.length).toBeGreaterThan(0)
await negativeTag.click()
await test.expect(searchBarInput).toHaveValue(text)
}
})
/** Find a list of labels in the search bar. */
function locateSearchBarSuggestions(page: Page) {
return locateSearchBar(page).getByTestId('asset-search-suggestion')
}
test.test('labels', async ({ page }) => {
await actions.mockAllAndLogin({
const FIRST_ASSET_NAME = 'foo'
test('tags (positive)', ({ page }) =>
mockAllAndLogin({ page }).withSearchBar(async (searchBarInput) => {
const tags = locateSearchBarTags(page)
await searchBarInput.click()
for (const positiveTag of await tags.all()) {
await searchBarInput.selectText()
await searchBarInput.press('Backspace')
const text = (await positiveTag.textContent()) ?? ''
expect(text.length).toBeGreaterThan(0)
await positiveTag.click()
await expect(searchBarInput).toHaveValue(text)
}
}))
test('tags (negative)', ({ page }) =>
mockAllAndLogin({ page }).withSearchBar(async (searchBar) => {
const tags = locateSearchBarTags(page)
await searchBar.click()
await page.keyboard.down('Shift')
for (const negativeTag of await tags.all()) {
await searchBar.selectText()
await searchBar.press('Backspace')
const text = (await negativeTag.textContent()) ?? ''
expect(text.length).toBeGreaterThan(0)
await negativeTag.click()
await expect(searchBar).toHaveValue(text)
}
}))
test('labels', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel('aaaa', backend.COLORS[0])
api.addLabel('bbbb', backend.COLORS[1])
api.addLabel('cccc', backend.COLORS[2])
api.addLabel('dddd', backend.COLORS[3])
api.addLabel('aaaa', COLORS[0])
api.addLabel('bbbb', COLORS[1])
api.addLabel('cccc', COLORS[2])
api.addLabel('dddd', COLORS[3])
},
})
const searchBarInput = actions.locateSearchBarInput(page)
const labels = actions.locateSearchBarLabels(page)
}).withSearchBar(async (searchBar) => {
const labels = locateSearchBarLabels(page)
await searchBarInput.click()
for (const label of await labels.all()) {
const name = (await label.textContent()) ?? ''
test.expect(name.length).toBeGreaterThan(0)
await label.click()
await test.expect(searchBarInput).toHaveValue('label:' + name)
await label.click()
await test.expect(searchBarInput).toHaveValue('-label:' + name)
await label.click()
await test.expect(searchBarInput).toHaveValue('')
}
})
await searchBar.click()
for (const label of await labels.all()) {
const name = (await label.textContent()) ?? ''
expect(name.length).toBeGreaterThan(0)
await label.click()
await expect(searchBar).toHaveValue('label:' + name)
await label.click()
await expect(searchBar).toHaveValue('-label:' + name)
await label.click()
await expect(searchBar).toHaveValue('')
}
}))
test.test('suggestions', async ({ page }) => {
await actions.mockAllAndLogin({
test('suggestions', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addDirectory({ title: 'foo' })
@ -73,25 +93,23 @@ test.test('suggestions', async ({ page }) => {
api.addSecret({ title: 'baz' })
api.addSecret({ title: 'quux' })
},
})
}).withSearchBar(async (searchBar) => {
const suggestions = locateSearchBarSuggestions(page)
const searchBarInput = actions.locateSearchBarInput(page)
const suggestions = actions.locateSearchBarSuggestions(page)
await searchBar.click()
await searchBarInput.click()
for (const suggestion of await suggestions.all()) {
const name = (await suggestion.textContent()) ?? ''
expect(name.length).toBeGreaterThan(0)
await suggestion.click()
await expect(searchBar).toHaveValue('name:' + name)
await searchBar.selectText()
await searchBar.press('Backspace')
}
}))
for (const suggestion of await suggestions.all()) {
const name = (await suggestion.textContent()) ?? ''
test.expect(name.length).toBeGreaterThan(0)
await suggestion.click()
await test.expect(searchBarInput).toHaveValue('name:' + name)
await searchBarInput.selectText()
await searchBarInput.press('Backspace')
}
})
test.test('suggestions (keyboard)', async ({ page }) => {
await actions.mockAllAndLogin({
test('suggestions (keyboard)', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addDirectory({ title: 'foo' })
@ -99,40 +117,34 @@ test.test('suggestions (keyboard)', async ({ page }) => {
api.addSecret({ title: 'baz' })
api.addSecret({ title: 'quux' })
},
})
}).withSearchBar(async (searchBar) => {
const suggestions = locateSearchBarSuggestions(page)
const searchBarInput = actions.locateSearchBarInput(page)
const suggestions = actions.locateSearchBarSuggestions(page)
await searchBar.click()
for (const suggestion of await suggestions.all()) {
const name = (await suggestion.textContent()) ?? ''
expect(name.length).toBeGreaterThan(0)
await page.press('body', 'ArrowDown')
await expect(searchBar).toHaveValue('name:' + name)
}
}))
await searchBarInput.click()
for (const suggestion of await suggestions.all()) {
const name = (await suggestion.textContent()) ?? ''
test.expect(name.length).toBeGreaterThan(0)
test('complex flows', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addDirectory({ title: FIRST_ASSET_NAME })
api.addProject({ title: 'bar' })
api.addSecret({ title: 'baz' })
api.addSecret({ title: 'quux' })
},
}).withSearchBar(async (searchBar) => {
await searchBar.click()
await page.press('body', 'ArrowDown')
await test.expect(searchBarInput).toHaveValue('name:' + name)
}
})
test.test('complex flows', async ({ page }) => {
const firstName = 'foo'
await actions.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addDirectory({ title: firstName })
api.addProject({ title: 'bar' })
api.addSecret({ title: 'baz' })
api.addSecret({ title: 'quux' })
},
})
const searchBarInput = actions.locateSearchBarInput(page)
await searchBarInput.click()
await page.press('body', 'ArrowDown')
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
await searchBarInput.selectText()
await searchBarInput.press('Backspace')
await test.expect(searchBarInput).toHaveValue('')
await page.press('body', 'ArrowDown')
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
})
await expect(searchBar).toHaveValue('name:' + FIRST_ASSET_NAME)
await searchBar.selectText()
await searchBar.press('Backspace')
await expect(searchBar).toHaveValue('')
await page.press('body', 'ArrowDown')
await expect(searchBar).toHaveValue('name:' + FIRST_ASSET_NAME)
}))

View File

@ -1,13 +1,37 @@
/** @file Test the drive view. */
import * as test from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import * as actions from './actions'
import { mockAllAndLogin, TEXT } from './actions'
/** Find an extra columns button panel. */
function locateExtraColumns(page: Page) {
// This has no identifying features.
return page.getByTestId('extra-columns')
}
/**
* Get the left side of the bounding box of an asset row. The locator MUST be for an asset row.
* DO NOT assume the left side of the outer container will change. This means that it is NOT SAFE
* to do anything with the returned values other than comparing them.
*/
function getAssetRowLeftPx(locator: Locator) {
return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0)
}
/**
* Find a root directory dropzone.
* This is the empty space below the assets table, if it doesn't take up the whole screen
* vertically.
*/
function locateRootDirectoryDropzone(page: Page) {
// This has no identifying features.
return page.getByTestId('root-directory-dropzone')
}
const PASS_TIMEOUT = 5_000
test.test('extra columns should stick to right side of assets table', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('extra columns should stick to right side of assets table', ({ page }) =>
mockAllAndLogin({ page })
.withAssetsTable(async (table) => {
await table.evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element
@ -20,25 +44,21 @@ test.test('extra columns should stick to right side of assets table', ({ page })
scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' })
})
})
.do(async (thePage) => {
const extraColumns = actions.locateExtraColumns(thePage)
const assetsTable = actions.locateAssetsTable(thePage)
await test
.expect(async () => {
const extraColumnsRight = await extraColumns.evaluate(
(element) => element.getBoundingClientRect().right,
)
const assetsTableRight = await assetsTable.evaluate(
(element) => element.getBoundingClientRect().right,
)
test.expect(extraColumnsRight).toEqual(assetsTableRight - 12)
})
.toPass({ timeout: PASS_TIMEOUT })
}),
)
.withAssetsTable(async (assetsTable, _, thePage) => {
const extraColumns = locateExtraColumns(thePage)
await expect(async () => {
const extraColumnsRight = await extraColumns.evaluate(
(element) => element.getBoundingClientRect().right,
)
const assetsTableRight = await assetsTable.evaluate(
(element) => element.getBoundingClientRect().right,
)
expect(extraColumnsRight).toEqual(assetsTableRight - 12)
}).toPass({ timeout: PASS_TIMEOUT })
}))
test.test('extra columns should stick to top of scroll container', async ({ page }) => {
await actions.mockAllAndLogin({
test('extra columns should stick to top of scroll container', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
for (let i = 0; i < 100; i += 1) {
@ -46,25 +66,8 @@ test.test('extra columns should stick to top of scroll container', async ({ page
}
},
})
await actions.locateAssetsTable(page).evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element
while (
scrollableParent != null &&
scrollableParent.scrollHeight <= scrollableParent.clientHeight
) {
scrollableParent = scrollableParent.parentElement
}
scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' })
})
const extraColumns = actions.locateExtraColumns(page)
const assetsTable = actions.locateAssetsTable(page)
await test
.expect(async () => {
const extraColumnsTop = await extraColumns.evaluate(
(element) => element.getBoundingClientRect().top,
)
const assetsTableTop = await assetsTable.evaluate((element) => {
.withAssetsTable(async (assetsTable) => {
await assetsTable.evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element
while (
scrollableParent != null &&
@ -72,29 +75,43 @@ test.test('extra columns should stick to top of scroll container', async ({ page
) {
scrollableParent = scrollableParent.parentElement
}
return scrollableParent?.getBoundingClientRect().top ?? 0
scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' })
})
test.expect(extraColumnsTop).toEqual(assetsTableTop + 2)
})
.toPass({ timeout: PASS_TIMEOUT })
})
.withAssetsTable(async (assetsTable, _, thePage) => {
const extraColumns = locateExtraColumns(thePage)
await expect(async () => {
const extraColumnsTop = await extraColumns.evaluate(
(element) => element.getBoundingClientRect().top,
)
const assetsTableTop = await assetsTable.evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element
while (
scrollableParent != null &&
scrollableParent.scrollHeight <= scrollableParent.clientHeight
) {
scrollableParent = scrollableParent.parentElement
}
return scrollableParent?.getBoundingClientRect().top ?? 0
})
expect(extraColumnsTop).toEqual(assetsTableTop + 2)
}).toPass({ timeout: PASS_TIMEOUT })
}))
test.test('can drop onto root directory dropzone', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('can drop onto root directory dropzone', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.uploadFile('b', 'testing')
.driveTable.doubleClickRow(0)
.driveTable.withRows(async (rows, nonAssetRows) => {
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
await test.expect(nonAssetRows.nth(0)).toHaveText(actions.TEXT.thisFolderIsEmpty)
const childLeft = await actions.getAssetRowLeftPx(nonAssetRows.nth(0))
test.expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft)
const parentLeft = await getAssetRowLeftPx(rows.nth(0))
await expect(nonAssetRows.nth(0)).toHaveText(TEXT.thisFolderIsEmpty)
const childLeft = await getAssetRowLeftPx(nonAssetRows.nth(0))
expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft)
})
.driveTable.dragRow(1, actions.locateRootDirectoryDropzone(page))
.driveTable.dragRow(1, locateRootDirectoryDropzone(page))
.driveTable.withRows(async (rows) => {
const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const secondLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft)
}),
)
const firstLeft = await getAssetRowLeftPx(rows.nth(0))
const secondLeft = await getAssetRowLeftPx(rows.nth(1))
expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft)
}))

View File

@ -1,10 +1,12 @@
import { test as setup } from '@playwright/test'
import fs from 'node:fs'
import * as actions from './actions'
import { test as setup } from '@playwright/test'
import { getAuthFilePath, mockAllAndLogin } from './actions'
setup('authenticate', ({ page }) => {
const authFilePath = actions.getAuthFilePath()
setup.slow()
const authFilePath = getAuthFilePath()
setup.skip(fs.existsSync(authFilePath), 'Already authenticated')
return actions.mockAllAndLogin({ page })
return mockAllAndLogin({ page })
})

View File

@ -1,30 +1,30 @@
/** @file Test that emails are preserved when navigating between auth pages. */
import * as test from '@playwright/test'
import { expect, test } from '@playwright/test'
import { VALID_EMAIL, mockAll } from './actions'
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.use({ storageState: { cookies: [], origins: [] } })
test.test('preserve email input when changing pages', ({ page }) =>
test('preserve email input when changing pages', ({ page }) =>
mockAll({ page })
.fillEmail(VALID_EMAIL)
.goToPage.register()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(VALID_EMAIL)
await expect(emailInput).toHaveValue(VALID_EMAIL)
})
.fillEmail(`2${VALID_EMAIL}`)
.goToPage.login()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`2${VALID_EMAIL}`)
await expect(emailInput).toHaveValue(`2${VALID_EMAIL}`)
})
.fillEmail(`3${VALID_EMAIL}`)
.goToPage.forgotPassword()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`3${VALID_EMAIL}`)
await expect(emailInput).toHaveValue(`3${VALID_EMAIL}`)
})
.fillEmail(`4${VALID_EMAIL}`)
.goToPage.login()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`4${VALID_EMAIL}`)
}),
)
await expect(emailInput).toHaveValue(`4${VALID_EMAIL}`)
}))

View File

@ -1,38 +1,64 @@
/** @file Test the drive view. */
import * as test from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
import { COLORS } from 'enso-common/src/services/Backend'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
const LABEL_NAME = 'aaaa'
test.test('drive view', ({ page }) =>
actions
.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(LABEL_NAME, COLORS[0])
},
})
/** Find the context menu. */
function locateContextMenu(page: Page) {
// This has no identifying features.
return page.getByTestId('context-menu')
}
/** Find labels in the "Labels" column of the assets table. */
function locateAssetLabels(page: Page) {
return page.getByTestId('asset-label')
}
/** Find a labels panel. */
function locateLabelsPanel(page: Page) {
// This has no identifying features.
return page.getByTestId('labels')
}
/** Find all labels in the labels panel. */
function locateLabelsPanelLabels(page: Page, name?: string) {
return (
locateLabelsPanel(page)
.getByRole('button')
.filter(name != null ? { has: page.getByText(name) } : {})
// The delete button is also a `button`.
.and(page.locator(':nth-child(1)'))
)
}
test('drive view', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(LABEL_NAME, COLORS[0])
},
})
.driveTable.expectPlaceholderRow()
.withDriveView(async (view) => {
await view.click({ button: 'right' })
})
.do(async (thePage) => {
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(1)
await expect(locateContextMenu(thePage)).toHaveCount(1)
})
.press('Escape')
.do(async (thePage) => {
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(0)
await expect(locateContextMenu(thePage)).toHaveCount(0)
})
.createFolder()
.driveTable.withRows(async (rows, _, thePage) => {
await actions.locateLabelsPanelLabels(page, LABEL_NAME).dragTo(rows.nth(0))
await actions.locateAssetLabels(thePage).first().click({ button: 'right' })
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(1)
.driveTable.withRows(async (rows, _, _context, thePage) => {
await locateLabelsPanelLabels(thePage, LABEL_NAME).dragTo(rows.nth(0))
await locateAssetLabels(thePage).first().click({ button: 'right' })
await expect(locateContextMenu(thePage)).toHaveCount(1)
})
.press('Escape')
.do(async (thePage) => {
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(0)
}),
)
await expect(locateContextMenu(thePage)).toHaveCount(0)
}))

View File

@ -1,11 +1,30 @@
/** @file Test copying, moving, cutting and pasting. */
import * as test from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
test.test('copy', ({ page }) =>
actions
.mockAllAndLogin({ page })
/** Find the context menu. */
function locateContextMenu(page: Page) {
// This has no identifying features.
return page.getByTestId('context-menu')
}
/** Find a button for the "Trash" category. */
function locateTrashCategory(page: Page) {
return page.getByLabel('Trash').locator('visible=true')
}
/**
* Get the left side of the bounding box of an asset row. The locator MUST be for an asset row.
* DO NOT assume the left side of the outer container will change. This means that it is NOT SAFE
* to do anything with the returned values other than comparing them.
*/
function getAssetRowLeftPx(locator: Locator) {
return locator.evaluate((el) => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0)
}
test('copy', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
@ -17,18 +36,16 @@ test.test('copy', ({ page }) =>
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
.contextMenu.paste()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}),
)
await expect(rows).toHaveCount(3)
await expect(rows.nth(2)).toBeVisible()
await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await getAssetRowLeftPx(rows.nth(1))
const childLeft = await getAssetRowLeftPx(rows.nth(2))
expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}))
test.test('copy (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('copy (keyboard)', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
@ -40,18 +57,16 @@ test.test('copy (keyboard)', ({ page }) =>
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
.press('Mod+V')
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}),
)
await expect(rows).toHaveCount(3)
await expect(rows.nth(2)).toBeVisible()
await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await getAssetRowLeftPx(rows.nth(1))
const childLeft = await getAssetRowLeftPx(rows.nth(2))
expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}))
test.test('move', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('move', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
@ -63,18 +78,16 @@ test.test('move', ({ page }) =>
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.contextMenu.paste()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}),
)
await expect(rows).toHaveCount(2)
await expect(rows.nth(1)).toBeVisible()
await expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await getAssetRowLeftPx(rows.nth(0))
const childLeft = await getAssetRowLeftPx(rows.nth(1))
expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}))
test.test('move (drag)', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('move (drag)', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
@ -82,18 +95,16 @@ test.test('move (drag)', ({ page }) =>
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.driveTable.dragRowToRow(0, 1)
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}),
)
await expect(rows).toHaveCount(2)
await expect(rows.nth(1)).toBeVisible()
await expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await getAssetRowLeftPx(rows.nth(0))
const childLeft = await getAssetRowLeftPx(rows.nth(1))
expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}))
test.test('move to trash', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('move to trash', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
@ -101,17 +112,15 @@ test.test('move to trash', ({ page }) =>
// NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still
// held.
.withModPressed((modActions) => modActions.driveTable.clickRow(0).driveTable.clickRow(1))
.driveTable.dragRow(0, actions.locateTrashCategory(page))
.driveTable.dragRow(0, locateTrashCategory(page))
.driveTable.expectPlaceholderRow()
.goToCategory.trash()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/])
}),
)
await expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/])
}))
test.test('move (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('move (keyboard)', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
@ -123,36 +132,30 @@ test.test('move (keyboard)', ({ page }) =>
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.press('Mod+V')
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}),
)
await expect(rows).toHaveCount(2)
await expect(rows.nth(1)).toBeVisible()
await expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await getAssetRowLeftPx(rows.nth(0))
const childLeft = await getAssetRowLeftPx(rows.nth(1))
expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}))
test.test('cut (keyboard)', async ({ page }) =>
actions
.mockAllAndLogin({ page })
test('cut (keyboard)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.clickRow(0)
.press('Mod+X')
.driveTable.withRows(async (rows) => {
// This action is not a builtin `expect` action, so it needs to be manually retried.
await test
.expect(async () => {
test
.expect(await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity)))
.toBeLessThan(1)
})
.toPass()
}),
)
await expect(async () => {
expect(
await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity)),
).toBeLessThan(1)
}).toPass()
}))
test.test('duplicate', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('duplicate', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: New Project 1]
.newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
@ -163,16 +166,14 @@ test.test('duplicate', ({ page }) =>
.contextMenu.duplicate()
.driveTable.withRows(async (rows) => {
// Assets: [0: New Project 1, 1: New Project 1 (copy)]
await test.expect(rows).toHaveCount(2)
await test.expect(actions.locateContextMenu(page)).not.toBeVisible()
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}),
)
await expect(rows).toHaveCount(2)
await expect(locateContextMenu(page)).not.toBeVisible()
await expect(rows.nth(1)).toBeVisible()
await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}))
test.test('duplicate (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('duplicate (keyboard)', ({ page }) =>
mockAllAndLogin({ page })
// Assets: [0: New Project 1]
.newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
@ -183,8 +184,7 @@ test.test('duplicate (keyboard)', ({ page }) =>
.press('Mod+D')
.driveTable.withRows(async (rows) => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}),
)
await expect(rows).toHaveCount(2)
await expect(rows.nth(1)).toBeVisible()
await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}))

View File

@ -1,11 +1,7 @@
/** @file Test copying, moving, cutting and pasting. */
import * as test from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
import * as actions from './actions'
// =================
// === Constants ===
// =================
import { mockAllAndLogin } from './actions'
/** The name of the uploaded file. */
const FILE_NAME = 'foo.txt'
@ -16,50 +12,45 @@ const SECRET_NAME = 'a secret name'
/** The value of the created secret. */
const SECRET_VALUE = 'a secret value'
// =============
// === Tests ===
// =============
/** Find an editor container. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function locateEditor(page: Page) {
// Test ID of a placeholder editor component used during testing.
return page.locator('.App')
}
test.test('create folder', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('create folder', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/)
}),
)
await expect(rows).toHaveCount(1)
await expect(rows.nth(0)).toBeVisible()
await expect(rows.nth(0)).toHaveText(/^New Folder 1/)
}))
test.test('create project', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('create project', ({ page }) =>
mockAllAndLogin({ page })
.newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled.
// .do((thePage) => test.expect(actions.locateEditor(thePage)).toBeAttached())
// .do((thePage) => expect(locateEditor(thePage)).toBeAttached())
// .goToPage.drive()
.driveTable.withRows((rows) => test.expect(rows).toHaveCount(1)),
)
.driveTable.withRows((rows) => expect(rows).toHaveCount(1)))
test.test('upload file', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('upload file', ({ page }) =>
mockAllAndLogin({ page })
.uploadFile(FILE_NAME, FILE_CONTENTS)
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME))
}),
)
await expect(rows).toHaveCount(1)
await expect(rows.nth(0)).toBeVisible()
await expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME))
}))
test.test('create secret', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('create secret', ({ page }) =>
mockAllAndLogin({ page })
.createSecret(SECRET_NAME, SECRET_VALUE)
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME))
}),
)
await expect(rows).toHaveCount(1)
await expect(rows.nth(0)).toBeVisible()
await expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME))
}))

View File

@ -1,15 +1,13 @@
/** @file Test the user settings tab. */
import * as test from '@playwright/test'
import { test } from '@playwright/test'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
const DATA_LINK_NAME = 'a data link'
test.test('data link editor', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('data link editor', ({ page }) =>
mockAllAndLogin({ page })
.openDataLinkModal()
.withNameInput(async (input) => {
await input.fill(DATA_LINK_NAME)
}),
)
}))

View File

@ -1,39 +1,39 @@
/** @file Test copying, moving, cutting and pasting. */
import * as test from '@playwright/test'
import { expect, test } from '@playwright/test'
import { mockAllAndLogin, TEXT } from './actions'
test.test('delete and restore', ({ page }) =>
test('delete and restore', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await expect(rows).toHaveCount(1)
})
.driveTable.rightClickRow(0)
.contextMenu.moveFolderToTrash()
.driveTable.expectPlaceholderRow()
.goToCategory.trash()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await expect(rows).toHaveCount(1)
})
.driveTable.rightClickRow(0)
.contextMenu.restoreFromTrash()
.driveTable.expectTrashPlaceholderRow()
.goToCategory.cloud()
.expectStartModal()
.withStartModal(async (startModal) => {
await test.expect(startModal).toBeVisible()
await expect(startModal).toBeVisible()
})
.closeGetStartedModal()
.close()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
}),
)
await expect(rows).toHaveCount(1)
}))
test.test('delete and restore (keyboard)', ({ page }) =>
test('delete and restore (keyboard)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await expect(rows).toHaveCount(1)
})
.driveTable.clickRow(0)
.press('Delete')
@ -43,17 +43,14 @@ test.test('delete and restore (keyboard)', ({ page }) =>
.driveTable.expectPlaceholderRow()
.goToCategory.trash()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await expect(rows).toHaveCount(1)
})
.driveTable.clickRow(0)
.press('Mod+R')
.driveTable.expectTrashPlaceholderRow()
.goToCategory.cloud()
.withStartModal(async (startModal) => {
await test.expect(startModal).toBeVisible()
})
.closeGetStartedModal()
.expectStartModal()
.close()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
}),
)
await expect(rows).toHaveCount(1)
}))

View File

@ -1,37 +1,49 @@
/** @file Test the drive view. */
import * as test from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import * as actions from './actions'
import { TEXT, mockAllAndLogin } from './actions'
test.test('drive view', ({ page }) =>
actions
.mockAllAndLogin({ page })
/** Find an editor container. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function locateEditor(page: Page) {
// Test ID of a placeholder editor component used during testing.
return page.locator('.App')
}
/** Find a button to close the project. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function locateStopProjectButton(page: Locator) {
return page.getByLabel(TEXT.stopExecution)
}
test('drive view', ({ page }) =>
mockAllAndLogin({ page })
.withDriveView(async (view) => {
await test.expect(view).toBeVisible()
await expect(view).toBeVisible()
})
.driveTable.expectPlaceholderRow()
.newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled.
// .do(async () => {
// await test.expect(actions.locateEditor(page)).toBeAttached()
// await expect(locateEditor(page)).toBeAttached()
// })
// .goToPage.drive()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
await expect(rows).toHaveCount(1)
})
.do(async () => {
await test.expect(actions.locateAssetsTable(page)).toBeVisible()
.withAssetsTable(async (assetsTable) => {
await expect(assetsTable).toBeVisible()
})
.newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled.
// .do(async () => {
// await test.expect(actions.locateEditor(page)).toBeAttached()
// await expect(locateEditor(page)).toBeAttached()
// })
// .goToPage.drive()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await expect(rows).toHaveCount(2)
})
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled.
@ -39,12 +51,11 @@ test.test('drive view', ({ page }) =>
// // user that project creation may take a while. Previously opened projects are stopped when the
// // new project is created.
// .driveTable.withRows(async (rows) => {
// await actions.locateStopProjectButton(rows.nth(1)).click()
// await locateStopProjectButton(rows.nth(1)).click()
// })
// Project context menu
.driveTable.rightClickRow(0)
.contextMenu.moveNonFolderToTrash()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
}),
)
await expect(rows).toHaveCount(1)
}))

View File

@ -1,129 +1,151 @@
/** @file Test copying, moving, cutting and pasting. */
import { test } from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import * as actions from './actions'
import { TEXT, mockAllAndLogin } from './actions'
test('edit name (double click)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz'
const NEW_NAME = 'foo bar baz'
const NEW_NAME_2 = 'foo bar baz quux'
await actions.locateNewFolderIcon(page).click()
await actions.locateAssetRowName(row).click()
await actions.locateAssetRowName(row).click()
await actions.locateAssetRowName(row).fill(newName)
await actions.locateEditingTick(row).click()
await test.expect(row).toHaveText(new RegExp('^' + newName))
/** Find the context menu. */
function locateContextMenu(page: Page) {
// This has no identifying features.
return page.getByTestId('context-menu')
}
/** Find the name column of the given assets table row. */
function locateAssetRowName(locator: Locator) {
return locator.getByTestId('asset-row-name')
}
/** Find a tick button. */
function locateEditingTick(page: Locator) {
return page.getByLabel(TEXT.confirmEdit)
}
/** Find a cross button. */
function locateEditingCross(page: Locator) {
return page.getByLabel(TEXT.cancelEdit)
}
test('edit name (double click)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows, _, { api }) => {
const row = rows.nth(0)
const nameEl = locateAssetRowName(row)
await nameEl.click()
await nameEl.click()
await nameEl.fill(NEW_NAME)
const calls = api.trackCalls()
await locateEditingTick(row).click()
await expect(row).toHaveText(new RegExp('^' + NEW_NAME))
expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME }])
}))
test('edit name (context menu)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows, _, { api }) => {
const row = rows.nth(0)
await locateAssetRowName(row).click({ button: 'right' })
await locateContextMenu(page)
.getByText(/Rename/)
.click()
const nameEl = locateAssetRowName(row)
await expect(nameEl).toBeVisible()
await expect(nameEl).toBeFocused()
await nameEl.fill(NEW_NAME)
await expect(nameEl).toHaveValue(NEW_NAME)
const calls = api.trackCalls()
await nameEl.press('Enter')
await expect(row).toHaveText(new RegExp('^' + NEW_NAME))
expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME }])
}))
test('edit name (keyboard)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows) => {
await locateAssetRowName(rows.nth(0)).click()
})
.press('Mod+R')
.driveTable.withRows(async (rows, _, { api }) => {
const row = rows.nth(0)
const nameEl = locateAssetRowName(row)
await nameEl.fill(NEW_NAME_2)
const calls = api.trackCalls()
await nameEl.press('Enter')
await expect(row).toHaveText(new RegExp('^' + NEW_NAME_2))
expect(calls.updateDirectory).toMatchObject([{ title: NEW_NAME_2 }])
}))
test('cancel editing name (double click)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows, _, { api }) => {
const row = rows.nth(0)
const nameEl = locateAssetRowName(row)
const oldName = (await nameEl.textContent()) ?? ''
await nameEl.click()
await nameEl.click()
await nameEl.fill(NEW_NAME)
const calls = api.trackCalls()
await locateEditingCross(row).click()
await expect(row).toHaveText(new RegExp('^' + oldName))
expect(calls.updateDirectory).toMatchObject([])
}))
test('cancel editing name (keyboard)', ({ page }) => {
let oldName = ''
return mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows) => {
await rows.nth(0).click()
})
.press('Mod+R')
.driveTable.withRows(async (rows, _, { api }) => {
const row = rows.nth(0)
const nameEl = locateAssetRowName(row)
oldName = (await nameEl.textContent()) ?? ''
await nameEl.fill(NEW_NAME_2)
const calls = api.trackCalls()
await nameEl.press('Escape')
await expect(row).toHaveText(new RegExp('^' + oldName))
expect(calls.updateDirectory).toMatchObject([])
})
})
test('edit name (context menu)', async ({ page }) => {
await actions.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addAsset(api.createDirectory({ title: 'foo' }))
},
})
test('change to blank name (double click)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows, _, { api }) => {
const row = rows.nth(0)
const nameEl = locateAssetRowName(row)
const oldName = (await nameEl.textContent()) ?? ''
await nameEl.click()
await nameEl.click()
await nameEl.fill('')
await expect(locateEditingTick(row)).not.toBeVisible()
const calls = api.trackCalls()
await locateEditingCross(row).click()
await expect(row).toHaveText(new RegExp('^' + oldName))
expect(calls.updateDirectory).toMatchObject([])
}))
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz'
await actions.locateAssetRowName(row).click({ button: 'right' })
await actions
.locateContextMenu(page)
.getByText(/Rename/)
.click()
const input = page.getByTestId('asset-row-name')
await test.expect(input).toBeVisible()
await test.expect(input).toBeFocused()
await input.fill(newName)
await test.expect(input).toHaveValue(newName)
await input.press('Enter')
await test.expect(row).toHaveText(new RegExp('^' + newName))
})
test('edit name (keyboard)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz quux'
await actions.locateNewFolderIcon(page).click()
await actions.locateAssetRowName(row).click()
await actions.press(page, 'Mod+R')
await actions.locateAssetRowName(row).fill(newName)
await actions.locateAssetRowName(row).press('Enter')
await test.expect(row).toHaveText(new RegExp('^' + newName))
})
test('cancel editing name (double click)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz'
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click()
await actions.locateAssetRowName(row).click()
await actions.locateAssetRowName(row).fill(newName)
await actions.locateEditingCross(row).click()
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})
test('cancel editing name (keyboard)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz quux'
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click()
await actions.press(page, 'Mod+R')
await actions.locateAssetRowName(row).fill(newName)
await actions.locateAssetRowName(row).press('Escape')
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})
test('change to blank name (double click)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click()
await actions.locateAssetRowName(row).click()
await actions.locateAssetRowName(row).fill('')
await test.expect(actions.locateEditingTick(row)).not.toBeVisible()
await actions.locateEditingCross(row).click()
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})
test('change to blank name (keyboard)', async ({ page }) => {
await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
await actions.locateNewFolderIcon(page).click()
const oldName = (await actions.locateAssetRowName(row).textContent()) ?? ''
await actions.locateAssetRowName(row).click()
await actions.press(page, 'Mod+R')
await actions.locateAssetRowName(row).fill('')
await actions.locateAssetRowName(row).press('Enter')
await test.expect(row).toHaveText(new RegExp('^' + oldName))
})
test('change to blank name (keyboard)', ({ page }) =>
mockAllAndLogin({ page })
.createFolder()
.driveTable.withRows(async (rows) => {
await locateAssetRowName(rows.nth(0)).click()
})
.press('Mod+R')
.driveTable.withRows(async (rows, _, { api }) => {
const row = rows.nth(0)
const nameEl = locateAssetRowName(row)
const oldName = (await nameEl.textContent()) ?? ''
await nameEl.fill('')
const calls = api.trackCalls()
await nameEl.press('Enter')
await expect(row).toHaveText(new RegExp('^' + oldName))
expect(calls.updateDirectory).toMatchObject([])
}))

View File

@ -1,80 +1,90 @@
/** @file Test dragging of labels. */
import * as test from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import * as backend from '#/services/Backend'
import { COLORS } from '#/services/Backend'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
export const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
const LABEL = 'aaaa'
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
/** Click an asset row. The center must not be clicked as that is the button for adding a label. */
export async function clickAssetRow(assetRow: test.Locator) {
async function clickAssetRow(assetRow: Locator) {
await assetRow.click({ position: ASSET_ROW_SAFE_POSITION })
}
test.test('drag labels onto single row', async ({ page }) => {
const label = 'aaaa'
return actions
.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(label, backend.COLORS[0])
api.addLabel('bbbb', backend.COLORS[1])
api.addLabel('cccc', backend.COLORS[2])
api.addLabel('dddd', backend.COLORS[3])
api.addDirectory({ title: 'foo' })
api.addSecret({ title: 'bar' })
api.addFile({ title: 'baz' })
api.addSecret({ title: 'quux' })
},
})
.do(async () => {
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
/** Find labels in the "Labels" column of the assets table. */
function locateAssetLabels(page: Locator) {
return page.getByTestId('asset-label')
}
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(1))
await test
.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label))
.not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible()
await test
.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label))
.not.toBeVisible()
await test
.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label))
.not.toBeVisible()
})
})
/** Find a labels panel. */
function locateLabelsPanel(page: Page) {
// This has no identifying features.
return page.getByTestId('labels')
}
test.test('drag labels onto multiple rows', async ({ page }) => {
const label = 'aaaa'
await actions.mockAllAndLogin({
/** Find all labels in the labels panel. */
function locateLabelsPanelLabels(page: Page, name?: string) {
return (
locateLabelsPanel(page)
.getByRole('button')
.filter(name != null ? { has: page.getByText(name) } : {})
// The delete button is also a `button`.
.and(page.locator(':nth-child(1)'))
)
}
test('drag labels onto single row', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(label, backend.COLORS[0])
api.addLabel('bbbb', backend.COLORS[1])
api.addLabel('cccc', backend.COLORS[2])
api.addLabel('dddd', backend.COLORS[3])
api.addLabel(LABEL, COLORS[0])
api.addLabel('bbbb', COLORS[1])
api.addLabel('cccc', COLORS[2])
api.addLabel('dddd', COLORS[3])
api.addDirectory({ title: 'foo' })
api.addSecret({ title: 'bar' })
api.addFile({ title: 'baz' })
api.addSecret({ title: 'quux' })
},
}).driveTable.withRows(async (rows, _, _context, page) => {
const labelEl = locateLabelsPanelLabels(page, LABEL)
await expect(labelEl).toBeVisible()
await labelEl.dragTo(rows.nth(1))
await expect(locateAssetLabels(rows.nth(0)).getByText(LABEL)).not.toBeVisible()
await expect(locateAssetLabels(rows.nth(1)).getByText(LABEL)).toBeVisible()
await expect(locateAssetLabels(rows.nth(2)).getByText(LABEL)).not.toBeVisible()
await expect(locateAssetLabels(rows.nth(3)).getByText(LABEL)).not.toBeVisible()
}))
test('drag labels onto multiple rows', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(LABEL, COLORS[0])
api.addLabel('bbbb', COLORS[1])
api.addLabel('cccc', COLORS[2])
api.addLabel('dddd', COLORS[3])
api.addDirectory({ title: 'foo' })
api.addSecret({ title: 'bar' })
api.addFile({ title: 'baz' })
api.addSecret({ title: 'quux' })
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
await page.keyboard.down(await actions.modModifier(page))
await test.expect(assetRows).toHaveCount(4)
await clickAssetRow(assetRows.nth(0))
await clickAssetRow(assetRows.nth(2))
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(2))
await page.keyboard.up(await actions.modModifier(page))
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible()
})
.withModPressed((self) =>
self.driveTable.withRows(async (rows, _, _context, page) => {
const labelEl = locateLabelsPanelLabels(page, LABEL)
await expect(rows).toHaveCount(4)
await clickAssetRow(rows.nth(0))
await clickAssetRow(rows.nth(2))
await expect(labelEl).toBeVisible()
await labelEl.dragTo(rows.nth(2))
}),
)
.driveTable.withRows(async (rows) => {
await expect(locateAssetLabels(rows.nth(0)).getByText(LABEL)).toBeVisible()
await expect(locateAssetLabels(rows.nth(1)).getByText(LABEL)).not.toBeVisible()
await expect(locateAssetLabels(rows.nth(2)).getByText(LABEL)).toBeVisible()
await expect(locateAssetLabels(rows.nth(3)).getByText(LABEL)).not.toBeVisible()
}))

View File

@ -1,57 +1,95 @@
/** @file Test the labels sidebar panel. */
import * as test from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import {
locateCreateButton,
locateLabelsPanel,
locateLabelsPanelLabels,
locateNewLabelButton,
locateNewLabelModal,
locateNewLabelModalColorButtons,
locateNewLabelModalNameInput,
mockAllAndLogin,
TEXT,
} from './actions'
import { mockAllAndLogin, TEXT } from './actions'
test.test.beforeEach(({ page }) => mockAllAndLogin({ page }))
/** Find a "new label" button. */
function locateNewLabelButton(page: Page) {
return page.getByRole('button', { name: 'new label' }).getByText('new label')
}
test.test('labels', async ({ page }) => {
// Empty labels panel
await test.expect(locateLabelsPanel(page)).toBeVisible()
/** Find a labels panel. */
function locateLabelsPanel(page: Page) {
// This has no identifying features.
return page.getByTestId('labels')
}
// "New Label" modal
await locateNewLabelButton(page).click()
await test.expect(locateNewLabelModal(page)).toBeVisible()
/** Find a "new label" modal. */
function locateNewLabelModal(page: Page) {
// This has no identifying features.
return page.getByTestId('new-label-modal')
}
// "New Label" modal with name set
await locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
/** Find a "name" input for a "new label" modal. */
function locateNewLabelModalNameInput(page: Page) {
return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox'))
}
await page.press('html', 'Escape')
/** Find all color radio button inputs for a "new label" modal. */
function locateNewLabelModalColorButtons(page: Page) {
return (
locateNewLabelModal(page)
.filter({ has: page.getByText('Color') })
// The `radio` inputs are invisible, so they cannot be used in the locator.
.locator('label[data-rac]')
)
}
// "New Label" modal with color set
// The exact number is allowed to vary; but to click the fourth color, there must be at least
// four colors.
await locateNewLabelButton(page).click()
test.expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4)
// `force: true` is required because the `label` needs to handle the click event, not the
// `button`.
await locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
await test.expect(locateNewLabelModal(page)).toBeVisible()
/** Find a "create" button. */
function locateCreateButton(page: Locator) {
return page.getByRole('button', { name: TEXT.create }).getByText(TEXT.create)
}
// "New Label" modal with name and color set
await locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
/** Find all labels in the labels panel. */
function locateLabelsPanelLabels(page: Page, name?: string) {
return (
locateLabelsPanel(page)
.getByRole('button')
.filter(name != null ? { has: page.getByText(name) } : {})
// The delete button is also a `button`.
.and(page.locator(':nth-child(1)'))
)
}
// Labels panel with one entry
await locateCreateButton(locateNewLabelModal(page)).click()
await test.expect(locateLabelsPanel(page)).toBeVisible()
test('labels', ({ page }) =>
mockAllAndLogin({ page })
.do(async (page) => {
// Empty labels panel
await expect(locateLabelsPanel(page)).toBeVisible()
// Empty labels panel again, after deleting the only entry
await locateLabelsPanelLabels(page).first().hover()
// "New Label" modal
await locateNewLabelButton(page).click()
await expect(locateNewLabelModal(page)).toBeVisible()
const labelsPanel = locateLabelsPanel(page)
await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click()
await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click()
test.expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1)
})
// "New Label" modal with name set
await locateNewLabelModalNameInput(page).fill('New Label')
await expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
})
.press('Escape')
.do(async (page) => {
// "New Label" modal with color set
// The exact number is allowed to vary; but to click the fourth color, there must be at least
// four colors.
await locateNewLabelButton(page).click()
expect(await locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4)
// `force: true` is required because the `label` needs to handle the click event, not the
// `button`.
await locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
await expect(locateNewLabelModal(page)).toBeVisible()
// "New Label" modal with name and color set
await locateNewLabelModalNameInput(page).fill('New Label')
await expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
// Labels panel with one entry
await locateCreateButton(locateNewLabelModal(page)).click()
await expect(locateLabelsPanel(page)).toBeVisible()
// Empty labels panel again, after deleting the only entry
await locateLabelsPanelLabels(page).first().hover()
const labelsPanel = locateLabelsPanel(page)
await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click()
await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click()
expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1)
}))

View File

@ -1,26 +1,36 @@
/** @file Test the login flow. */
import * as test from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
import * as actions from './actions'
import { TEXT, mockAll } from './actions'
// =============
// === Tests ===
// =============
/** Find a "login" button.on the current locator. */
function locateLoginButton(page: Page) {
return page.getByRole('button', { name: TEXT.login, exact: true }).getByText(TEXT.login)
}
/** Find a drive view. */
function locateDriveView(page: Page) {
// This has no identifying features.
return page.getByTestId('drive-view')
}
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.use({ storageState: { cookies: [], origins: [] } })
test.test('login and logout', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('login and logout', ({ page }) =>
mockAll({ page })
.login()
.expectStartModal()
.close()
.withDriveView(async (driveView) => {
await expect(driveView).toBeVisible()
})
.do(async (thePage) => {
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
await expect(locateLoginButton(thePage)).not.toBeVisible()
})
.openUserMenu()
.userMenu.logout()
.do(async (thePage) => {
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).toBeVisible()
}),
)
await expect(locateDriveView(thePage)).not.toBeVisible()
await expect(locateLoginButton(thePage)).toBeVisible()
}))

View File

@ -1,16 +1,12 @@
/** @file Test the login flow. */
import * as test from '@playwright/test'
import { expect, test } from '@playwright/test'
import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions'
// =============
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.use({ storageState: { cookies: [], origins: [] } })
test.test('login screen', ({ page }) =>
test('login screen', ({ page }) =>
mockAll({ page })
.loginThatShouldFail('invalid email', VALID_PASSWORD, {
assert: {
@ -22,6 +18,5 @@ test.test('login screen', ({ page }) =>
// Technically it should not be allowed, but
.login(VALID_EMAIL, INVALID_PASSWORD)
.withDriveView(async (driveView) => {
await test.expect(driveView).toBeVisible()
}),
)
await expect(driveView).toBeVisible()
}))

View File

@ -1,106 +1,101 @@
/** @file Test the organization settings tab. */
import * as test from '@playwright/test'
import { expect, test } from '@playwright/test'
import { Plan } from 'enso-common/src/services/Backend'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
test.test('organization settings', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({
const NEW_NAME = 'another organization-name'
const INVALID_EMAIL = 'invalid@email'
const NEW_EMAIL = 'organization@email.com'
const NEW_WEBSITE = 'organization.org'
const NEW_LOCATION = 'Somewhere, CA'
const PROFILE_PICTURE_FILENAME = 'bar.jpeg'
const PROFILE_PICTURE_CONTENT = 'organization profile picture'
const PROFILE_PICTURE_MIMETYPE = 'image/jpeg'
test('organization settings', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (theApi) => {
theApi.setPlan(Plan.team)
setupAPI: (api) => {
api.setPlan(Plan.team)
api.setCurrentOrganization(api.defaultOrganization)
},
})
const localActions = actions.settings.organization
// Setup
api.setCurrentOrganization(api.defaultOrganization)
await test.test.step('Initial state', () => {
test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName)
test.expect(api.currentOrganization()?.email).toBe(null)
test.expect(api.currentOrganization()?.picture).toBe(null)
test.expect(api.currentOrganization()?.website).toBe(null)
test.expect(api.currentOrganization()?.address).toBe(null)
})
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
await localActions.go(page)
const nameInput = localActions.locateNameInput(page)
const newName = 'another organization-name'
await test.test.step('Set name', async () => {
await nameInput.fill(newName)
await nameInput.press('Enter')
test.expect(api.currentOrganization()?.name).toBe(newName)
test.expect(api.currentUser()?.name).not.toBe(newName)
})
await test.test.step('Unset name (should fail)', async () => {
await nameInput.fill('')
await nameInput.press('Enter')
await test.expect(nameInput).toHaveValue('')
test.expect(api.currentOrganization()?.name).toBe(newName)
await page.getByRole('button', { name: actions.TEXT.cancel }).click()
})
const invalidEmail = 'invalid@email'
const emailInput = localActions.locateEmailInput(page)
await test.test.step('Set invalid email', async () => {
await emailInput.fill(invalidEmail)
await emailInput.press('Enter')
test.expect(api.currentOrganization()?.email).toBe('')
})
const newEmail = 'organization@email.com'
await test.test.step('Set email', async () => {
await emailInput.fill(newEmail)
await emailInput.press('Enter')
test.expect(api.currentOrganization()?.email).toBe(newEmail)
await test.expect(emailInput).toHaveValue(newEmail)
})
const websiteInput = localActions.locateWebsiteInput(page)
const newWebsite = 'organization.org'
// NOTE: It's not yet possible to unset the website or the location.
await test.test.step('Set website', async () => {
await websiteInput.fill(newWebsite)
await websiteInput.press('Enter')
test.expect(api.currentOrganization()?.website).toBe(newWebsite)
await test.expect(websiteInput).toHaveValue(newWebsite)
})
const locationInput = localActions.locateLocationInput(page)
const newLocation = 'Somewhere, CA'
await test.test.step('Set location', async () => {
await locationInput.fill(newLocation)
await locationInput.press('Enter')
test.expect(api.currentOrganization()?.address).toBe(newLocation)
await test.expect(locationInput).toHaveValue(newLocation)
})
})
test.test('upload organization profile picture', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({
page,
setupAPI: (theApi) => {
theApi.setPlan(Plan.team)
},
})
const localActions = actions.settings.organizationProfilePicture
await localActions.go(page)
const fileChooserPromise = page.waitForEvent('filechooser')
await localActions.locateInput(page).click()
const fileChooser = await fileChooserPromise
const name = 'bar.jpeg'
const content = 'organization profile picture'
await fileChooser.setFiles([{ name, buffer: Buffer.from(content), mimeType: 'image/jpeg' }])
await test
.expect(() => {
test.expect(api.currentOrganizationProfilePicture()).toEqual(content)
.step('Verify initial organization state', (_, { api }) => {
expect(api.defaultUser.isOrganizationAdmin).toBe(true)
expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName)
expect(api.currentOrganization()?.email).toBe(null)
expect(api.currentOrganization()?.picture).toBe(null)
expect(api.currentOrganization()?.website).toBe(null)
expect(api.currentOrganization()?.address).toBe(null)
})
.toPass()
})
.goToPage.settings()
.goToSettingsTab.organization()
.organizationForm()
.fillName(NEW_NAME)
.do((_, context) => {
context.calls = context.api.trackCalls()
})
.save()
.step('Set organization name', (_, { api, calls }) => {
expect(api.currentOrganization()?.name).toBe(NEW_NAME)
expect(api.currentUser()?.name).not.toBe(NEW_NAME)
expect(calls.updateOrganization).toMatchObject([{ name: NEW_NAME }])
})
.organizationForm()
.fillName('')
.do((_, context) => {
context.calls = context.api.trackCalls()
})
.save()
.step('Unsetting organization name should fail', (_, { api, calls }) => {
expect(api.currentOrganization()?.name).toBe(NEW_NAME)
expect(calls.updateOrganization).toMatchObject([{ name: '' }])
})
.organizationForm()
.cancel()
.organizationForm()
.fillEmail(INVALID_EMAIL)
.save()
.step('Setting invalid email should fail', (_, { api }) => {
expect(api.currentOrganization()?.email).toBe('')
})
.organizationForm()
.fillEmail(NEW_EMAIL)
.save()
.step('Set email', (_, { api }) => {
expect(api.currentOrganization()?.email).toBe(NEW_EMAIL)
})
.organizationForm()
.fillWebsite(NEW_WEBSITE)
.save()
// NOTE: It is not yet possible to unset the website or the location.
.step('Set website', async (_, { api }) => {
expect(api.currentOrganization()?.website).toBe(NEW_WEBSITE)
})
.organizationForm()
.fillLocation(NEW_LOCATION)
.save()
.step('Set website', async (_, { api }) => {
expect(api.currentOrganization()?.address).toBe(NEW_LOCATION)
}))
test('upload organization profile picture', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (theApi) => {
theApi.setPlan(Plan.team)
},
})
.goToPage.settings()
.goToSettingsTab.organization()
.uploadProfilePicture(
PROFILE_PICTURE_FILENAME,
PROFILE_PICTURE_CONTENT,
PROFILE_PICTURE_MIMETYPE,
)
.step('Profile picture should be updated', async (_, { api }) => {
await expect(() => {
expect(api.currentOrganizationProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT)
}).toPass()
}))

View File

@ -1,27 +1,37 @@
/** @file Test the login flow. */
// import * as test from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
// import * as actions from './actions'
import { mockAllAndLogin } from './actions'
/** Find an editor container. */
function locateEditor(page: Page) {
// Test ID of a placeholder editor component used during testing.
return page.locator('.App')
}
/** Find a drive view. */
function locateDriveView(page: Page) {
// This has no identifying features.
return page.getByTestId('drive-view')
}
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled.
// test.test('page switcher', ({ page }) =>
// actions
// .mockAllAndLogin({ page })
// // Create a new project so that the editor page can be switched to.
// .newEmptyProject()
// .do(async (thePage) => {
// await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
// await test.expect(actions.locateEditor(thePage)).toBeVisible()
// })
// .goToPage.drive()
// .do(async (thePage) => {
// await test.expect(actions.locateDriveView(thePage)).toBeVisible()
// await test.expect(actions.locateEditor(thePage)).not.toBeVisible()
// })
// .goToPage.editor()
// .do(async (thePage) => {
// await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
// await test.expect(actions.locateEditor(thePage)).toBeVisible()
// }),
// )
// Unskip once cloud execution in the browser is re-enabled.
test.skip('page switcher', ({ page }) =>
mockAllAndLogin({ page })
// Create a new project so that the editor page can be switched to.
.newEmptyProjectTest()
.do(async (thePage) => {
await expect(locateDriveView(thePage)).not.toBeVisible()
await expect(locateEditor(thePage)).toBeVisible()
})
.goToPage.drive()
.do(async (thePage) => {
await expect(locateDriveView(thePage)).toBeVisible()
await expect(locateEditor(thePage)).not.toBeVisible()
})
.goToPage.editor()
.do(async (thePage) => {
await expect(locateDriveView(thePage)).not.toBeVisible()
await expect(locateEditor(thePage)).toBeVisible()
}))

View File

@ -1,54 +1,49 @@
/** @file Test the setup flow. */
import * as test from '@playwright/test'
import { expect, test } from '@playwright/test'
import { Plan } from 'enso-common/src/services/Backend'
import * as actions from './actions'
import { mockAll } from './actions'
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.use({ storageState: { cookies: [], origins: [] } })
test.test('setup (free plan)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
test('setup (free plan)', ({ page }) =>
mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
.stayOnFreePlan()
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
await expect(drive).toBeVisible()
}))
test.test('setup (solo plan)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
test('setup (solo plan)', ({ page }) =>
mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
.selectSoloPlan()
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
await expect(drive).toBeVisible()
}))
test.test('setup (team plan, skipping invites)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
test('setup (team plan, skipping invites)', ({ page }) =>
mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
.selectTeamPlan(Plan.team)
@ -57,18 +52,16 @@ test.test('setup (team plan, skipping invites)', ({ page }) =>
.setTeamName('test team')
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
await expect(drive).toBeVisible()
}))
test.test('setup (team plan)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
test('setup (team plan)', ({ page }) =>
mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
.selectTeamPlan(Plan.team, 10)
@ -77,8 +70,7 @@ test.test('setup (team plan)', ({ page }) =>
.setTeamName('test team')
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
await expect(drive).toBeVisible()
}))
// No test for enterprise plan as the plan must be set to enterprise manually.

View File

@ -1,16 +1,12 @@
/** @file Test the login flow. */
import * as test from '@playwright/test'
import { test } from '@playwright/test'
import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions'
// =============
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.use({ storageState: { cookies: [], origins: [] } })
test.test('sign up without organization id', ({ page }) =>
test('sign up without organization id', ({ page }) =>
mockAll({ page })
.goToPage.register()
.registerThatShouldFail('invalid email', VALID_PASSWORD, VALID_PASSWORD, {
@ -37,5 +33,4 @@ test.test('sign up without organization id', ({ page }) =>
formError: null,
},
})
.register(),
)
.register())

View File

@ -1,43 +1,62 @@
/** @file Test sorting of assets columns. */
import * as test from '@playwright/test'
import { expect, test, type Locator } from '@playwright/test'
import * as dateTime from '#/utilities/dateTime'
import { toRfc3339 } from '#/utilities/dateTime'
import * as actions from './actions'
import { mockAllAndLogin } from './actions'
// =================
// === Constants ===
// =================
/** A test assertion to confirm that the element is fully transparent. */
async function expectOpacity0(locator: Locator) {
await test.step('Expect `opacity: 0`', async () => {
await expect(async () => {
expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).toBe('0')
}).toPass()
})
}
/** A test assertion to confirm that the element is not fully transparent. */
async function expectNotOpacity0(locator: Locator) {
await test.step('Expect not `opacity: 0`', async () => {
await expect(async () => {
expect(await locator.evaluate((el) => getComputedStyle(el).opacity)).not.toBe('0')
}).toPass()
})
}
/** Find a "sort ascending" icon. */
function locateSortAscendingIcon(page: Locator) {
return page.getByAltText('Sort Ascending')
}
/** Find a "sort descending" icon. */
function locateSortDescendingIcon(page: Locator) {
return page.getByAltText('Sort Descending')
}
const START_DATE_EPOCH_MS = 1.7e12
/** The number of milliseconds in a minute. */
const MIN_MS = 60_000
// =============
// === Tests ===
// =============
test.test('sort', async ({ page }) => {
await actions.mockAll({
test('sort', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (api) => {
const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS))
const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS))
const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS))
const date4 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS))
const date5 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS))
const date6 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS))
const date7 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS))
const date8 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS))
const date1 = toRfc3339(new Date(START_DATE_EPOCH_MS))
const date2 = toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS))
const date3 = toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS))
const date4 = toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS))
const date5 = toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS))
const date6 = toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS))
const date7 = toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS))
const date8 = toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS))
api.addDirectory({ modifiedAt: date4, title: 'a directory' })
api.addDirectory({ modifiedAt: date6, title: 'G directory' })
api.addProject({ modifiedAt: date7, title: 'C project' })
api.addSecret({ modifiedAt: date2, title: 'H secret' })
api.addProject({ modifiedAt: date1, title: 'b project' })
api.addFile({ modifiedAt: date8, title: 'd file' })
api.addFile({ modifiedAt: date5, title: 'e file' })
api.addSecret({ modifiedAt: date2, title: 'H secret' })
api.addSecret({ modifiedAt: date3, title: 'f secret' })
api.addFile({ modifiedAt: date5, title: 'e file' })
// By date:
// b project
// h secret
@ -49,113 +68,135 @@ test.test('sort', async ({ page }) => {
// d file
},
})
const assetRows = actions.locateAssetRows(page)
const nameHeading = actions.locateNameColumnHeading(page)
const modifiedHeading = actions.locateModifiedColumnHeading(page)
await actions.login({ page })
// By default, assets should be grouped by type.
// Assets in each group are ordered by insertion order.
await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading))
await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible()
await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading))
await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
await Promise.all([
test.expect(assetRows.nth(0)).toHaveText(/^a directory/),
test.expect(assetRows.nth(1)).toHaveText(/^G directory/),
test.expect(assetRows.nth(2)).toHaveText(/^C project/),
test.expect(assetRows.nth(3)).toHaveText(/^b project/),
test.expect(assetRows.nth(4)).toHaveText(/^d file/),
test.expect(assetRows.nth(5)).toHaveText(/^e file/),
test.expect(assetRows.nth(6)).toHaveText(/^H secret/),
test.expect(assetRows.nth(7)).toHaveText(/^f secret/),
])
// Sort by name ascending.
await nameHeading.click()
await actions.expectNotOpacity0(actions.locateSortAscendingIcon(nameHeading))
await Promise.all([
test.expect(assetRows.nth(0)).toHaveText(/^a directory/),
test.expect(assetRows.nth(1)).toHaveText(/^b project/),
test.expect(assetRows.nth(2)).toHaveText(/^C project/),
test.expect(assetRows.nth(3)).toHaveText(/^d file/),
test.expect(assetRows.nth(4)).toHaveText(/^e file/),
test.expect(assetRows.nth(5)).toHaveText(/^f secret/),
test.expect(assetRows.nth(6)).toHaveText(/^G directory/),
test.expect(assetRows.nth(7)).toHaveText(/^H secret/),
])
// Sort by name descending.
await nameHeading.click()
await actions.expectNotOpacity0(actions.locateSortDescendingIcon(nameHeading))
await Promise.all([
test.expect(assetRows.nth(0)).toHaveText(/^H secret/),
test.expect(assetRows.nth(1)).toHaveText(/^G directory/),
test.expect(assetRows.nth(2)).toHaveText(/^f secret/),
test.expect(assetRows.nth(3)).toHaveText(/^e file/),
test.expect(assetRows.nth(4)).toHaveText(/^d file/),
test.expect(assetRows.nth(5)).toHaveText(/^C project/),
test.expect(assetRows.nth(6)).toHaveText(/^b project/),
test.expect(assetRows.nth(7)).toHaveText(/^a directory/),
])
// Sorting should be unset.
await nameHeading.click()
await page.mouse.move(0, 0)
await actions.expectOpacity0(actions.locateSortAscendingIcon(nameHeading))
await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible()
await Promise.all([
test.expect(assetRows.nth(0)).toHaveText(/^a directory/),
test.expect(assetRows.nth(1)).toHaveText(/^G directory/),
test.expect(assetRows.nth(2)).toHaveText(/^C project/),
test.expect(assetRows.nth(3)).toHaveText(/^b project/),
test.expect(assetRows.nth(4)).toHaveText(/^d file/),
test.expect(assetRows.nth(5)).toHaveText(/^e file/),
test.expect(assetRows.nth(6)).toHaveText(/^H secret/),
test.expect(assetRows.nth(7)).toHaveText(/^f secret/),
])
// Sort by date ascending.
await modifiedHeading.click()
await actions.expectNotOpacity0(actions.locateSortAscendingIcon(modifiedHeading))
await Promise.all([
test.expect(assetRows.nth(0)).toHaveText(/^b project/),
test.expect(assetRows.nth(1)).toHaveText(/^H secret/),
test.expect(assetRows.nth(2)).toHaveText(/^f secret/),
test.expect(assetRows.nth(3)).toHaveText(/^a directory/),
test.expect(assetRows.nth(4)).toHaveText(/^e file/),
test.expect(assetRows.nth(5)).toHaveText(/^G directory/),
test.expect(assetRows.nth(6)).toHaveText(/^C project/),
test.expect(assetRows.nth(7)).toHaveText(/^d file/),
])
// Sort by date descending.
await modifiedHeading.click()
await actions.expectNotOpacity0(actions.locateSortDescendingIcon(modifiedHeading))
await Promise.all([
test.expect(assetRows.nth(0)).toHaveText(/^d file/),
test.expect(assetRows.nth(1)).toHaveText(/^C project/),
test.expect(assetRows.nth(2)).toHaveText(/^G directory/),
test.expect(assetRows.nth(3)).toHaveText(/^e file/),
test.expect(assetRows.nth(4)).toHaveText(/^a directory/),
test.expect(assetRows.nth(5)).toHaveText(/^f secret/),
test.expect(assetRows.nth(6)).toHaveText(/^H secret/),
test.expect(assetRows.nth(7)).toHaveText(/^b project/),
])
// Sorting should be unset.
await modifiedHeading.click()
await page.mouse.move(0, 0)
await actions.expectOpacity0(actions.locateSortAscendingIcon(modifiedHeading))
await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
await Promise.all([
test.expect(assetRows.nth(0)).toHaveText(/^a directory/),
test.expect(assetRows.nth(1)).toHaveText(/^G directory/),
test.expect(assetRows.nth(2)).toHaveText(/^C project/),
test.expect(assetRows.nth(3)).toHaveText(/^b project/),
test.expect(assetRows.nth(4)).toHaveText(/^d file/),
test.expect(assetRows.nth(5)).toHaveText(/^e file/),
test.expect(assetRows.nth(6)).toHaveText(/^H secret/),
test.expect(assetRows.nth(7)).toHaveText(/^f secret/),
])
})
.driveTable.withNameColumnHeading(async (nameHeading) => {
await expectOpacity0(locateSortAscendingIcon(nameHeading))
await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible()
})
.driveTable.withModifiedColumnHeading(async (modifiedHeading) => {
await expectOpacity0(locateSortAscendingIcon(modifiedHeading))
await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
})
.driveTable.withRows(async (rows) => {
// By default, assets should be grouped by type.
// Assets in each group are ordered by insertion order.
await expect(rows).toHaveText([
/^a directory/,
/^G directory/,
/^C project/,
/^b project/,
/^d file/,
/^e file/,
/^H secret/,
/^f secret/,
])
})
// Sort by name ascending.
.driveTable.clickNameColumnHeading()
.driveTable.withNameColumnHeading(async (nameHeading) => {
await expectNotOpacity0(locateSortAscendingIcon(nameHeading))
})
.driveTable.withRows(async (rows) => {
await expect(rows).toHaveText([
/^a directory/,
/^b project/,
/^C project/,
/^d file/,
/^e file/,
/^f secret/,
/^G directory/,
/^H secret/,
])
})
// Sort by name descending.
.driveTable.clickNameColumnHeading()
.driveTable.withNameColumnHeading(async (nameHeading) => {
await expectNotOpacity0(locateSortDescendingIcon(nameHeading))
})
.driveTable.withRows(async (rows) => {
await expect(rows).toHaveText([
/^H secret/,
/^G directory/,
/^f secret/,
/^e file/,
/^d file/,
/^C project/,
/^b project/,
/^a directory/,
])
})
// Sorting should be unset.
.driveTable.clickNameColumnHeading()
.do(async (thePage) => {
await thePage.mouse.move(0, 0)
})
.driveTable.withNameColumnHeading(async (nameHeading) => {
await expectOpacity0(locateSortAscendingIcon(nameHeading))
await expect(locateSortDescendingIcon(nameHeading)).not.toBeVisible()
})
.driveTable.withRows(async (rows) => {
await expect(rows).toHaveText([
/^a directory/,
/^G directory/,
/^C project/,
/^b project/,
/^d file/,
/^e file/,
/^H secret/,
/^f secret/,
])
})
// Sort by date ascending.
.driveTable.clickModifiedColumnHeading()
.driveTable.withModifiedColumnHeading(async (modifiedHeading) => {
await expectNotOpacity0(locateSortAscendingIcon(modifiedHeading))
})
.driveTable.withRows(async (rows) => {
await expect(rows).toHaveText([
/^b project/,
/^H secret/,
/^f secret/,
/^a directory/,
/^e file/,
/^G directory/,
/^C project/,
/^d file/,
])
})
// Sort by date descending.
.driveTable.clickModifiedColumnHeading()
.driveTable.withModifiedColumnHeading(async (modifiedHeading) => {
await expectNotOpacity0(locateSortDescendingIcon(modifiedHeading))
})
.driveTable.withRows(async (rows) => {
await expect(rows).toHaveText([
/^d file/,
/^C project/,
/^G directory/,
/^e file/,
/^a directory/,
/^f secret/,
/^H secret/,
/^b project/,
])
})
// Sorting should be unset.
.driveTable.clickModifiedColumnHeading()
.do(async (thePage) => {
await thePage.mouse.move(0, 0)
})
.driveTable.withModifiedColumnHeading(async (modifiedHeading) => {
await expectOpacity0(locateSortAscendingIcon(modifiedHeading))
await expect(locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
})
.driveTable.withRows(async (rows) => {
await expect(rows).toHaveText([
/^a directory/,
/^G directory/,
/^C project/,
/^b project/,
/^d file/,
/^e file/,
/^H secret/,
/^f secret/,
])
}))

View File

@ -1,17 +1,34 @@
/** @file Test the "change password" modal. */
// import * as test from '@playwright/test'
import { expect, test, type Page } from '@playwright/test'
// import * as actions from './actions'
import { mockAllAndLogin } from './actions'
/** Find an editor container. */
function locateEditor(page: Page) {
// Test ID of a placeholder editor component used during testing.
return page.locator('.App')
}
/** Find a samples list. */
function locateSamplesList(page: Page) {
// This has no identifying features.
return page.getByTestId('samples')
}
/** Find all samples list. */
function locateSamples(page: Page) {
// This has no identifying features.
return locateSamplesList(page).getByRole('button')
}
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled.
// test.test('create project from template', ({ page }) =>
// actions
// .mockAllAndLogin({ page })
// .openStartModal()
// .createProjectFromTemplate(0)
// .do(async (thePage) => {
// await test.expect(actions.locateEditor(thePage)).toBeAttached()
// await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
// }),
// )
// Unskip once cloud execution in the browser is re-enabled.
test.skip('create project from template', ({ page }) =>
mockAllAndLogin({ page })
.expectStartModal()
.createProjectFromTemplate(0)
.do(async (thePage) => {
await expect(locateEditor(thePage)).toBeAttached()
await expect(locateSamples(page).first()).not.toBeVisible()
}))

View File

@ -1,89 +1,80 @@
/** @file Test the user settings tab. */
import * as test from '@playwright/test'
import { expect, test } from '@playwright/test'
import * as actions from './actions'
import { INVALID_PASSWORD, TEXT, VALID_PASSWORD, mockAllAndLogin } from './actions'
test.test('user settings', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.userAccount
test.expect(api.currentUser()?.name).toBe(api.defaultName)
const NEW_USERNAME = 'another user-name'
const NEW_PASSWORD = '1234!' + VALID_PASSWORD
const PROFILE_PICTURE_FILENAME = 'foo.png'
const PROFILE_PICTURE_CONTENT = 'a profile picture'
const PROFILE_PICTURE_MIMETYPE = 'image/png'
await localActions.go(page)
const nameInput = localActions.locateNameInput(page)
const newName = 'another user-name'
await nameInput.fill(newName)
await nameInput.press('Enter')
test.expect(api.currentUser()?.name).toBe(newName)
test.expect(api.currentOrganization()?.name).not.toBe(newName)
})
test('user settings', ({ page }) =>
mockAllAndLogin({ page })
.do((_, { api }) => {
expect(api.currentUser()?.name).toBe(api.defaultName)
})
.goToPage.settings()
.accountForm()
.fillName(NEW_USERNAME)
.save()
.do((_, { api }) => {
expect(api.currentUser()?.name).toBe(NEW_USERNAME)
expect(api.currentOrganization()?.name).not.toBe(NEW_USERNAME)
}))
test.test('change password form', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.changePassword
await localActions.go(page)
test.expect(api.currentPassword()).toBe(actions.VALID_PASSWORD)
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await test.test.step('Invalid new password', async () => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await localActions.locateSaveButton(page).click()
await test
.expect(
localActions
.locate(page)
test('change password form', ({ page }) =>
mockAllAndLogin({ page })
.do((_, { api }) => {
expect(api.currentPassword()).toBe(VALID_PASSWORD)
})
.goToPage.settings()
.changePasswordForm()
.fillCurrentPassword(VALID_PASSWORD)
.fillNewPassword(INVALID_PASSWORD)
.fillConfirmNewPassword(INVALID_PASSWORD)
.save()
.step('Invalid new password should fail', async (page) => {
await expect(
page
.getByRole('group', { name: /^New password/, exact: true })
.locator('.text-danger')
.last(),
)
.toHaveText(actions.TEXT.passwordValidationError)
})
await test.test.step('Invalid new password confirmation', async () => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a')
await localActions.locateSaveButton(page).click()
await test
.expect(
localActions
.locate(page)
).toHaveText(TEXT.passwordValidationError)
})
.changePasswordForm()
.fillCurrentPassword(VALID_PASSWORD)
.fillNewPassword(VALID_PASSWORD)
.fillConfirmNewPassword(VALID_PASSWORD + 'a')
.save()
.step('Invalid new password confirmation should fail', async (page) => {
await expect(
page
.getByRole('group', { name: /^Confirm new password/, exact: true })
.locator('.text-danger')
.last(),
)
.toHaveText(actions.TEXT.passwordMismatchError)
})
await test.test.step('Successful password change', async () => {
const newPassword = '1234!' + actions.VALID_PASSWORD
await localActions.locateNewPasswordInput(page).fill(newPassword)
await localActions.locateConfirmNewPasswordInput(page).fill(newPassword)
await localActions.locateSaveButton(page).click()
await test.expect(localActions.locateCurrentPasswordInput(page)).toHaveText('')
await test.expect(localActions.locateNewPasswordInput(page)).toHaveText('')
await test.expect(localActions.locateConfirmNewPasswordInput(page)).toHaveText('')
test.expect(api.currentPassword()).toBe(newPassword)
})
})
test.test('upload profile picture', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.profilePicture
await localActions.go(page)
const fileChooserPromise = page.waitForEvent('filechooser')
await localActions.locateInput(page).click()
const fileChooser = await fileChooserPromise
const name = 'foo.png'
const content = 'a profile picture'
await fileChooser.setFiles([{ name, mimeType: 'image/png', buffer: Buffer.from(content) }])
await test
.expect(() => {
test.expect(api.currentProfilePicture()).toEqual(content)
).toHaveText(TEXT.passwordMismatchError)
})
.toPass()
})
.changePasswordForm()
.fillCurrentPassword(VALID_PASSWORD)
.fillNewPassword(NEW_PASSWORD)
.fillConfirmNewPassword(NEW_PASSWORD)
.save()
// TODO: consider checking that password inputs are now empty.
.step('Password change should be successful', (_, { api }) => {
expect(api.currentPassword()).toBe(NEW_PASSWORD)
}))
test('upload profile picture', ({ page }) =>
mockAllAndLogin({ page })
.goToPage.settings()
.uploadProfilePicture(
PROFILE_PICTURE_FILENAME,
PROFILE_PICTURE_CONTENT,
PROFILE_PICTURE_MIMETYPE,
)
.step('Profile picture should be updated', async (_, { api }) => {
await expect(() => {
expect(api.currentProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT)
}).toPass()
}))

View File

@ -52,7 +52,10 @@ export default function OrganizationProfilePictureInput(
return (
<>
<FocusRing within>
<aria.Label className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame">
<aria.Label
data-testid="organization-profile-picture-input"
className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame"
>
<img
src={organization?.picture ?? DefaultUserIcon}
className="pointer-events-none h-full w-full"

View File

@ -48,7 +48,10 @@ export default function ProfilePictureInput(props: ProfilePictureInputProps) {
return (
<>
<FocusRing within>
<aria.Label className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame">
<aria.Label
data-testid="user-profile-picture-input"
className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame"
>
<img
src={user?.profilePicture ?? DefaultUserIcon}
className="pointer-events-none h-full w-full"