Skip to content

Commit

Permalink
Clean up integration tests and add listeners for backend calls (#11847)
Browse files Browse the repository at this point in the history
- Close enso-org/cloud-v2#1604
- Add ability to track backend calls
- Remove inconsistent integration test code
- Add skeleton classes for settings pages

# Important Notes
None
  • Loading branch information
somebody1234 authored Dec 12, 2024
1 parent 2964457 commit b83c5a1
Show file tree
Hide file tree
Showing 63 changed files with 2,479 additions and 2,357 deletions.
52 changes: 24 additions & 28 deletions app/gui/integration-test/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,51 +3,47 @@
## Running tests

Execute all commands from the parent directory.
Note that all options can be used in any combination.

```sh
# Run tests normally
pnpm run test:integration
pnpm playwright test
# Open UI to run tests
pnpm run test:integration:debug
pnpm playwright test --ui
# Run tests in a specific file only
pnpm run test:integration -- integration-test/file-name-here.spec.ts
pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts
pnpm playwright test integration-test/dashboard/file-name-here.spec.ts
# Compile the entire app before running the tests.
# DOES NOT hot reload the tests.
# Prefer not using this when you are trying to fix a test;
# prefer using this when you just want to know which tests are failing (if any).
PROD=1 pnpm run test:integration
PROD=1 pnpm run test:integration:debug
PROD=1 pnpm run test:integration -- integration-test/file-name-here.spec.ts
PROD=1 pnpm run test:integration:debug -- integration-test/file-name-here.spec.ts
PROD=true pnpm playwright test
```

## Getting started

```ts
test.test('test name here', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
// ONLY chain methods from `pageActions`.
// Using methods not in `pageActions` is UNDEFINED BEHAVIOR.
// If it is absolutely necessary though, please remember to `await` the method chain.
// Note that the `async`/`await` pair is REQUIRED, as `Actions` subclasses are `PromiseLike`s,
// not `Promise`s, which causes Playwright to output a type error.
async ({ pageActions }) => await pageActions.goTo.drive(),
),
)
// ONLY chain methods from `pageActions`.
// Using methods not in `pageActions` is UNDEFINED BEHAVIOR.
// If it is absolutely necessary though, please remember to `await` the method chain.
test('test name here', ({ page }) => mockAllAndLogin({ page }).goToPage.drive())
```

### Perform arbitrary actions (e.g. actions on the API)

```ts
test.test('test name here', ({ page }) =>
actions.mockAllAndLogin({ page }).then(
async ({ pageActions, api }) =>
await pageActions.do(() => {
api.foo()
api.bar()
test.expect(api.baz()?.quux).toEqual('bar')
}),
),
)
test('test name here', ({ page }) =>
mockAllAndLogin({ page }).do((_page, { api }) => {
api.foo()
api.bar()
expect(api.baz()?.quux).toEqual('bar')
}))
```

### Writing new classes extending `BaseActions`

- Make sure that every method returns either the class itself (`this`) or `.into(AnotherActionsClass<Context>)`.
- Avoid constructing `new AnotherActionsClass()` - instead prefer `.into(AnotherActionsClass<Context>)` and optionally `.into(ThisClass<Context>)` if required.
- Never construct an `ActionsClass`
- In some rare exceptions, it is fine as long as you `await` the `PageActions` class - for example in `index.ts` there is `await new StartModalActions().close()`.
- Methods for locators are fine, but it is not recommended to expose them as it makes it easy to accidentally - i.e. it is fine as long as they are `private`.
- In general, avoid exposing any method that returns a `Promise` rather than a `PageActions`.
83 changes: 45 additions & 38 deletions app/gui/integration-test/dashboard/actions/BaseActions.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
/** @file The base class from which all `Actions` classes are derived. */
import * as test from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'

import type * as inputBindings from '#/utilities/inputBindings'
import type { AutocompleteKeybind } from '#/utilities/inputBindings'

import { modModifier } from '.'

// ====================
// === PageCallback ===
// ====================

/** A callback that performs actions on a {@link test.Page}. */
export interface PageCallback {
(input: test.Page): Promise<void> | void
/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */
async function modModifier(page: Page) {
let userAgent = ''
await test.step('Detect browser OS', async () => {
userAgent = await page.evaluate(() => navigator.userAgent)
})
return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control'
}

// =======================
// === LocatorCallback ===
// =======================
/** A callback that performs actions on a {@link Page}. */
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}. */
export interface LocatorCallback<Context> {
(input: Locator, context: Context): Promise<void> | void
}

// ===================
// === BaseActions ===
// ===================
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.
Expand All @@ -34,10 +34,11 @@ export interface LocatorCallback {
*
* [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables
*/
export default class BaseActions implements Promise<void> {
export default class BaseActions<Context> implements Promise<void> {
/** Create a {@link BaseActions}. */
constructor(
protected readonly page: test.Page,
protected readonly page: Page,
protected readonly context: Context,
private readonly promise = Promise.resolve(),
) {}

Expand All @@ -53,11 +54,11 @@ export default class BaseActions implements Promise<void> {
* Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms.
*/
static press(page: test.Page, keyOrShortcut: string): Promise<void> {
return test.test.step(`Press '${keyOrShortcut}'`, async () => {
static press(page: Page, keyOrShortcut: string): Promise<void> {
return test.step(`Press '${keyOrShortcut}'`, async () => {
if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) {
let userAgent = ''
await test.test.step('Detect browser OS', async () => {
await test.step('Detect browser OS', async () => {
userAgent = await page.evaluate(() => navigator.userAgent)
})
const isMacOS = /\bMac OS\b/i.test(userAgent)
Expand Down Expand Up @@ -99,43 +100,49 @@ export default class BaseActions implements Promise<void> {

/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
into<
T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>,
T extends new (
page: Page,
context: Context,
promise: Promise<void>,
...args: Args
) => InstanceType<T>,
Args extends readonly unknown[],
>(clazz: T, ...args: Args): InstanceType<T> {
return new clazz(this.page, this.promise, ...args)
return new clazz(this.page, this.context, this.promise, ...args)
}

/**
* Perform an action on the current page. This should generally be avoided in favor of using
* Perform an action. This should generally be avoided in favor of using
* specific methods; this is more or less an escape hatch used ONLY when the methods do not
* support desired functionality.
*/
do(callback: PageCallback): this {
do(callback: PageCallback<Context>): this {
// @ts-expect-error This is SAFE, but only when the constructor of this class has the exact
// same parameters as `BaseActions`.
return new this.constructor(
this.page,
this.then(() => callback(this.page)),
this.context,
this.then(() => callback(this.page, this.context)),
)
}

/** Perform an action on the current page. */
step(name: string, callback: PageCallback) {
return this.do(() => test.test.step(name, () => callback(this.page)))
/** Perform an action. */
step(name: string, callback: PageCallback<Context>) {
return this.do(() => test.step(name, () => callback(this.page, this.context)))
}

/**
* Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms.
*/
press<Key extends string>(keyOrShortcut: inputBindings.AutocompleteKeybind<Key>) {
press<Key extends string>(keyOrShortcut: AutocompleteKeybind<Key>) {
return this.do((page) => BaseActions.press(page, keyOrShortcut))
}

/** Perform actions until a predicate passes. */
retry(
callback: (actions: this) => this,
predicate: (page: test.Page) => Promise<boolean>,
predicate: (page: Page) => Promise<boolean>,
options: { retries?: number; delay?: number } = {},
) {
const { retries = 3, delay = 1_000 } = options
Expand All @@ -152,7 +159,7 @@ export default class BaseActions implements Promise<void> {
}

/** Perform actions with the "Mod" modifier key pressed. */
withModPressed<R extends BaseActions>(callback: (actions: this) => R) {
withModPressed<R extends BaseActions<Context>>(callback: (actions: this) => R) {
return callback(
this.step('Press "Mod"', async (page) => {
await page.keyboard.down(await modModifier(page))
Expand All @@ -171,11 +178,11 @@ export default class BaseActions implements Promise<void> {
return this
} else if (expected != null) {
return this.step(`Expect ${description} error to be '${expected}'`, async (page) => {
await test.expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected)
await expect(page.getByTestId(testId).getByTestId('error')).toHaveText(expected)
})
} else {
return this.step(`Expect no ${description} error`, async (page) => {
await test.expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible()
await expect(page.getByTestId(testId).getByTestId('error')).not.toBeVisible()
})
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading

0 comments on commit b83c5a1

Please sign in to comment.