Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change runJS parameter passing and return value #535

Merged
merged 8 commits into from
Aug 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions .changeset/pretty-coats-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
'pleasantest': major
---

Values exported from `runJS` are now available in Node.

For example:

```js
test(
'receiving exported values from runJS',
withBrowser(async ({ utils }) => {
// Each export is available in the returned object.
// Each export is wrapped in a JSHandle, meaning that it points to an in-browser object
const { focusTarget, favoriteNumber } = await utils.runJS(`
export const focusTarget = document.activeElement
export const favoriteNumber = 20
`);

// Serializable JSHandles can be unwrapped using JSONValue:
console.log(await favoriteNumber.jsonValue()); // Logs "20"

// A JSHandle<Element>, or ElementHandle is not serializable
// But we can pass it back into the browser to use it (it will be unwrapped in the browser):

await utils.runJS(
`
// The import.meta.pleasantestArgs context object receives the parameters passed in below
const [focusTarget] = import.meta.pleasantestArgs;
console.log(focusTarget) // Logs the element in the browser
`,
// Passing the JSHandle in here passes it into the browser (unwrapped) in import.meta.pleasantestArgs
[focusTarget],
);
}),
);
```

We've also introduced a utility function to make it easier to call `JSHandle`s that point to functions, `makeCallableJSHandle`. This function takes a `JSHandle<Function>` and returns a node function that calls the corresponding browser function, passing along the parameters, and returning the return value wrapped in `Promise<JSHandle<T>>`:

```js
// new import:
import { makeCallableJSHandle } from 'pleasantest';

test(
'calling functions with makeCallableJSHandle',
withBrowser(async ({ utils }) => {
const { displayFavoriteNumber } = await utils.runJS(`
export const displayFavoriteNumber = (number) => {
document.querySelector('.output').innerHTML = "Favorite number is: " + number
}
`);

// displayFavoriteNumber is a JSHandle<Function>
// (a pointer to a function in the browser)
// so we cannot call it directly, so we wrap it in a node function first:

const displayFavoriteNumberNode = makeCallableJSHandle(
displayFavoriteNumber,
);

// Note the added `await`.
// Even though the original function was not async, the wrapped function is.
// This is needed because the wrapped function needs to asynchronously communicate with the browser.
await displayFavoriteNumberNode(42);
}),
);
```

For TypeScript users, `runJS` now accepts a new optional type parameter, to specify the exported types of the in-browser module that is passed in. The default value for this parameter is `Record<string, unknown>` (an object with string properties and unknown values). Note that this type does not include `JSHandles`, those are wrapped in the return type from `runJS` automatically.

Using the first example, the optional type would be:

```ts
test(
'receiving exported values from runJS',
withBrowser(async ({ utils }) => {
const { focusTarget, favoriteNumber } = await utils.runJS<{
focusTarget: Element;
favoriteNumber: number;
}>(`
export const focusTarget = document.activeElement
export const favoriteNumber = 20
`);
}),
);
```

Now `focusTarget` automatically has the type `JSHandle<Element>` and `favoriteNumber` automatically has the type `JSHandle<number>`. Without passing in the type parameter to `runJS`, their types would both be `JSHandle<unknown>`.
53 changes: 53 additions & 0 deletions .changeset/smart-games-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'pleasantest': major
---

The way that `runJS` receives parameters in the browser has changed. Now, parameters are available as `import.meta.pleasantestArgs` instead of through an automatically-called default export.

For example, code that used to work like this:

```js
test(
'old version of runJS parameters',
withBrowser(async ({ utils }) => {
// Pass a variable from node to the browser
const url = isDev ? 'dev.example.com' : 'prod.example.com';

await utils.runJS(
`
// Parameters get passed into the default-export function, which is called automatically
export default (url) => {
console.log(url)
}
`,
// array of parameters passed here
[url],
);
}),
);
```

Now should be written like this:

```js
test(
'new version of runJS parameters',
withBrowser(async ({ utils }) => {
// Pass a variable from node to the browser
const url = isDev ? 'dev.example.com' : 'prod.example.com';

await utils.runJS(
`
// Parameters get passed as an array into this context variable, and we can destructure them
const [url] = import.meta.pleasantestArgs
console.log(url)
// If we added a default exported function here, it would no longer be automatically called.
`,
// array of parameters passed here
[url],
);
}),
);
```

