Skip to content

Commit

Permalink
Change runJS parameter passing and return value (#535)
Browse files Browse the repository at this point in the history
  • Loading branch information
calebeby authored Aug 1, 2022
1 parent 05ff36f commit dc6f81c
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 45 deletions.
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

0 comments on commit dc6f81c

Please sign in to comment.