diff --git a/.changeset/pretty-coats-burn.md b/.changeset/pretty-coats-burn.md new file mode 100644 index 00000000..553740ce --- /dev/null +++ b/.changeset/pretty-coats-burn.md @@ -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, 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` and returns a node function that calls the corresponding browser function, passing along the parameters, and returning the return value wrapped in `Promise>`: + +```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 + // (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` (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` and `favoriteNumber` automatically has the type `JSHandle`. Without passing in the type parameter to `runJS`, their types would both be `JSHandle`. diff --git a/.changeset/smart-games-camp.md b/.changeset/smart-games-camp.md new file mode 100644 index 00000000..7a8f2fd0 --- /dev/null +++ b/.changeset/smart-games-camp.md @@ -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. diff --git a/README.md b/README.md index eb6ebb17..0dc402ba 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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'; @@ -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. @@ -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` +#### `PleasantestUtils.runJS(code: string, browserArgs?: unknown[]): Promise>` 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. @@ -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'; @@ -678,10 +679,11 @@ 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], ); @@ -689,6 +691,61 @@ test( ); ``` +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, 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` (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` and `favoriteNumber` automatically has the type `JSHandle`. Without passing in the type parameter to `runJS`, their types would both be `JSHandle`. + #### `PleasantestUtils.loadJS(jsPath: string): Promise` Load a JS (or TS, JSX) file into the browser. Pass a path that will be resolved from your test file. @@ -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` + +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>`. + +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 + // (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). @@ -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 diff --git a/src/async-hooks.ts b/src/async-hooks.ts index 655d8b57..c6d3e1a2 100644 --- a/src/async-hooks.ts +++ b/src/async-hooks.ts @@ -19,7 +19,7 @@ export interface AsyncHookTracker { addHook( func: () => Promise, captureFunction: (...args: any[]) => any, - ): Promise; + ): Promise; close(): Error | undefined; } @@ -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); } diff --git a/src/extend-expect.ts b/src/extend-expect.ts index 6ec9ead3..5f17d6c3 100644 --- a/src/extend-expect.ts +++ b/src/extend-expect.ts @@ -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; } diff --git a/src/index.ts b/src/index.ts index e751c5fc..07202c96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,10 @@ export interface PleasantestUtils { * The code string supports top-level await to wait for a Promise to resolve. * You can pass an array of variables to be passed into the browser as the 2nd parameter. */ - runJS(code: string, args?: unknown[]): Promise; + runJS>( + code: string, + args?: unknown[], + ): Promise<{ [Export in keyof Module]: puppeteer.JSHandle }>; /** Set the contents of a new style tag */ injectCSS(css: string): Promise; @@ -333,7 +336,7 @@ const createTab = async ({ const runJS: PleasantestUtils['runJS'] = (code, args) => asyncHookTracker.addHook(async () => { await page - .exposeFunction('pleasantest_callFunction', (id, args) => + .exposeFunction('_pleasantestCallFunction', (id, args) => functionArgs[id](...args), ) .catch((error) => { @@ -354,36 +357,52 @@ const createTab = async ({ // This uses the testPath as the url so that if there are relative imports // in the inline code, the relative imports are resolved relative to the test file const url = `http://localhost:${port}/${testPath}?inline-code=${encodedCode}&build-id=${buildStatus.buildId}`; - const res = await page.evaluate( + + const outputHandle = await page.evaluateHandle( new Function( '...args', - `return import(${JSON.stringify(url)}) - .then(async m => { - const argsWithFuncs = args.map(arg => { - if (typeof arg === 'object' && arg && arg.isFunction) { - return async (...args) => { - return await window.pleasantest_callFunction(arg.id, args); - } - } - return arg - }) - if (m.default) await m.default(...argsWithFuncs) + ` + const argsWithFuncs = args.map(arg => { + if (typeof arg === 'object' && arg?.isFunction) { + return async (...args) => { + return await window._pleasantestCallFunction(arg.id, args); + } + } + return arg + }) + window._pleasantestArgs = argsWithFuncs; + return import(${JSON.stringify(url)}) + .then(async mod => { + window._pleasantestArgs = undefined; + return { success: true, result: { ...mod } } }) - .catch(e => - e instanceof Error - ? { message: e.message, stack: e.stack } - : e)`, + .catch(e => ({ + success: false, + result: e instanceof Error ? { message: e.message, stack: e.stack } : e + }))`, ) as () => any, ...(Array.isArray(argsWithFuncsAsObjs) ? (argsWithFuncsAsObjs as any) : []), ); + const { success, result } = Object.fromEntries( + await outputHandle.getProperties(), + ); + const errorsFromBuild = buildStatus.complete(); // It only throws the first one but that is probably OK if (errorsFromBuild) throw errorsFromBuild[0]; - await sourceMapErrorFromBrowser(res, requestCache, port, runJS); + if (await success.jsonValue()) + return Object.fromEntries(await result.getProperties()) as any; + await sourceMapErrorFromBrowser( + await result.jsonValue(), + requestCache, + port, + runJS, + ); + throw await result.jsonValue(); }, runJS); const injectHTML: PleasantestUtils['injectHTML'] = (html) => @@ -495,5 +514,6 @@ export { accessibilityTreeSnapshotSerializer, } from './accessibility/index.js'; +export { makeCallableJSHandle } from './utils.js'; export { type PleasantestUser } from './user.js'; export { type WaitForOptions } from './pptr-testing-library.js'; diff --git a/src/module-server/middleware/js.ts b/src/module-server/middleware/js.ts index d0ff932c..9e3b69c0 100644 --- a/src/module-server/middleware/js.ts +++ b/src/module-server/middleware/js.ts @@ -98,12 +98,18 @@ export const jsMiddleware = async ({ let map: DecodedSourceMap | RawSourceMap | string | undefined; if (typeof req.query['inline-code'] === 'string') { code = req.query['inline-code']; + const injectedArgsCode = `if (window._pleasantestArgs) { + import.meta.pleasantestArgs = [...window._pleasantestArgs] + }`; const fileSrc = await fs.readFile(file, 'utf8'); const inlineStartIdx = fileSrc.indexOf(code); + code = injectedArgsCode + code; if (inlineStartIdx !== -1) { const str = new MagicString(fileSrc); str.remove(0, inlineStartIdx); str.remove(inlineStartIdx + code.length, fileSrc.length); + // Account for the injected import.meta.pleasantestArgs code in the source map + str.prepend(injectedArgsCode); map = str.generateMap({ hires: true, source: id, diff --git a/src/utils.ts b/src/utils.ts index 31172366..b8c13861 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,27 @@ import * as kolorist from 'kolorist'; import type { ElementHandle, JSHandle } from 'puppeteer'; +type Promisify = T extends Promise ? T : Promise; +type JSHandleify = T extends JSHandle ? T : JSHandle; + +/** + * 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>` + */ +export const makeCallableJSHandle = + any>( + browserFunction: JSHandle, + ): (( + ...args: Parameters + ) => Promisify>>) => + (...args) => + browserFunction.evaluateHandle( + (browserFn, ...args) => browserFn(...args), + ...args, + ) as any; + export const jsHandleToArray = async (arrayHandle: JSHandle) => { const properties = await arrayHandle.getProperties(); const arr = Array.from({ length: properties.size }); diff --git a/tests/utils/runJS.test.tsx b/tests/utils/runJS.test.tsx index 23e3928c..fca91f36 100644 --- a/tests/utils/runJS.test.tsx +++ b/tests/utils/runJS.test.tsx @@ -1,6 +1,6 @@ import aliasPlugin from '@rollup/plugin-alias'; import { babel } from '@rollup/plugin-babel'; -import { withBrowser } from 'pleasantest'; +import { makeCallableJSHandle, withBrowser } from 'pleasantest'; import type { PleasantestContext, PleasantestUtils } from 'pleasantest'; import sveltePlugin from 'rollup-plugin-svelte'; import vuePlugin from 'rollup-plugin-vue'; @@ -46,18 +46,28 @@ test( await utils.runJS( ` - export default async (mockFuncA, mockFuncB) => { - const val = await mockFuncA('hello world') - if (val !== 5) throw new Error('Did not get return value'); - await mockFuncB() - } - `, + const [mockFuncA, mockFuncB] = import.meta.pleasantestArgs; + const val = await mockFuncA('hello world'); + if (val !== 5) throw new Error('Did not get return value'); + await mockFuncB() + `, [mockFuncA, mockFuncB], ); expect(mockFuncA).toHaveBeenCalledTimes(1); - expect(mockFuncA).toHaveBeenCalledWith('hello world'); + expect(mockFuncA.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "hello world", + ], + ] + `); expect(mockFuncB).toHaveBeenCalledTimes(1); + expect(mockFuncB.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [], + ] + `); }), ); @@ -67,14 +77,13 @@ test( const heading = await createHeading({ utils, screen }); await utils.runJS( ` - export default (heading, object) => { - if (heading.outerHTML !== "