This is a breaking change, because the previous mechanism for receiving parameters no longer works, and functions that are `default export`s from runJS are no longer called automatically.
105 changes: 98 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Pleasantest is a library that allows you test web applications using real browse
- [Utilities API: `PleasantestUtils`](#utilities-api-pleasantestutils)
- [`jest-dom` Matchers](#jest-dom-matchers)
- [`getAccessibilityTree`](#getaccessibilitytreeelement-elementhandle--page-options-accessibilitytreeoptions--promiseaccessibilitytreesnapshot)
- [`makeCallableJSHandle`](#makecallablejshandlebrowserfunction-jshandlefunction-function)
- [`toPassAxeTests`](#expectpagetopassaxetestsopts-topassaxetestsopts)
- [Puppeteer Tips](#puppeteer-tips)
- [Comparisons with other testing tools](#comparisons-with-other-testing-tools)
Expand Down Expand Up @@ -92,7 +93,7 @@ test(

#### Option 1: Rendering using a client-side framework

If your app is client-side rendered, you can use [`utils.runJS`](#pleasantestutilsrunjscode-string-promisevoid) to tell Pleasantest how to render your app:
If your app is client-side rendered, you can use [`utils.runJS`](#pleasantestutilsrunjscode-string-browserargs-unknown-promiserecordstring-unknown) to tell Pleasantest how to render your app:

```js
import { withBrowser } from 'pleasantest';
Expand Down Expand Up @@ -366,7 +367,7 @@ Call Signatures:

- `headless`: `boolean`, default `true`: Whether to open a headless (not visible) browser. If you use the `withBrowser.headed` chain, that will override the value of `headless`.
- `device`: Device Object [described here](https://pptr.dev/#?product=Puppeteer&version=v13.5.2&show=api-pageemulateoptions).
- `moduleServer`: Module Server options object (all properties are optional). They will be applied to files imported through [`utils.runJS`](#pleasantestutilsrunjscode-string-promisevoid) or [`utils.loadJS`](#pleasantestutilsloadjsjspath-string-promisevoid).
- `moduleServer`: Module Server options object (all properties are optional). They will be applied to files imported through [`utils.runJS`](#pleasantestutilsrunjscode-string-browserargs-unknown-promiserecordstring-unknown) or [`utils.loadJS`](#pleasantestutilsloadjsjspath-string-promisevoid).
- `plugins`: Array of Rollup, Vite, or WMR plugins to add.
- `envVars`: Object with string keys and string values for environment variables to pass in as `import.meta.env.*` / `process.env.*`
- `esbuild`: ([`TransformOptions`](https://esbuild.github.io/api/#transform-api) | `false`) Options to pass to esbuild. Set to false to disable esbuild.
Expand Down Expand Up @@ -646,7 +647,7 @@ test(

The utilities API provides shortcuts for loading and running code in the browser. The methods are wrappers around behavior that can be performed more verbosely with the [Puppeteer `Page` object](#pleasantestcontextpage). This API is exposed via the [`utils` property in `PleasantestContext`](#pleasantestcontextutils-pleasantestutils)

#### `PleasantestUtils.runJS(code: string): Promise<void>`
#### `PleasantestUtils.runJS(code: string, browserArgs?: unknown[]): Promise<Record<string, unknown>>`

Execute a JS code string in the browser. The code string inherits the syntax abilities of the file it is in, i.e. if your test file is a `.tsx` file, then the code string can include JSX and TS. The code string can use (static or dynamic) ES6 imports to import other modules, including TS/JSX modules, and it supports resolving from `node_modules`, and relative paths from the test file. The code string supports top-level await to wait for a Promise to resolve. Since the code in the string is only a string, you cannot access variables that are defined in the Node.js scope. It is proably a bad idea to use interpolation in the code string, only static strings should be used, so that the source location detection works when an error is thrown.

Expand All @@ -668,7 +669,7 @@ test(
);
```

To pass variables from the test environment into the browser, you can pass them as the 2nd parameter. Note that they must either be JSON-serializable or they can be a [`JSHandle`](https://pptr.dev/#?product=Puppeteer&version=v13.5.2&show=api-class-jshandle) or an [`ElementHandle`](https://pptr.dev/#?product=Puppeteer&version=v13.0.0&show=api-class-elementhandle). The arguments can be received in the browser as parameters to a default-exported function:
To pass variables from the test environment into the browser, you can pass them in an array as the 2nd parameter. Note that they must either be JSON-serializable or they can be a [`JSHandle`](https://pptr.dev/#?product=Puppeteer&version=v13.5.2&show=api-class-jshandle) or an [`ElementHandle`](https://pptr.dev/#?product=Puppeteer&version=v13.0.0&show=api-class-elementhandle). The arguments will be received in the browser via `import.meta.pleasantestArgs`:

```js
import { withBrowser } from 'pleasantest';
Expand All @@ -678,17 +679,73 @@ test(
withBrowser(async ({ utils, screen }) => {
// element is an ElementHandle (pointer to an element in the browser)
const element = await screen.getByText(/button/i);
// we can pass element into runJS and the default exported function can access it as an Element
// we can pass element into runJS and access it as an Element via import.meta.pleasantestArgs
await utils.runJS(
`
export default (element) => console.log(element);
const [element] = import.meta.pleasantestArgs;
console.log(element);
`,
[element],
);
}),
);
```

The code string passed to `runJS` is also a module, and it can export values to make them available in Node. `runJS` returns a Promise resolving to the exports from the module that executed in the browser. Each export is wrapped in a [`JSHandle`](https://pptr.dev/#?product=Puppeteer&version=v13.5.2&show=api-class-jshandle) (a pointer to an in-browser JS object), so that it can be passed back into the browser if necessary, or deserialized in Node using `.jsonValue()`.

```js
test(
'receiving exported values from runJS',
withBrowser(async ({ utils }) => {
// Each export is available in the returned object.
// Each export is wrapped in a JSHandle, meaning that it points to an in-browser object
const { focusTarget, favoriteNumber } = await utils.runJS(`
export const focusTarget = document.activeElement
export const favoriteNumber = 20
`);

// Serializable JSHandles can be unwrapped using JSONValue:
console.log(await favoriteNumber.jsonValue()); // Logs "20"

// A JSHandle<Element>, or ElementHandle is not serializable
// But we can pass it back into the browser to use it (it will be unwrapped in the browser):

await utils.runJS(
`
// The import.meta.pleasantestArgs context object receives the parameters passed in below
const [focusTarget] = import.meta.pleasantestArgs;
console.log(focusTarget) // Logs the element in the browser
`,
// Passing the JSHandle in here passes it into the browser (unwrapped) in import.meta.pleasantestArgs
[focusTarget],
);
}),
);
```

If you export a function from the browser, the easiest way to call it in Node is to use [`makeCallableJSHandle`](#makecallablejshandlebrowserfunction-jshandlefunction-function).

For TypeScript users, `runJS` accepts an optional type parameter, to specify the exported types of the in-browser module that is passed in. The default value for this parameter is `Record<string, unknown>` (an object with string properties and unknown values). Note that this type does not include `JSHandles`, those are wrapped in the return type from `runJS` automatically.

Reusing the same example, the optional type would be:

```ts
test(
'receiving exported values from runJS',
withBrowser(async ({ utils }) => {
const { focusTarget, favoriteNumber } = await utils.runJS<{
focusTarget: Element;
favoriteNumber: number;
}>(`
export const focusTarget = document.activeElement
export const favoriteNumber = 20
`);
}),
);
```

Now `focusTarget` automatically has the type `JSHandle<Element>` and `favoriteNumber` automatically has the type `JSHandle<number>`. Without passing in the type parameter to `runJS`, their types would both be `JSHandle<unknown>`.

#### `PleasantestUtils.loadJS(jsPath: string): Promise<void>`

Load a JS (or TS, JSX) file into the browser. Pass a path that will be resolved from your test file.
Expand Down Expand Up @@ -831,6 +888,40 @@ Disabling these options can be used to reduce the output or to exclude text that

The returned `Promise` wraps an `AccessibilityTreeSnapshot`, which can be passed directly as the `expect` first parameter in `expect(___).toMatchInlineSnapshot()`. The returned object can also be converted to a string using `String(accessibilityTreeSnapshot)`.

### `makeCallableJSHandle(browserFunction: JSHandle<Function>): Function`

Wraps a JSHandle that points to a function in a browser, with a node function that calls the corresponding browser function, passing along the parameters, and returning the return value wrapped in `Promise<JSHandle<T>>`.

This is especially useful to make it easier to call browser functions returned by `runJS`. In this example, we make a `displayFavoriteNumber` function available in Node:

```js
import { makeCallableJSHandle, withBrowser } from 'pleasantest';

test(
'calling functions with makeCallableJSHandle',
withBrowser(async ({ utils }) => {
const { displayFavoriteNumber } = await utils.runJS(`
export const displayFavoriteNumber = (number) => {
document.querySelector('.output').innerHTML = "Favorite number is: " + number
}
`);

// displayFavoriteNumber is a JSHandle<Function>
// (a pointer to a function in the browser)
// so we cannot call it directly, so we wrap it in a node function first:

const displayFavoriteNumberNode = makeCallableJSHandle(
displayFavoriteNumber,
);

// Note the added `await`.
// Even though the original function was not async, the wrapped function is.
// This is needed because the wrapped function needs to asynchronously communicate with the browser.
await displayFavoriteNumberNode(42);
}),
);
```

### `expect(page).toPassAxeTests(opts?: ToPassAxeTestsOpts)`

This assertion, based on [`jest-puppeteer-axe`](https://github.com/WordPress/gutenberg/tree/3b2eccc289cfc90bd99252b12fc4c6e470ce4c04/packages/jest-puppeteer-axe), allows you to check a page using the [axe accessibility linter](https://github.com/dequelabs/axe-core).
Expand Down Expand Up @@ -980,7 +1071,7 @@ Jest uses [jsdom](https://github.com/jsdom/jsdom) and exposes browser-like globa
);
```

- **No Synchronous DOM Access**: Because Jest runs your tests, Pleasantest will never support synchronously and directly modifying the DOM. While you can use [`utils.runJS`](#pleasantestutilsrunjscode-string-promisevoid) to execute snippets of code in the browser, all other browser manipulation must be through the provided asynchronous APIs. This is an advantage [jsdom](https://github.com/jsdom/jsdom)-based tests will always have over Pleasantest tests.
- **No Synchronous DOM Access**: Because Jest runs your tests, Pleasantest will never support synchronously and directly modifying the DOM. While you can use [`utils.runJS`](#pleasantestutilsrunjscode-string-browserargs-unknown-promiserecordstring-unknown) to execute snippets of code in the browser, all other browser manipulation must be through the provided asynchronous APIs. This is an advantage [jsdom](https://github.com/jsdom/jsdom)-based tests will always have over Pleasantest tests.

### Temporary Limitations

Expand Down
14 changes: 13 additions & 1 deletion src/async-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface AsyncHookTracker {
addHook<T>(
func: () => Promise<T>,
captureFunction: (...args: any[]) => any,
): Promise<T | undefined>;
): Promise<T>;
close(): Error | undefined;
}

Expand All @@ -39,7 +39,19 @@ export const createAsyncHookTracker = (): AsyncHookTracker => {
try {
return await func();
} catch (error) {
// If we throw an error here and it _is_ closed,
// there will be an unhandled rejection, ending the process, without a code frame.
//
// If it is closed, it is better to not throw an error because when close() was called,
// we would have already noticed that there was an async hook and thrown an error there.
// So, we only throw an error if it is open
if (!isClosed) throw error;

// This line has no runtime effect, it is just there to make TS OK
// If someone forgot await and is using promise-wrapped values directly,
// TS is already giving them useful error messages.
// The `as never` here tells TS that we can ignore that sometimes this will return undefined
return undefined as never;
} finally {
if (!isClosed) hooks.delete(forgotAwaitError);
}
Expand Down
6 changes: 6 additions & 0 deletions src/extend-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ Received ${this.utils.printReceived(arg)}`,
);
// AddHook resolves to undefined if the function throws after the async hook tracker closes
// Because it needs to not trigger an unhandled promise rejection
// asyncHookTracker can return undefined (even though its types say it won't)
// if the user forgot to use await,
// and the test already exited/threw because of the withBrowser forgot-await detection,
// but the code will keep running because it's impossible to stop without an unhandled promise rejection,
// which is frustrating to debug
// eslint-disable-next-line @cloudfour/typescript-eslint/no-unnecessary-condition
if (res === undefined) return { pass: !this.isNot, message: () => '' };
return res;
}
Expand Down
Loading