enso/app/gui/integration-test/dashboard/actions/BaseActions.ts
somebody1234 b83c5a15eb
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
2024-12-12 09:49:58 +00:00

190 lines
6.7 KiB
TypeScript

/** @file The base class from which all `Actions` classes are derived. */
import { expect, test, type Locator, type Page } from '@playwright/test'
import type { AutocompleteKeybind } from '#/utilities/inputBindings'
/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */
async function modModifier(page: Page) {
let userAgent = ''
await test.step('Detect browser OS', async () => {
userAgent = await page.evaluate(() => navigator.userAgent)
})
return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control'
}
/** A callback that performs actions on a {@link Page}. */
export interface PageCallback<Context> {
(input: Page, context: Context): Promise<void> | void
}
/** A callback that performs actions on a {@link Locator}. */
export interface LocatorCallback<Context> {
(input: Locator, context: Context): Promise<void> | void
}
export interface BaseActionsClass<Context, Args extends readonly unknown[] = []> {
// The return type should be `InstanceType<this>`, but that results in a circular reference error.
new (page: Page, context: Context, promise: Promise<void>, ...args: Args): any
}
/**
* The base class from which all `Actions` classes are derived.
* It contains method common to all `Actions` subclasses.
* This is a [`thenable`], so it can be used as if it was a {@link Promise}.
*
* [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables
*/
export default class BaseActions<Context> implements Promise<void> {
/** Create a {@link BaseActions}. */
constructor(
protected readonly page: Page,
protected readonly context: Context,
private readonly promise = Promise.resolve(),
) {}
/**
* Get the string name of the class of this instance. Required for this class to implement
* {@link Promise}.
*/
get [Symbol.toStringTag]() {
return this.constructor.name
}
/**
* Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms.
*/
static press(page: Page, keyOrShortcut: string): Promise<void> {
return test.step(`Press '${keyOrShortcut}'`, async () => {
if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) {
let userAgent = ''
await test.step('Detect browser OS', async () => {
userAgent = await page.evaluate(() => navigator.userAgent)
})
const isMacOS = /\bMac OS\b/i.test(userAgent)
const ctrlKey = isMacOS ? 'Meta' : 'Control'
const deleteKey = isMacOS ? 'Backspace' : 'Delete'
const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey)
await page.keyboard.press(shortcut)
} else {
await page.keyboard.press(keyOrShortcut)
}
})
}
/** Proxies the `then` method of the internal {@link Promise}. */
async then<T, E>(
onfulfilled?: (() => PromiseLike<T> | T) | null | undefined,
onrejected?: ((reason: unknown) => E | PromiseLike<E>) | null | undefined,
) {
return await this.promise.then(onfulfilled, onrejected)
}
/**
* Proxies the `catch` method of the internal {@link Promise}.
* This method is not required for this to be a `thenable`, but it is still useful
* to treat this class as a {@link Promise}.
*/
async catch<T>(onrejected?: ((reason: unknown) => PromiseLike<T> | T) | null | undefined) {
return await this.promise.catch(onrejected)
}
/**
* Proxies the `catch` method of the internal {@link Promise}.
* This method is not required for this to be a `thenable`, but it is still useful
* to treat this class as a {@link Promise}.
*/
async finally(onfinally?: (() => void) | null | undefined): Promise<void> {
await this.promise.finally(onfinally)
}
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
into<
T extends new (
page: Page,
context: Context,
promise: Promise<void>,
...args: Args
) => InstanceType<T>,
Args extends readonly unknown[],
>(clazz: T, ...args: Args): InstanceType<T> {
return new clazz(this.page, this.context, this.promise, ...args)
}
/**
* Perform an action. This should generally be avoided in favor of using
* specific methods; this is more or less an escape hatch used ONLY when the methods do not
* support desired functionality.
*/
do(callback: PageCallback<Context>): this {
// @ts-expect-error This is SAFE, but only when the constructor of this class has the exact
// same parameters as `BaseActions`.
return new this.constructor(
this.page,
this.context,
this.then(() => callback(this.page, this.context)),
)
}
/** Perform an action. */
step(name: string, callback: PageCallback<Context>) {
return this.do(() => test.step(name, () => callback(this.page, this.context)))
}
/**
* Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms.
*/
press<Key extends string>(keyOrShortcut: AutocompleteKeybind<Key>) {
return this.do((page) => BaseActions.press(page, keyOrShortcut))
}
/** Perform actions until a predicate passes. */
retry(
callback: (actions: this) => this,
predicate: (page: Page) => Promise<boolean>,
options: { retries?: number; delay?: number } = {},
) {
const { retries = 3, delay = 1_000 } = options
return this.step('Perform actions with retries', async (thePage) => {
for (let i = 0; i < retries; i += 1) {
await callback(this)
if (await predicate(thePage)) {
return
}
await thePage.waitForTimeout(delay)
}
throw new Error('This action did not succeed.')
})
}
/** Perform actions with the "Mod" modifier key pressed. */
withModPressed<R extends BaseActions<Context>>(callback: (actions: this) => R) {
return callback(
this.step('Press "Mod"', async (page) => {
await page.keyboard.down(await modModifier(page))
}),
).step('Release "Mod"', async (page) => {
await page.keyboard.up(await modModifier(page))
})
}
/**
* Expect an input to have an error (or no error if the expected value is `null`).
* If the expected value is `undefined`, the assertion is skipped.
*/
expectInputError(testId: string, description: string, expected: string | null | undefined) {
if (expected === undefined) {
return this
} else if (expected != null) {
return this.step(`Expect ${description} error to be '${expected}'`, async (page) => {
await expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected)
})
} else {
return this.step(`Expect no ${description} error`, async (page) => {
await expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible()
})
}
}
}