mirror of
https://github.com/enso-org/enso.git
synced 2024-12-18 16:51:41 +03:00
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:
parent
2964457d48
commit
b83c5a15eb
@ -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`.
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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' })
|
||||
})
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
@ -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) }])
|
||||
})
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
@ -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) }])
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
@ -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>),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>)
|
||||
}
|
||||
}
|
||||
|
@ -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>)
|
||||
}
|
||||
}
|
||||
|
@ -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>)
|
||||
}
|
||||
}
|
||||
|
@ -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>)
|
||||
}
|
||||
}
|
||||
|
@ -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>)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
@ -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
|
||||
|
@ -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>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
@ -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
@ -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(),
|
||||
|
@ -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>),
|
||||
}
|
||||
}
|
||||
|
@ -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/)
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
@ -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)
|
||||
}))
|
||||
|
@ -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)
|
||||
}))
|
||||
|
@ -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 })
|
||||
})
|
||||
|
@ -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}`)
|
||||
}))
|
||||
|
@ -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)
|
||||
}))
|
||||
|
@ -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[)]/)
|
||||
}))
|
||||
|
@ -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))
|
||||
}))
|
||||
|
@ -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)
|
||||
}),
|
||||
)
|
||||
}))
|
||||
|
@ -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)
|
||||
}))
|
||||
|
@ -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)
|
||||
}))
|
||||
|
@ -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([])
|
||||
}))
|
||||
|
@ -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()
|
||||
}))
|
||||
|
@ -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)
|
||||
}))
|
||||
|
@ -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()
|
||||
}))
|
||||
|
@ -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()
|
||||
}))
|
||||
|
@ -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()
|
||||
}))
|
||||
|
@ -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()
|
||||
}))
|
||||
|
@ -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.
|
||||
|
@ -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())
|
||||
|
@ -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/,
|
||||
])
|
||||
}))
|
||||
|
@ -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()
|
||||
}))
|
||||
|
@ -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()
|
||||
}))
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user