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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,16 +10,11 @@ import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions' import * as permissions from '#/utilities/permissions'
import * as uniqueString from 'enso-common/src/utilities/uniqueString' import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import * as actions from './actions' import * as actions from '.'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
// =================
// === Constants ===
// =================
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
@ -58,9 +53,75 @@ const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*')
const BASE_URL = 'https://mock/' const BASE_URL = 'https://mock/'
const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/' const MOCK_S3_BUCKET_URL = 'https://mock-s3-bucket.com/'
// =============== function array<T>(): Readonly<T>[] {
// === mockApi === 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}. */ /** Parameters for {@link mockApi}. */
export interface MockParams { export interface MockParams {
@ -77,24 +138,10 @@ export interface SetupAPI {
} }
/** The return type of {@link mockApi}. */ /** 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 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. */ /** Add route handlers for the mock API to a page. */
async function mockApiInternal({ page, setupAPI }: MockParams) { async function mockApiInternal({ page, setupAPI }: MockParams) {
const defaultEmail = 'email@example.com' as backend.EmailAddress const defaultEmail = 'email@example.com' as backend.EmailAddress
@ -124,6 +171,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
website: null, website: null,
subscription: {}, subscription: {},
} }
const callsObjects = new Set<typeof INITIAL_CALLS_OBJECT>()
let totalSeats = 1 let totalSeats = 1
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
let subscriptionDuration = 0 let subscriptionDuration = 0
@ -160,6 +208,29 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
>() >()
usersMap.set(defaultUser.userId, defaultUser) 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) => { const addAsset = <T extends backend.AnyAsset>(asset: T) => {
assets.push(asset) assets.push(asset)
assetMap.set(asset.id, asset) assetMap.set(asset.id, asset)
@ -316,7 +387,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
return label 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]) const ids = new Set<backend.AssetId>([id])
for (const [innerId, asset] of assetMap) { for (const [innerId, asset] of assetMap) {
if (ids.has(asset.parentId)) { if (ids.has(asset.parentId)) {
@ -451,56 +522,6 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const patch = method('PATCH') const patch = method('PATCH')
const delete_ = method('DELETE') 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) => { await page.route(BASE_URL + '**', (_route, request) => {
throw new Error( throw new Error(
`Missing route handler for '${request.method()} ${request.url().replace(BASE_URL, '')}'.`, `Missing route handler for '${request.method()} ${request.url().replace(BASE_URL, '')}'.`,
@ -519,6 +540,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly newPassword: string readonly newPassword: string
} }
const body: Body = await request.postDataJSON() const body: Body = await request.postDataJSON()
called('changePassword', body)
if (body.oldPassword === currentPassword) { if (body.oldPassword === currentPassword) {
currentPassword = body.newPassword currentPassword = body.newPassword
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
@ -538,14 +560,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly labels?: backend.LabelName[] readonly labels?: backend.LabelName[]
readonly recent_projects?: boolean readonly recent_projects?: boolean
} }
const body = Object.fromEntries( const query = Object.fromEntries(
new URL(request.url()).searchParams.entries(), new URL(request.url()).searchParams.entries(),
) as unknown as Query ) 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) let filteredAssets = assets.filter((asset) => asset.parentId === parentId)
// This lint rule is broken; there is clearly a case for `undefined` below. // 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: { case backend.FilterBy.active: {
filteredAssets = filteredAssets.filter((asset) => !deletedAssets.has(asset.id)) filteredAssets = filteredAssets.filter((asset) => !deletedAssets.has(asset.id))
break break
@ -576,18 +599,23 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
return json return json
}) })
await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => { await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => {
called('listFiles', {})
return { files: [] } satisfies remoteBackend.ListFilesResponseBody return { files: [] } satisfies remoteBackend.ListFilesResponseBody
}) })
await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => { await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => {
called('listProjects', {})
return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody
}) })
await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => { await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => {
called('listSecrets', {})
return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody
}) })
await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => { await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => {
called('listTags', {})
return { tags: labels } satisfies remoteBackend.ListTagsResponseBody return { tags: labels } satisfies remoteBackend.ListTagsResponseBody
}) })
await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => { await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => {
called('listUsers', {})
if (currentUser != null) { if (currentUser != null) {
return { users } satisfies remoteBackend.ListUsersResponseBody return { users } satisfies remoteBackend.ListUsersResponseBody
} else { } else {
@ -596,28 +624,35 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} }
}) })
await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => { await get(remoteBackendPaths.LIST_USER_GROUPS_PATH + '*', async (route) => {
called('listUserGroups', {})
await route.fulfill({ json: userGroups }) await route.fulfill({ json: userGroups })
}) })
await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => ({ await get(remoteBackendPaths.LIST_VERSIONS_PATH + '*', (_route, request) => {
versions: [ called('listVersions', {})
{ return {
ami: null, versions: [
created: dateTime.toRfc3339(new Date()), {
number: { ami: null,
lifecycle: created: dateTime.toRfc3339(new Date()),
'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development, number: {
value: '2023.2.1-dev', lifecycle:
}, 'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development,
// eslint-disable-next-line camelcase value: '2023.2.1-dev',
version_type: (new URL(request.url()).searchParams.get('version_type') ?? },
'') as backend.VersionType, // eslint-disable-next-line camelcase
} satisfies backend.Version, version_type: (new URL(request.url()).searchParams.get('version_type') ??
], '') as backend.VersionType,
})) } satisfies backend.Version,
],
}
})
// === Endpoints with dummy implementations === // === Endpoints with dummy implementations ===
await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => { 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) const project = assetMap.get(projectId)
if (!project) { if (!project) {
@ -661,11 +696,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly parentDirectoryId: backend.DirectoryId 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 // This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy. // `DirectoryId` to make TypeScript happy.
const asset = const asset = assetId != null ? assetMap.get(assetId) : null
assetId != null ? assetMap.get(backend.DirectoryId(decodeURIComponent(assetId))) : null
if (asset == null) { if (asset == null) {
if (assetId == null) { if (assetId == null) {
await route.fulfill({ await route.fulfill({
@ -681,6 +717,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} else { } else {
const body: Body = request.postDataJSON() const body: Body = request.postDataJSON()
const parentId = body.parentDirectoryId const parentId = body.parentDirectoryId
called('copyAsset', { assetId: assetId!, parentId })
// Can be any asset ID. // Can be any asset ID.
const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`) const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`)
const json: backend.CopyAssetResponse = { const json: backend.CopyAssetResponse = {
@ -701,22 +738,25 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}) })
await get(remoteBackendPaths.INVITATION_PATH + '*', (): backend.ListInvitationsResponseBody => { await get(remoteBackendPaths.INVITATION_PATH + '*', (): backend.ListInvitationsResponseBody => {
called('listInvitations', {})
return { return {
invitations: [], invitations: [],
availableLicenses: totalSeats - usersMap.size, availableLicenses: totalSeats - usersMap.size,
} }
}) })
await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => { await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => {
called('inviteUser', {})
await route.fulfill() await route.fulfill()
}) })
await post(remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async (route) => { await post(remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async (route) => {
await route.fulfill() called('createPermission', {})
})
await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route) => {
await route.fulfill() await route.fulfill()
}) })
await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => { 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) const project = assetMap.get(projectId)
if (project?.projectState) { if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.closed object.unsafeMutable(project.projectState).type = backend.ProjectState.closed
@ -724,7 +764,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await route.fulfill() await route.fulfill()
}) })
await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => { 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) const project = assetMap.get(projectId)
@ -740,10 +783,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
route.fulfill() 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 route.fulfill()
}) })
await post(remoteBackendPaths.POST_LOG_EVENT_PATH, async (route) => { await post(remoteBackendPaths.POST_LOG_EVENT_PATH, async (route) => {
called('postLogEvent', {})
await route.fulfill() await route.fulfill()
}) })
@ -752,6 +800,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await put(remoteBackendPaths.UPLOAD_USER_PICTURE_PATH + '*', async (route, request) => { await put(remoteBackendPaths.UPLOAD_USER_PICTURE_PATH + '*', async (route, request) => {
const content = request.postData() const content = request.postData()
if (content != null) { if (content != null) {
called('uploadUserPicture', { content })
currentProfilePicture = content currentProfilePicture = content
return null return null
} else { } else {
@ -762,6 +811,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await put(remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH + '*', async (route, request) => { await put(remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH + '*', async (route, request) => {
const content = request.postData() const content = request.postData()
if (content != null) { if (content != null) {
called('uploadOrganizationPicture', { content })
currentOrganizationProfilePicture = content currentOrganizationProfilePicture = content
return null return null
} else { } else {
@ -771,6 +821,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}) })
await page.route(MOCK_S3_BUCKET_URL + '**', async (route, request) => { await page.route(MOCK_S3_BUCKET_URL + '**', async (route, request) => {
if (request.method() !== 'PUT') { if (request.method() !== 'PUT') {
called('s3Put', {})
await route.fallback() await route.fallback()
} else { } else {
await route.fulfill({ await route.fulfill({
@ -782,9 +833,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} }
}) })
await post(remoteBackendPaths.UPLOAD_FILE_START_PATH + '*', () => { await post(remoteBackendPaths.UPLOAD_FILE_START_PATH + '*', () => {
const uploadId = backend.FileId('file-' + uniqueString.uniqueString())
called('uploadFileStart', { uploadId })
return { return {
sourcePath: backend.S3FilePath(''), sourcePath: backend.S3FilePath(''),
uploadId: 'file-' + uniqueString.uniqueString(), uploadId,
presignedUrls: Array.from({ length: 10 }, () => presignedUrls: Array.from({ length: 10 }, () =>
backend.HttpsUrl(`${MOCK_S3_BUCKET_URL}${uniqueString.uniqueString()}`), 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) => { await post(remoteBackendPaths.UPLOAD_FILE_END_PATH + '*', (_route, request) => {
const body: backend.UploadFileEndRequestBody = request.postDataJSON() const body: backend.UploadFileEndRequestBody = request.postDataJSON()
called('uploadFileEnd', body)
const file = addFile({ const file = addFile({
id: backend.FileId(body.uploadId), id: backend.FileId(body.uploadId),
@ -804,9 +858,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => { await post(remoteBackendPaths.CREATE_SECRET_PATH + '*', async (_route, request) => {
const body: backend.CreateSecretRequestBody = await request.postDataJSON() const body: backend.CreateSecretRequestBody = await request.postDataJSON()
const secret = addSecret({ called('createSecret', body)
title: body.name, const secret = addSecret({ title: body.name })
})
return secret.id return secret.id
}) })
@ -814,6 +867,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await post(remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH + '*', async (_route, request) => { await post(remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH + '*', async (_route, request) => {
const body: backend.CreateCheckoutSessionRequestBody = await request.postDataJSON() const body: backend.CreateCheckoutSessionRequestBody = await request.postDataJSON()
called('createCheckoutSession', body)
return createCheckoutSession(body) return createCheckoutSession(body)
}) })
await get( await get(
@ -825,6 +879,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} else { } else {
const result = checkoutSessionsMap.get(backend.CheckoutSessionId(checkoutSessionId)) const result = checkoutSessionsMap.get(backend.CheckoutSessionId(checkoutSessionId))
if (result) { if (result) {
called('getCheckoutSession', result)
if (currentUser) { if (currentUser) {
object.unsafeMutable(currentUser).plan = result.body.plan 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) => { await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => {
const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '' const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1]
const body: backend.UpdateAssetRequestBody = request.postDataJSON() if (!maybeId) return
// This could be an id for an arbitrary asset, but pretend it's a // This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy. // `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 (asset != null) {
if (body.description != null) { if (body.description != null) {
object.unsafeMutable(asset).description = body.description 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) => { 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. */ /** The type for the JSON request payload for this endpoint. */
interface Body { interface Body {
readonly labels: backend.LabelName[] readonly labels: readonly backend.LabelName[]
} }
/** The type for the JSON response payload for this endpoint. */ /** The type for the JSON response payload for this endpoint. */
interface Response { interface Response {
readonly tags: backend.Label[] readonly tags: readonly backend.Label[]
} }
const body: Body = await request.postDataJSON() const body: Body = await request.postDataJSON()
// This could be an id for an arbitrary asset, but pretend it's a called('associateTag', { ...body, assetId })
// `DirectoryId` to make TypeScript happy. setLabels(assetId, body.labels)
setLabels(backend.DirectoryId(assetId), body.labels)
const json: Response = { const json: Response = {
tags: body.labels.flatMap((value) => { tags: body.labels.flatMap((value) => {
const label = labelsByValue.get(value) const label = labelsByValue.get(value)
@ -876,16 +937,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
return json return json
}) })
await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => { 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 body: backend.UpdateDirectoryRequestBody = request.postDataJSON()
const asset = assetMap.get(backend.DirectoryId(directoryId)) called('updateDirectory', { ...body, directoryId })
const asset = assetMap.get(directoryId)
if (asset == null) { if (asset == null) {
await route.abort() await route.abort()
} else { } else {
object.unsafeMutable(asset).title = body.title object.unsafeMutable(asset).title = body.title
await route.fulfill({ await route.fulfill({
json: { json: {
id: backend.DirectoryId(directoryId), id: directoryId,
parentId: asset.parentId, parentId: asset.parentId,
title: body.title, title: body.title,
} satisfies backend.UpdatedDirectory, } satisfies backend.UpdatedDirectory,
@ -893,10 +957,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} }
}) })
await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { 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 // This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy. // `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 route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
}) })
await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => { await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => {
@ -905,6 +972,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
readonly assetId: backend.AssetId readonly assetId: backend.AssetId
} }
const body: Body = await request.postDataJSON() const body: Body = await request.postDataJSON()
called('undoDeleteAsset', body)
undeleteAsset(body.assetId) undeleteAsset(body.assetId)
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
}) })
@ -914,6 +982,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const rootDirectoryId = backend.DirectoryId( const rootDirectoryId = backend.DirectoryId(
organizationId.replace(/^organization-/, 'directory-'), organizationId.replace(/^organization-/, 'directory-'),
) )
called('createUser', body)
currentUser = { currentUser = {
email: body.userEmail, email: body.userEmail,
name: body.userName, name: body.userName,
@ -928,17 +997,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}) })
await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => { await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => {
const body: backend.CreateUserGroupRequestBody = await request.postDataJSON() const body: backend.CreateUserGroupRequestBody = await request.postDataJSON()
called('createUserGroup', body)
const userGroup = addUserGroup(body.name) const userGroup = addUserGroup(body.name)
return userGroup return userGroup
}) })
await put( await put(
remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*', remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*',
async (route, request) => { async (route, request) => {
const userId = backend.UserId( const maybeId = request.url().match(/[/]users[/]([^?/]+)/)?.[1]
decodeURIComponent(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. // The type of the body sent by this app is statically known.
const body: backend.ChangeUserGroupRequestBody = await request.postDataJSON() const body: backend.ChangeUserGroupRequestBody = await request.postDataJSON()
called('changeUserGroup', { userId, ...body })
const user = usersMap.get(userId) const user = usersMap.get(userId)
if (!user) { if (!user) {
await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) 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) => { await put(remoteBackendPaths.UPDATE_CURRENT_USER_PATH + '*', async (_route, request) => {
const body: backend.UpdateUserRequestBody = await request.postDataJSON() const body: backend.UpdateUserRequestBody = await request.postDataJSON()
called('updateCurrentUser', body)
if (currentUser && body.username != null) { if (currentUser && body.username != null) {
currentUser = { ...currentUser, name: body.username } currentUser = { ...currentUser, name: body.username }
} }
}) })
await get(remoteBackendPaths.USERS_ME_PATH + '*', (route) => { await get(remoteBackendPaths.USERS_ME_PATH + '*', (route) => {
called('usersMe', {})
if (currentUser == null) { if (currentUser == null) {
return route.fulfill({ status: HTTP_STATUS_NOT_FOUND }) return route.fulfill({ status: HTTP_STATUS_NOT_FOUND })
} else { } else {
@ -963,6 +1036,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}) })
await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => { await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => {
const body: backend.UpdateOrganizationRequestBody = await request.postDataJSON() const body: backend.UpdateOrganizationRequestBody = await request.postDataJSON()
called('updateOrganization', body)
if (body.name === '') { if (body.name === '') {
await route.fulfill({ await route.fulfill({
status: HTTP_STATUS_BAD_REQUEST, status: HTTP_STATUS_BAD_REQUEST,
@ -978,6 +1052,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} }
}) })
await get(remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async (route) => { await get(remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async (route) => {
called('getOrganization', {})
await route.fulfill({ await route.fulfill({
json: currentOrganization, json: currentOrganization,
status: currentOrganization == null ? 404 : 200, status: currentOrganization == null ? 404 : 200,
@ -985,10 +1060,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}) })
await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => { await post(remoteBackendPaths.CREATE_TAG_PATH + '*', (route) => {
const body: backend.CreateTagRequestBody = route.request().postDataJSON() const body: backend.CreateTagRequestBody = route.request().postDataJSON()
called('createTag', body)
return addLabel(body.value, body.color) return addLabel(body.value, body.color)
}) })
await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => { await post(remoteBackendPaths.CREATE_PROJECT_PATH + '*', (_route, request) => {
const body: backend.CreateProjectRequestBody = request.postDataJSON() const body: backend.CreateProjectRequestBody = request.postDataJSON()
called('createProject', body)
const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`) const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`)
const parentId = const parentId =
body.parentDirectoryId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) 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) => { await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => {
const body: backend.CreateDirectoryRequestBody = request.postDataJSON() const body: backend.CreateDirectoryRequestBody = request.postDataJSON()
called('createDirectory', body)
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
const parentId = body.parentId ?? defaultDirectoryId const parentId = body.parentId ?? defaultDirectoryId
@ -1058,8 +1136,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
} }
}) })
await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route) => { await get(remoteBackendPaths.getProjectContentPath(GLOB_PROJECT_ID), (route, request) => {
const content = readFileSync(join(__dirname, './mock/enso-demo.main'), 'utf8') 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({ return route.fulfill({
body: content, 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({ return route.fulfill({
// This is a mock SVG image. Just a square with a black background. // This is a mock SVG image. Just a square with a black background.
body: '/mock/svg.svg', body: '/mock/svg.svg',
@ -1145,6 +1231,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
// deletePermission, // deletePermission,
addUserGroupToUser, addUserGroupToUser,
removeUserGroupFromUser, removeUserGroupFromUser,
trackCalls,
} as const } as const
if (setupAPI) { if (setupAPI) {

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,15 +1,30 @@
/** @file Tests for the asset panel. */ /** @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'
// ================= /** Find an asset panel. */
// === Constants === 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. */ /** An example description for the asset selected in the asset panel. */
const DESCRIPTION = 'foo bar' const DESCRIPTION = 'foo bar'
@ -18,13 +33,8 @@ const USERNAME = 'baz quux'
/** An example owner email for the asset selected in the asset panel. */ /** An example owner email for the asset selected in the asset panel. */
const EMAIL = 'baz.quux@email.com' const EMAIL = 'baz.quux@email.com'
// =============
// === Tests ===
// =============
test('open and close asset panel', ({ page }) => test('open and close asset panel', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.withAssetPanel(async (assetPanel) => { .withAssetPanel(async (assetPanel) => {
await expect(assetPanel).toBeVisible() await expect(assetPanel).toBeVisible()
}) })
@ -34,50 +44,47 @@ test('open and close asset panel', ({ page }) =>
})) }))
test('asset panel contents', ({ page }) => test('asset panel contents', ({ page }) =>
actions mockAllAndLogin({
.mockAllAndLogin({ page,
page, setupAPI: (api) => {
setupAPI: (api) => { const { defaultOrganizationId, defaultUserId } = api
const { defaultOrganizationId, defaultUserId } = api api.addProject({
api.addProject({ description: DESCRIPTION,
description: DESCRIPTION, permissions: [
permissions: [ {
{ permission: PermissionAction.own,
permission: permissions.PermissionAction.own, user: {
user: { organizationId: defaultOrganizationId,
organizationId: defaultOrganizationId, // Using the default ID causes the asset to have a dynamic username.
// Using the default ID causes the asset to have a dynamic username. userId: UserId(defaultUserId + '2'),
userId: backend.UserId(defaultUserId + '2'), name: USERNAME,
name: USERNAME, email: EmailAddress(EMAIL),
email: backend.EmailAddress(EMAIL),
},
}, },
], },
}) ],
}, })
}) },
})
.driveTable.clickRow(0) .driveTable.clickRow(0)
.toggleDescriptionAssetPanel() .toggleDescriptionAssetPanel()
.do(async () => { .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. // `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. // 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 }) => { test('Asset Panel documentation view', ({ page }) =>
return actions mockAllAndLogin({
.mockAllAndLogin({ page,
page, setupAPI: (api) => {
setupAPI: (api) => { api.addProject({})
api.addProject({}) },
}, })
})
.driveTable.clickRow(0) .driveTable.clickRow(0)
.toggleDocsAssetPanel() .toggleDocsAssetPanel()
.withAssetPanel(async (assetPanel) => { .withAssetPanel(async (assetPanel) => {
await expect(assetPanel.getByTestId('asset-panel-tab-panel-docs')).toBeVisible() 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')).toBeVisible()
await expect(assetPanel.getByTestId('asset-docs-content')).toHaveText(/Project Goal/) await expect(assetPanel.getByTestId('asset-docs-content')).toHaveText(/Project Goal/)
}) }))
})

View File

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

View File

@ -1,13 +1,37 @@
/** @file Test the drive view. */ /** @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 const PASS_TIMEOUT = 5_000
test.test('extra columns should stick to right side of assets table', ({ page }) => test('extra columns should stick to right side of assets table', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.withAssetsTable(async (table) => { .withAssetsTable(async (table) => {
await table.evaluate((element) => { await table.evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = 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' }) scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' })
}) })
}) })
.do(async (thePage) => { .withAssetsTable(async (assetsTable, _, thePage) => {
const extraColumns = actions.locateExtraColumns(thePage) const extraColumns = locateExtraColumns(thePage)
const assetsTable = actions.locateAssetsTable(thePage) await expect(async () => {
await test const extraColumnsRight = await extraColumns.evaluate(
.expect(async () => { (element) => element.getBoundingClientRect().right,
const extraColumnsRight = await extraColumns.evaluate( )
(element) => element.getBoundingClientRect().right, const assetsTableRight = await assetsTable.evaluate(
) (element) => element.getBoundingClientRect().right,
const assetsTableRight = await assetsTable.evaluate( )
(element) => element.getBoundingClientRect().right, expect(extraColumnsRight).toEqual(assetsTableRight - 12)
) }).toPass({ timeout: PASS_TIMEOUT })
test.expect(extraColumnsRight).toEqual(assetsTableRight - 12) }))
})
.toPass({ timeout: PASS_TIMEOUT })
}),
)
test.test('extra columns should stick to top of scroll container', async ({ page }) => { test('extra columns should stick to top of scroll container', ({ page }) =>
await actions.mockAllAndLogin({ mockAllAndLogin({
page, page,
setupAPI: (api) => { setupAPI: (api) => {
for (let i = 0; i < 100; i += 1) { 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
} }
}, },
}) })
.withAssetsTable(async (assetsTable) => {
await actions.locateAssetsTable(page).evaluate((element) => { await assetsTable.evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element
while (
scrollableParent != null &&
scrollableParent.scrollHeight <= scrollableParent.clientHeight
) {
scrollableParent = scrollableParent.parentElement
}
scrollableParent?.scrollTo({ top: 999999, behavior: 'instant' })
})
const extraColumns = actions.locateExtraColumns(page)
const assetsTable = actions.locateAssetsTable(page)
await test
.expect(async () => {
const extraColumnsTop = await extraColumns.evaluate(
(element) => element.getBoundingClientRect().top,
)
const assetsTableTop = await assetsTable.evaluate((element) => {
let scrollableParent: HTMLElement | SVGElement | null = element let scrollableParent: HTMLElement | SVGElement | null = element
while ( while (
scrollableParent != null && scrollableParent != null &&
@ -72,29 +75,43 @@ test.test('extra columns should stick to top of scroll container', async ({ page
) { ) {
scrollableParent = scrollableParent.parentElement 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 }) => test('can drop onto root directory dropzone', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.createFolder() .createFolder()
.uploadFile('b', 'testing') .uploadFile('b', 'testing')
.driveTable.doubleClickRow(0) .driveTable.doubleClickRow(0)
.driveTable.withRows(async (rows, nonAssetRows) => { .driveTable.withRows(async (rows, nonAssetRows) => {
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const parentLeft = await getAssetRowLeftPx(rows.nth(0))
await test.expect(nonAssetRows.nth(0)).toHaveText(actions.TEXT.thisFolderIsEmpty) await expect(nonAssetRows.nth(0)).toHaveText(TEXT.thisFolderIsEmpty)
const childLeft = await actions.getAssetRowLeftPx(nonAssetRows.nth(0)) const childLeft = await getAssetRowLeftPx(nonAssetRows.nth(0))
test.expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft) 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) => { .driveTable.withRows(async (rows) => {
const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const firstLeft = await getAssetRowLeftPx(rows.nth(0))
const secondLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const secondLeft = await getAssetRowLeftPx(rows.nth(1))
test.expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft) expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft)
}), }))
)

View File

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

View File

@ -1,30 +1,30 @@
/** @file Test that emails are preserved when navigating between auth pages. */ /** @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' import { VALID_EMAIL, mockAll } from './actions'
// Reset storage state for this file to avoid being authenticated // 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 }) mockAll({ page })
.fillEmail(VALID_EMAIL) .fillEmail(VALID_EMAIL)
.goToPage.register() .goToPage.register()
.withEmailInput(async (emailInput) => { .withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(VALID_EMAIL) await expect(emailInput).toHaveValue(VALID_EMAIL)
}) })
.fillEmail(`2${VALID_EMAIL}`) .fillEmail(`2${VALID_EMAIL}`)
.goToPage.login() .goToPage.login()
.withEmailInput(async (emailInput) => { .withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`2${VALID_EMAIL}`) await expect(emailInput).toHaveValue(`2${VALID_EMAIL}`)
}) })
.fillEmail(`3${VALID_EMAIL}`) .fillEmail(`3${VALID_EMAIL}`)
.goToPage.forgotPassword() .goToPage.forgotPassword()
.withEmailInput(async (emailInput) => { .withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`3${VALID_EMAIL}`) await expect(emailInput).toHaveValue(`3${VALID_EMAIL}`)
}) })
.fillEmail(`4${VALID_EMAIL}`) .fillEmail(`4${VALID_EMAIL}`)
.goToPage.login() .goToPage.login()
.withEmailInput(async (emailInput) => { .withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`4${VALID_EMAIL}`) await expect(emailInput).toHaveValue(`4${VALID_EMAIL}`)
}), }))
)

View File

@ -1,38 +1,64 @@
/** @file Test the drive view. */ /** @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 { COLORS } from 'enso-common/src/services/Backend'
import * as actions from './actions' import { mockAllAndLogin } from './actions'
const LABEL_NAME = 'aaaa' const LABEL_NAME = 'aaaa'
test.test('drive view', ({ page }) => /** Find the context menu. */
actions function locateContextMenu(page: Page) {
.mockAllAndLogin({ // This has no identifying features.
page, return page.getByTestId('context-menu')
setupAPI: (api) => { }
api.addLabel(LABEL_NAME, COLORS[0])
}, /** 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() .driveTable.expectPlaceholderRow()
.withDriveView(async (view) => { .withDriveView(async (view) => {
await view.click({ button: 'right' }) await view.click({ button: 'right' })
}) })
.do(async (thePage) => { .do(async (thePage) => {
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(1) await expect(locateContextMenu(thePage)).toHaveCount(1)
}) })
.press('Escape') .press('Escape')
.do(async (thePage) => { .do(async (thePage) => {
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(0) await expect(locateContextMenu(thePage)).toHaveCount(0)
}) })
.createFolder() .createFolder()
.driveTable.withRows(async (rows, _, thePage) => { .driveTable.withRows(async (rows, _, _context, thePage) => {
await actions.locateLabelsPanelLabels(page, LABEL_NAME).dragTo(rows.nth(0)) await locateLabelsPanelLabels(thePage, LABEL_NAME).dragTo(rows.nth(0))
await actions.locateAssetLabels(thePage).first().click({ button: 'right' }) await locateAssetLabels(thePage).first().click({ button: 'right' })
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(1) await expect(locateContextMenu(thePage)).toHaveCount(1)
}) })
.press('Escape') .press('Escape')
.do(async (thePage) => { .do(async (thePage) => {
await test.expect(actions.locateContextMenu(thePage)).toHaveCount(0) await expect(locateContextMenu(thePage)).toHaveCount(0)
}), }))
)

View File

@ -1,11 +1,30 @@
/** @file Test copying, moving, cutting and pasting. */ /** @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 }) => /** Find the context menu. */
actions function locateContextMenu(page: Page) {
.mockAllAndLogin({ 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] // Assets: [0: Folder 1]
.createFolder() .createFolder()
// Assets: [0: Folder 2, 1: Folder 1] // 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 }>] // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
.contextMenu.paste() .contextMenu.paste()
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3) await expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible() await expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const parentLeft = await getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) const childLeft = await getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}), }))
)
test.test('copy (keyboard)', ({ page }) => test('copy (keyboard)', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
// Assets: [0: Folder 1] // Assets: [0: Folder 1]
.createFolder() .createFolder()
// Assets: [0: Folder 2, 1: Folder 1] // 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 }>] // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
.press('Mod+V') .press('Mod+V')
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3) await expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible() await expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const parentLeft = await getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2)) const childLeft = await getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}), }))
)
test.test('move', ({ page }) => test('move', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
// Assets: [0: Folder 1] // Assets: [0: Folder 1]
.createFolder() .createFolder()
// Assets: [0: Folder 2, 1: Folder 1] // 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 }>] // Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.contextMenu.paste() .contextMenu.paste()
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2) await expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible() await expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) await expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const parentLeft = await getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const childLeft = await getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}), }))
)
test.test('move (drag)', ({ page }) => test('move (drag)', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
// Assets: [0: Folder 1] // Assets: [0: Folder 1]
.createFolder() .createFolder()
// Assets: [0: Folder 2, 1: Folder 1] // 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 }>] // Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.driveTable.dragRowToRow(0, 1) .driveTable.dragRowToRow(0, 1)
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2) await expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible() await expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) await expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const parentLeft = await getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const childLeft = await getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}), }))
)
test.test('move to trash', ({ page }) => test('move to trash', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
// Assets: [0: Folder 1] // Assets: [0: Folder 1]
.createFolder() .createFolder()
// Assets: [0: Folder 2, 1: Folder 1] // 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 // NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still
// held. // held.
.withModPressed((modActions) => modActions.driveTable.clickRow(0).driveTable.clickRow(1)) .withModPressed((modActions) => modActions.driveTable.clickRow(0).driveTable.clickRow(1))
.driveTable.dragRow(0, actions.locateTrashCategory(page)) .driveTable.dragRow(0, locateTrashCategory(page))
.driveTable.expectPlaceholderRow() .driveTable.expectPlaceholderRow()
.goToCategory.trash() .goToCategory.trash()
.driveTable.withRows(async (rows) => { .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 }) => test('move (keyboard)', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
// Assets: [0: Folder 1] // Assets: [0: Folder 1]
.createFolder() .createFolder()
// Assets: [0: Folder 2, 1: Folder 1] // 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 }>] // Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.press('Mod+V') .press('Mod+V')
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2) await expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible() await expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/) await expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0)) const parentLeft = await getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1)) const childLeft = await getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
}), }))
)
test.test('cut (keyboard)', async ({ page }) => test('cut (keyboard)', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.createFolder() .createFolder()
.driveTable.clickRow(0) .driveTable.clickRow(0)
.press('Mod+X') .press('Mod+X')
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
// This action is not a builtin `expect` action, so it needs to be manually retried. // This action is not a builtin `expect` action, so it needs to be manually retried.
await test await expect(async () => {
.expect(async () => { expect(
test await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity)),
.expect(await rows.nth(0).evaluate((el) => Number(getComputedStyle(el).opacity))) ).toBeLessThan(1)
.toBeLessThan(1) }).toPass()
}) }))
.toPass()
}),
)
test.test('duplicate', ({ page }) => test('duplicate', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
// Assets: [0: New Project 1] // Assets: [0: New Project 1]
.newEmptyProject() .newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
@ -163,16 +166,14 @@ test.test('duplicate', ({ page }) =>
.contextMenu.duplicate() .contextMenu.duplicate()
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
// Assets: [0: New Project 1, 1: New Project 1 (copy)] // Assets: [0: New Project 1, 1: New Project 1 (copy)]
await test.expect(rows).toHaveCount(2) await expect(rows).toHaveCount(2)
await test.expect(actions.locateContextMenu(page)).not.toBeVisible() await expect(locateContextMenu(page)).not.toBeVisible()
await test.expect(rows.nth(1)).toBeVisible() await expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}), }))
)
test.test('duplicate (keyboard)', ({ page }) => test('duplicate (keyboard)', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
// Assets: [0: New Project 1] // Assets: [0: New Project 1]
.newEmptyProject() .newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
@ -183,8 +184,7 @@ test.test('duplicate (keyboard)', ({ page }) =>
.press('Mod+D') .press('Mod+D')
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
// Assets: [0: New Project 1 (copy), 1: New Project 1] // Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2) await expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible() await expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/) await expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}), }))
)

View File

@ -1,11 +1,7 @@
/** @file Test copying, moving, cutting and pasting. */ /** @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' import { mockAllAndLogin } from './actions'
// =================
// === Constants ===
// =================
/** The name of the uploaded file. */ /** The name of the uploaded file. */
const FILE_NAME = 'foo.txt' const FILE_NAME = 'foo.txt'
@ -16,50 +12,45 @@ const SECRET_NAME = 'a secret name'
/** The value of the created secret. */ /** The value of the created secret. */
const SECRET_VALUE = 'a secret value' const SECRET_VALUE = 'a secret value'
// ============= /** Find an editor container. */
// === Tests === // 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 }) => test('create folder', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.createFolder() .createFolder()
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1) await expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible() await expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/) await expect(rows.nth(0)).toHaveText(/^New Folder 1/)
}), }))
)
test.test('create project', ({ page }) => test('create project', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.newEmptyProject() .newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled. // 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() // .goToPage.drive()
.driveTable.withRows((rows) => test.expect(rows).toHaveCount(1)), .driveTable.withRows((rows) => expect(rows).toHaveCount(1)))
)
test.test('upload file', ({ page }) => test('upload file', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.uploadFile(FILE_NAME, FILE_CONTENTS) .uploadFile(FILE_NAME, FILE_CONTENTS)
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1) await expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible() await expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) await expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME))
}), }))
)
test.test('create secret', ({ page }) => test('create secret', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.createSecret(SECRET_NAME, SECRET_VALUE) .createSecret(SECRET_NAME, SECRET_VALUE)
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1) await expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toBeVisible() await expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) await expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME))
}), }))
)