I'm a heading

") { - throw new Error('element was not passed correctly') - } - if (object.some.serializable.value !== false) { - throw new Error('object was not passed correctly') - } - } + const [heading, object] = import.meta.pleasantestArgs + if (heading.outerHTML !== "

I'm a heading

") { + throw new Error('element was not passed correctly') + } + if (object.some.serializable.value !== false) { + throw new Error('object was not passed correctly') + } `, [heading, { some: { serializable: { value: false } } }], ); @@ -118,6 +127,44 @@ describe('Waiting for Promises in executed code', () => { ); }); +test( + 'runJS returns exported values, and allows calling returned functions', + withBrowser(async ({ utils }) => { + const result = await utils.runJS<{ + a: number; + default: string; + b: ( + arg1: number, + arg2: { test: 'object' }, + ) => ['it worked', typeof arg1, typeof arg2]; + }>(` + export const a = 25; + export default "hi" + export const b = (arg1, arg2) => { + return ['it worked!', arg1, arg2] + } + `); + expect(Object.keys(result).sort()).toStrictEqual(['a', 'b', 'default']); + expect(result.a.toString()).toEqual('JSHandle:25'); + expect(result.b.toString()).toEqual('JSHandle@function'); + expect(result.default.toString()).toEqual('JSHandle:hi'); + + expect(await result.a.jsonValue()).toStrictEqual(25); + expect(await result.b.jsonValue()).toStrictEqual({}); // function is not JSON-serializable + expect(await result.default.jsonValue()).toStrictEqual('hi'); + + const b = makeCallableJSHandle(result.b); + const callResult = await b(37, { test: 'object' }); + + expect(callResult.toString()).toEqual('JSHandle@array'); + expect(await callResult.jsonValue()).toStrictEqual([ + 'it worked!', + 37, + { test: 'object' }, + ]); + }), +); + test( 'supports TS in snippet', withBrowser(async ({ utils, screen }) => { @@ -329,7 +376,7 @@ test( withBrowser(async ({ utils }) => { const runPromise = formatErrorWithCodeFrame( utils.runJS(` - import { foo } from 'something-not-existing' + import 'something-not-existing' `), ); @@ -343,7 +390,7 @@ test( 'resolution error if a relative path does not exist', withBrowser(async ({ utils }) => { const runPromise = utils.runJS(` - import { foo } from './bad-relative-path' + import './bad-relative-path' `); await expect( @@ -481,6 +528,7 @@ describe('Ecosystem interoperability', () => { withBrowser(async ({ utils }) => { const runPromise = utils.runJS(` import CounterComponent from './svelte-component.svelte' + window._ = CounterComponent; // so that the import does not get removed by TS transpilation `); await expect(formatErrorWithCodeFrame(runPromise)).rejects