View File

@ -1,15 +1,13 @@
/** @file Test the user settings tab. */ /** @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' const DATA_LINK_NAME = 'a data link'
test.test('data link editor', ({ page }) => test('data link editor', ({ page }) =>
actions mockAllAndLogin({ page })
.mockAllAndLogin({ page })
.openDataLinkModal() .openDataLinkModal()
.withNameInput(async (input) => { .withNameInput(async (input) => {
await input.fill(DATA_LINK_NAME) await input.fill(DATA_LINK_NAME)
}), }))
)

View File

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

View File

@ -1,37 +1,49 @@
/** @file Test the drive view. */ /** @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 }) => /** Find an editor container. */
actions // eslint-disable-next-line @typescript-eslint/no-unused-vars
.mockAllAndLogin({ page }) 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) => { .withDriveView(async (view) => {
await test.expect(view).toBeVisible() await expect(view).toBeVisible()
}) })
.driveTable.expectPlaceholderRow() .driveTable.expectPlaceholderRow()
.newEmptyProject() .newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled. // Uncomment once cloud execution in the browser is re-enabled.
// .do(async () => { // .do(async () => {
// await test.expect(actions.locateEditor(page)).toBeAttached() // await expect(locateEditor(page)).toBeAttached()
// }) // })
// .goToPage.drive() // .goToPage.drive()
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1) await expect(rows).toHaveCount(1)
}) })
.do(async () => { .withAssetsTable(async (assetsTable) => {
await test.expect(actions.locateAssetsTable(page)).toBeVisible() await expect(assetsTable).toBeVisible()
}) })
.newEmptyProject() .newEmptyProject()
// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled. // Uncomment once cloud execution in the browser is re-enabled.
// .do(async () => { // .do(async () => {
// await test.expect(actions.locateEditor(page)).toBeAttached() // await expect(locateEditor(page)).toBeAttached()
// }) // })
// .goToPage.drive() // .goToPage.drive()
.driveTable.withRows(async (rows) => { .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 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled. // 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 // // user that project creation may take a while. Previously opened projects are stopped when the
// // new project is created. // // new project is created.
// .driveTable.withRows(async (rows) => { // .driveTable.withRows(async (rows) => {
// await actions.locateStopProjectButton(rows.nth(1)).click() // await locateStopProjectButton(rows.nth(1)).click()
// }) // })
// Project context menu // Project context menu
.driveTable.rightClickRow(0) .driveTable.rightClickRow(0)
.contextMenu.moveNonFolderToTrash() .contextMenu.moveNonFolderToTrash()
.driveTable.withRows(async (rows) => { .driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1) await expect(rows).toHaveCount(1)
}), }))
)

View File

@ -1,129 +1,151 @@
/** @file Test copying, moving, cutting and pasting. */ /** @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 }) => { const NEW_NAME = 'foo bar baz'
await actions.mockAllAndLogin({ page }) const NEW_NAME_2 = 'foo bar baz quux'
const assetRows = actions.locateAssetRows(page)
const row = assetRows.nth(0)
const newName = 'foo bar baz'
await actions.locateNewFolderIcon(page).click() /** Find the context menu. */
await actions.locateAssetRowName(row).click() function locateContextMenu(page: Page) {
await actions.locateAssetRowName(row).click() // This has no identifying features.
await actions.locateAssetRowName(row).fill(newName) return page.getByTestId('context-menu')
await actions.locateEditingTick(row).click() }
await test.expect(row).toHaveText(new RegExp('^' + newName))
/** 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 }) => { test('change to blank name (double click)', ({ page }) =>
await actions.mockAllAndLogin({ mockAllAndLogin({ page })
page, .createFolder()
setupAPI: (api) => { .driveTable.withRows(async (rows, _, { api }) => {
api.addAsset(api.createDirectory({ title: 'foo' })) 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) test('change to blank name (keyboard)', ({ page }) =>
const row = assetRows.nth(0) mockAllAndLogin({ page })
const newName = 'foo bar baz' .createFolder()
.driveTable.withRows(async (rows) => {
await actions.locateAssetRowName(row).click({ button: 'right' }) await locateAssetRowName(rows.nth(0)).click()
await actions })
.locateContextMenu(page) .press('Mod+R')
.getByText(/Rename/) .driveTable.withRows(async (rows, _, { api }) => {
.click() const row = rows.nth(0)
const nameEl = locateAssetRowName(row)
const input = page.getByTestId('asset-row-name') const oldName = (await nameEl.textContent()) ?? ''
await nameEl.fill('')
await test.expect(input).toBeVisible() const calls = api.trackCalls()
await test.expect(input).toBeFocused() await nameEl.press('Enter')
await expect(row).toHaveText(new RegExp('^' + oldName))
await input.fill(newName) expect(calls.updateDirectory).toMatchObject([])
}))
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))
})

View File

@ -1,80 +1,90 @@
/** @file Test dragging of labels. */ /** @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. */ /** 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 }) await assetRow.click({ position: ASSET_ROW_SAFE_POSITION })
} }
test.test('drag labels onto single row', async ({ page }) => { /** Find labels in the "Labels" column of the assets table. */
const label = 'aaaa' function locateAssetLabels(page: Locator) {
return actions return page.getByTestId('asset-label')
.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)
await test.expect(labelEl).toBeVisible() /** Find a labels panel. */
await labelEl.dragTo(assetRows.nth(1)) function locateLabelsPanel(page: Page) {
await test // This has no identifying features.
.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)) return page.getByTestId('labels')
.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()
})
})
test.test('drag labels onto multiple rows', async ({ page }) => { /** Find all labels in the labels panel. */
const label = 'aaaa' function locateLabelsPanelLabels(page: Page, name?: string) {
await actions.mockAllAndLogin({ 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, page,
setupAPI: (api) => { setupAPI: (api) => {
api.addLabel(label, backend.COLORS[0]) api.addLabel(LABEL, COLORS[0])
api.addLabel('bbbb', backend.COLORS[1]) api.addLabel('bbbb', COLORS[1])
api.addLabel('cccc', backend.COLORS[2]) api.addLabel('cccc', COLORS[2])
api.addLabel('dddd', backend.COLORS[3]) 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.addDirectory({ title: 'foo' })
api.addSecret({ title: 'bar' }) api.addSecret({ title: 'bar' })
api.addFile({ title: 'baz' }) api.addFile({ title: 'baz' })
api.addSecret({ title: 'quux' }) api.addSecret({ title: 'quux' })
}, },
}) })
.withModPressed((self) =>
const assetRows = actions.locateAssetRows(page) self.driveTable.withRows(async (rows, _, _context, page) => {
const labelEl = actions.locateLabelsPanelLabels(page, label) const labelEl = locateLabelsPanelLabels(page, LABEL)
await expect(rows).toHaveCount(4)
await page.keyboard.down(await actions.modModifier(page)) await clickAssetRow(rows.nth(0))
await test.expect(assetRows).toHaveCount(4) await clickAssetRow(rows.nth(2))
await clickAssetRow(assetRows.nth(0)) await expect(labelEl).toBeVisible()
await clickAssetRow(assetRows.nth(2)) await labelEl.dragTo(rows.nth(2))
await test.expect(labelEl).toBeVisible() }),
await labelEl.dragTo(assetRows.nth(2)) )
await page.keyboard.up(await actions.modModifier(page)) .driveTable.withRows(async (rows) => {
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible() await expect(locateAssetLabels(rows.nth(0)).getByText(LABEL)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible() await expect(locateAssetLabels(rows.nth(1)).getByText(LABEL)).not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible() await expect(locateAssetLabels(rows.nth(2)).getByText(LABEL)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible() await expect(locateAssetLabels(rows.nth(3)).getByText(LABEL)).not.toBeVisible()
}) }))

View File

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

View File

@ -1,26 +1,36 @@
/** @file Test the login flow. */ /** @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'
// ============= /** Find a "login" button.on the current locator. */
// === Tests === 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 // 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 }) => test('login and logout', ({ page }) =>
actions mockAll({ page })
.mockAllAndLogin({ page }) .login()
.expectStartModal()
.close()
.withDriveView(async (driveView) => {
await expect(driveView).toBeVisible()
})
.do(async (thePage) => { .do(async (thePage) => {
await test.expect(actions.locateDriveView(thePage)).toBeVisible() await expect(locateLoginButton(thePage)).not.toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
}) })
.openUserMenu() .openUserMenu()
.userMenu.logout() .userMenu.logout()
.do(async (thePage) => { .do(async (thePage) => {
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() await expect(locateDriveView(thePage)).not.toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).toBeVisible() await expect(locateLoginButton(thePage)).toBeVisible()
}), }))
)

View File

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

View File

@ -1,106 +1,101 @@
/** @file Test the organization settings tab. */ /** @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 { Plan } from 'enso-common/src/services/Backend'
import * as actions from './actions' import { mockAllAndLogin } from './actions'
test.test('organization settings', async ({ page }) => { const NEW_NAME = 'another organization-name'
const api = await actions.mockAllAndLoginAndExposeAPI({ 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, page,
setupAPI: (theApi) => { setupAPI: (api) => {
theApi.setPlan(Plan.team) api.setPlan(Plan.team)
api.setCurrentOrganization(api.defaultOrganization)
}, },
}) })
const localActions = actions.settings.organization .step('Verify initial organization state', (_, { api }) => {
expect(api.defaultUser.isOrganizationAdmin).toBe(true)
// Setup expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName)
api.setCurrentOrganization(api.defaultOrganization) expect(api.currentOrganization()?.email).toBe(null)
await test.test.step('Initial state', () => { expect(api.currentOrganization()?.picture).toBe(null)
test.expect(api.currentOrganization()?.name).toBe(api.defaultOrganizationName) expect(api.currentOrganization()?.website).toBe(null)
test.expect(api.currentOrganization()?.email).toBe(null) expect(api.currentOrganization()?.address).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)
}) })
.toPass() .goToPage.settings()
}) .goToSettingsTab.organization()
.organizationForm()
.fillName(NEW_NAME)
.do((_, context) => {
context.calls = context.api.trackCalls()
})
.save()
.step('Set organization name', (_, { api, calls }) => {
expect(api.currentOrganization()?.name).toBe(NEW_NAME)
expect(api.currentUser()?.name).not.toBe(NEW_NAME)
expect(calls.updateOrganization).toMatchObject([{ name: NEW_NAME }])
})
.organizationForm()
.fillName('')
.do((_, context) => {
context.calls = context.api.trackCalls()
})
.save()
.step('Unsetting organization name should fail', (_, { api, calls }) => {
expect(api.currentOrganization()?.name).toBe(NEW_NAME)
expect(calls.updateOrganization).toMatchObject([{ name: '' }])
})
.organizationForm()
.cancel()
.organizationForm()
.fillEmail(INVALID_EMAIL)
.save()
.step('Setting invalid email should fail', (_, { api }) => {
expect(api.currentOrganization()?.email).toBe('')
})
.organizationForm()
.fillEmail(NEW_EMAIL)
.save()
.step('Set email', (_, { api }) => {
expect(api.currentOrganization()?.email).toBe(NEW_EMAIL)
})
.organizationForm()
.fillWebsite(NEW_WEBSITE)
.save()
// NOTE: It is not yet possible to unset the website or the location.
.step('Set website', async (_, { api }) => {
expect(api.currentOrganization()?.website).toBe(NEW_WEBSITE)
})
.organizationForm()
.fillLocation(NEW_LOCATION)
.save()
.step('Set website', async (_, { api }) => {
expect(api.currentOrganization()?.address).toBe(NEW_LOCATION)
}))
test('upload organization profile picture', ({ page }) =>
mockAllAndLogin({
page,
setupAPI: (theApi) => {
theApi.setPlan(Plan.team)
},
})
.goToPage.settings()
.goToSettingsTab.organization()
.uploadProfilePicture(
PROFILE_PICTURE_FILENAME,
PROFILE_PICTURE_CONTENT,
PROFILE_PICTURE_MIMETYPE,
)
.step('Profile picture should be updated', async (_, { api }) => {
await expect(() => {
expect(api.currentOrganizationProfilePicture()).toEqual(PROFILE_PICTURE_CONTENT)
}).toPass()
}))

View File

@ -1,27 +1,37 @@
/** @file Test the login flow. */ /** @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 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled. // Unskip once cloud execution in the browser is re-enabled.
// test.test('page switcher', ({ page }) => test.skip('page switcher', ({ page }) =>
// actions mockAllAndLogin({ page })
// .mockAllAndLogin({ page }) // Create a new project so that the editor page can be switched to.
// // Create a new project so that the editor page can be switched to. .newEmptyProjectTest()
// .newEmptyProject() .do(async (thePage) => {
// .do(async (thePage) => { await expect(locateDriveView(thePage)).not.toBeVisible()
// await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() await expect(locateEditor(thePage)).toBeVisible()
// await test.expect(actions.locateEditor(thePage)).toBeVisible() })
// }) .goToPage.drive()
// .goToPage.drive() .do(async (thePage) => {
// .do(async (thePage) => { await expect(locateDriveView(thePage)).toBeVisible()
// await test.expect(actions.locateDriveView(thePage)).toBeVisible() await expect(locateEditor(thePage)).not.toBeVisible()
// await test.expect(actions.locateEditor(thePage)).not.toBeVisible() })
// }) .goToPage.editor()
// .goToPage.editor() .do(async (thePage) => {
// .do(async (thePage) => { await expect(locateDriveView(thePage)).not.toBeVisible()
// await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() await expect(locateEditor(thePage)).toBeVisible()
// await test.expect(actions.locateEditor(thePage)).toBeVisible() }))
// }),
// )

View File

@ -1,54 +1,49 @@
/** @file Test the setup flow. */ /** @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 { 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 // 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 }) => test('setup (free plan)', ({ page }) =>
actions mockAll({
.mockAll({ page,
page, setupAPI: (api) => {
setupAPI: (api) => { api.setCurrentUser(null)
api.setCurrentUser(null) },
}, })
})
.loginAsNewUser() .loginAsNewUser()
.setUsername('test user') .setUsername('test user')
.stayOnFreePlan() .stayOnFreePlan()
.goToPage.drive() .goToPage.drive()
.withDriveView(async (drive) => { .withDriveView(async (drive) => {
await test.expect(drive).toBeVisible() await expect(drive).toBeVisible()
}), }))
)
test.test('setup (solo plan)', ({ page }) => test('setup (solo plan)', ({ page }) =>
actions mockAll({
.mockAll({ page,
page, setupAPI: (api) => {
setupAPI: (api) => { api.setCurrentUser(null)
api.setCurrentUser(null) },
}, })
})
.loginAsNewUser() .loginAsNewUser()
.setUsername('test user') .setUsername('test user')
.selectSoloPlan() .selectSoloPlan()
.goToPage.drive() .goToPage.drive()
.withDriveView(async (drive) => { .withDriveView(async (drive) => {
await test.expect(drive).toBeVisible() await expect(drive).toBeVisible()
}), }))
)
test.test('setup (team plan, skipping invites)', ({ page }) => test('setup (team plan, skipping invites)', ({ page }) =>
actions mockAll({
.mockAll({ page,
page, setupAPI: (api) => {
setupAPI: (api) => { api.setCurrentUser(null)
api.setCurrentUser(null) },
}, })
})
.loginAsNewUser() .loginAsNewUser()
.setUsername('test user') .setUsername('test user')
.selectTeamPlan(Plan.team) .selectTeamPlan(Plan.team)
@ -57,18 +52,16 @@ test.test('setup (team plan, skipping invites)', ({ page }) =>
.setTeamName('test team') .setTeamName('test team')
.goToPage.drive() .goToPage.drive()
.withDriveView(async (drive) => { .withDriveView(async (drive) => {
await test.expect(drive).toBeVisible() await expect(drive).toBeVisible()
}), }))
)
test.test('setup (team plan)', ({ page }) => test('setup (team plan)', ({ page }) =>
actions mockAll({
.mockAll({ page,
page, setupAPI: (api) => {
setupAPI: (api) => { api.setCurrentUser(null)
api.setCurrentUser(null) },
}, })
})
.loginAsNewUser() .loginAsNewUser()
.setUsername('test user') .setUsername('test user')
.selectTeamPlan(Plan.team, 10) .selectTeamPlan(Plan.team, 10)
@ -77,8 +70,7 @@ test.test('setup (team plan)', ({ page }) =>
.setTeamName('test team') .setTeamName('test team')
.goToPage.drive() .goToPage.drive()
.withDriveView(async (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. // No test for enterprise plan as the plan must be set to enterprise manually.

View File

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

View File

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

View File

@ -1,17 +1,34 @@
/** @file Test the "change password" modal. */ /** @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 // FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615
// Uncomment once cloud execution in the browser is re-enabled. // Unskip once cloud execution in the browser is re-enabled.
// test.test('create project from template', ({ page }) =>
// actions test.skip('create project from template', ({ page }) =>
// .mockAllAndLogin({ page }) mockAllAndLogin({ page })
// .openStartModal() .expectStartModal()
// .createProjectFromTemplate(0) .createProjectFromTemplate(0)
// .do(async (thePage) => { .do(async (thePage) => {
// await test.expect(actions.locateEditor(thePage)).toBeAttached() await expect(locateEditor(thePage)).toBeAttached()
// await test.expect(actions.locateSamples(page).first()).not.toBeVisible() await expect(locateSamples(page).first()).not.toBeVisible()
// }), }))
// )

View File

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

View File

@ -52,7 +52,10 @@ export default function OrganizationProfilePictureInput(
return ( return (
<> <>
<FocusRing within> <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 <img
src={organization?.picture ?? DefaultUserIcon} src={organization?.picture ?? DefaultUserIcon}
className="pointer-events-none h-full w-full" className="pointer-events-none h-full w-full"

View File

@ -48,7 +48,10 @@ export default function ProfilePictureInput(props: ProfilePictureInputProps) {
return ( return (
<> <>
<FocusRing within> <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 <img
src={user?.profilePicture ?? DefaultUserIcon} src={user?.profilePicture ?? DefaultUserIcon}
className="pointer-events-none h-full w-full" className="pointer-events-none h-full w-full"