├── .eslintrc.js ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── 01-bug.md │ └── 02-feature.md └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── README.md ├── jest.config.ts ├── ossjs.release.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── createBrowser.ts ├── index.ts ├── internal │ └── createLogger.ts ├── middleware │ └── staticFromMemory.ts ├── pageWith.ts ├── server │ ├── PreviewServer.ts │ └── template.mustache ├── utils │ ├── asyncCompile.ts │ ├── debug.ts │ ├── request.ts │ └── spyOnConsole.ts └── webpack.config.ts ├── test ├── fixtures │ ├── custom.html │ ├── goodbye.js │ └── hello.js ├── jest.setup.ts ├── suites │ └── pageWith.test.ts └── tsconfig.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'jest', 'prettier'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:prettier/recommended', 9 | 'prettier', 10 | ], 11 | rules: { 12 | 'jest/no-disabled-tests': 'warn', 13 | 'jest/no-focused-tests': 'error', 14 | 'jest/no-identical-title': 'error', 15 | '@typescript-eslint/no-namespace': 'off', 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering contributing to this library! Below you can find the instructions on the development process, as well as testing and publishing guidelines. Don't hesitate to reach out to the library maintainers in the case of questions. 4 | 5 | ## Pre-requisites 6 | 7 | - [PNPM](https://pnpm.io/) 8 | - [TypeScript](https://www.typescriptlang.org/) 9 | - [Jest](https://jestjs.io/) 10 | 11 | ## Git workflow 12 | 13 | ```bash 14 | $ git checkout -b 15 | $ git add . 16 | $ git commit -m 'Adds contribution guidelines' 17 | $ git push -u origin 18 | ``` 19 | 20 | Ensure that your feature branch is up-to-date with the latest `main` before assigning it for code review: 21 | 22 | ```bash 23 | $ git checkout master 24 | $ git pull --rebase 25 | $ git checkout 26 | $ git rebase master 27 | ``` 28 | 29 | Once your changes are ready, open a Pull request and assign one of the library maintainers as a reviewer. We will go through your changes and ensure they land in the next release. 30 | 31 | ## Develop 32 | 33 | ```bash 34 | $ pnpm start 35 | ``` 36 | 37 | ## Test 38 | 39 | ### Run all tests 40 | 41 | ```bash 42 | $ pnpm test 43 | ``` 44 | 45 | ### Run a single test 46 | 47 | ```bash 48 | $ pnpm test test/add.test.ts 49 | ``` 50 | 51 | ## Publish 52 | 53 | This package is published automatically upon each merge to the `main` branch. 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'New issue' 3 | about: 'Let us know what you are struggling with' 4 | title: '' 5 | labels: bug 6 | --- 7 | 8 | ## Description 9 | 10 | 11 | 12 | ## Reproduction steps 13 | 14 | 15 | 16 | 17 | ## Expected behavior 18 | 19 | 20 | 21 | ## Initial assessment 22 | 23 | 24 | 25 | ## Screenshots 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'New feature' 3 | about: 'Request or suggest a new feature' 4 | title: '' 5 | labels: feature 6 | --- 7 | 8 | ## Description 9 | 10 | 11 | 12 | 13 | ## Alternatives 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | token: ${{ secrets.CI_GITHUB_TOKEN }} 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | always-auth: true 24 | registry-url: https://registry.npmjs.org 25 | 26 | - name: Setup Git 27 | run: | 28 | git config --local user.name "GitHub Actions" 29 | git config --local user.email "actions@github.com" 30 | 31 | - uses: pnpm/action-setup@v2 32 | with: 33 | version: 7.12 34 | 35 | - name: Install dependencies 36 | run: pnpm install 37 | 38 | - name: Build 39 | run: pnpm build 40 | 41 | - name: Tests 42 | run: pnpm test 43 | 44 | - name: Release 45 | if: github.ref == 'refs/heads/main' 46 | run: pnpm release 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} 49 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # OS files 107 | *.DS_Store 108 | 109 | # Build 110 | lib 111 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.8.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "always", 6 | "useTabs": false, 7 | "tabWidth": 2 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `page-with` 2 | 3 | A library for usage example-driven in-browser testing of your own libraries. 4 | 5 | ## Motivation 6 | 7 | This library empowers example-based testing. That is a testing approach when you write a bunch of actual usage example modules of your own library and wish to run tests against them. 8 | 9 | ### Why not JSDOM? 10 | 11 | JSDOM is designed to emulate browser environment, not substitute it. The code you test in JSDOM still runs in NodeJS and there is no actual browser context involved. 12 | 13 | ### Why not Cypress? 14 | 15 | Tools like Cypress give you a benefit of executing your tests in a real browser. However, the setup of such tools is often verbose and may be an overkill for usage-based in-browser testing of a _library_. Cypress also lacks a low-level browser automation API (i.e. creating and performing actions across multiple tabs, Service Worker access), which makes it not suitable for a versatile yet plain usage testing. 16 | 17 | ### Why not Puppeteer/Playwrigth/Selenium/etc.? 18 | 19 | Low-level browser automation software like Puppeteer gives you a great control over the browser. However, you still need to load your usage example into it, which may involve optional compilation step in case you wish to illustrate usage examples in TypeScript, React, or any other format that cannot run directly in a browser. 20 | 21 | ## How does this work? 22 | 23 | 1. Creates a single browser process for the entire test run. 24 | 1. Spawns a single server that compiles usage examples on-demand. 25 | 1. Gives you an API to compile and load a given usage example as a part of a test. 26 | 1. Cleans up afterwards. 27 | 28 | ## Getting started 29 | 30 | ### Install 31 | 32 | ```bash 33 | $ npm install page-with --save-dev 34 | ``` 35 | 36 | ### Configure your test framework 37 | 38 | Here's an example how to use `page-with` with Jest: 39 | 40 | ```js 41 | // jest.setup.js 42 | import { createBrowser } from 'page-with' 43 | 44 | let browser 45 | 46 | beforeAll(async () => { 47 | browser = await createBrowser() 48 | }) 49 | 50 | afterAll(async () => { 51 | await browser.cleanup() 52 | }) 53 | ``` 54 | 55 | > Specify the `jest.setup.js` file as the value for the [`setupFilesAfterEnv`](https://jestjs.io/docs/en/configuration.html#setupfilesafterenv-array) option in your Jest configuration file. 56 | 57 | ### Create a usage scenario 58 | 59 | ```js 60 | // test/getValue.usage.js 61 | import { getValue } from 'my-library' 62 | 63 | // My library hydrates the value by the key from sessionStorage 64 | // if it's present, otherwise it returns undefined. 65 | window.value = getValue('key') 66 | ``` 67 | 68 | > Use [webpack `resolve.alias`](https://webpack.js.org/configuration/resolve/#resolvealias) to import the source code of your library from its published namespace (i.e. `my-library`) instead of relative imports. Let your usage examples look exactly how your library is used. 69 | 70 | ### Test your library 71 | 72 | ```js 73 | // test/getValue.test.js 74 | import { pageWith } from 'page-with' 75 | 76 | it('hydrates the value from the sessionStorage', async () => { 77 | const scenario = await pageWith({ 78 | // Provide the usage example we've created earlier. 79 | example: './getValue.usage.ts', 80 | }) 81 | 82 | const initialValue = await scenario.page.evaluate(() => { 83 | return window.value 84 | }) 85 | expect(initialValue).toBeUndefined() 86 | 87 | await scenario.page.evaluate(() => { 88 | sessionStorage.setItem('key', 'abc-123') 89 | }) 90 | await scenario.page.reload() 91 | 92 | const hydratedValue = await scenario.page.evaluate(() => { 93 | return window.value 94 | }) 95 | expect(hydratedValue).toBe('abc-123') 96 | }) 97 | ``` 98 | 99 | ## Options 100 | 101 | ### `example` 102 | 103 | (_Required_) A relative path to the example module to compile and load in the browser. 104 | 105 | ```js 106 | pageWith({ 107 | example: path.resolve(__dirname, 'example.js'), 108 | }) 109 | ``` 110 | 111 | ### `title` 112 | 113 | A custom title of the page. Useful to discern pages when loading multiple scenarios in the same browser. 114 | 115 | ```js 116 | pageWith({ 117 | title: 'My app', 118 | }) 119 | ``` 120 | 121 | ### `markup` 122 | 123 | A custom HTML markup of the loaded example. 124 | 125 | ```js 126 | pageWith({ 127 | markup: ` 128 | 129 | 130 | 131 | `, 132 | }) 133 | ``` 134 | 135 | > Note that the compiled example module will be appended to the markup automatically. 136 | 137 | You can also provide a relative path to the HTML file to use as the custom markup: 138 | 139 | ```js 140 | pageWith({ 141 | markup: path.resolve(__dirname, 'markup.html'), 142 | }) 143 | ``` 144 | 145 | ### `contentBase` 146 | 147 | A relative path to a directory to use to resolve page's resources. Useful to load static resources (i.e. images) on the runtime. 148 | 149 | ```js 150 | pageWith({ 151 | contentBase: path.resolve(__dirname, 'public'), 152 | }) 153 | ``` 154 | 155 | ### `routes` 156 | 157 | A function to customize the Express server instance that runs the local preview of the compiled example. 158 | 159 | ```js 160 | pageWith({ 161 | routes(app) { 162 | app.get('/user', (res, res) => { 163 | res.status(200).json({ firstName: 'John' }) 164 | }) 165 | }, 166 | }) 167 | ``` 168 | 169 | > Making a `GET /user` request in your example module now returns the defined JSON response. 170 | 171 | ### `env` 172 | 173 | Environmental variables to propagate to the browser's `window`. 174 | 175 | ```js 176 | pageWith({ 177 | env: { 178 | serverUrl: 'http://localhost:3000', 179 | }, 180 | }) 181 | ``` 182 | 183 | > The `serverUrl` variable will be available under `window.serverUrl` in the browser (and your example). 184 | 185 | ## Recipes 186 | 187 | ### Debug mode 188 | 189 | Debugging headless automated browsers is not an easy task. That's why `page-with` supports a debug mode in which it will open the browser for you to see and log out all the steps that your test performs into the terminal. 190 | 191 | To enable the debug mode pass the `DEBUG` environmental variable to your testing command and scope it down to `pageWith`: 192 | 193 | ```bash 194 | $ DEBUG=pageWith npm test 195 | ``` 196 | 197 | > If necessary, replace `npm test` with the command that runs your automated tests. 198 | 199 | Since you see the same browser instance that runs in your test, you will also see all the steps your test makes live. 200 | 201 | ### Debug breakpoints 202 | 203 | You can use the `debug` utility to create a breakpoint at any point of your test. 204 | 205 | ```js 206 | import { pageWith, debug } from 'page-with' 207 | 208 | it('automates the browser', async () => { 209 | const { page } = await pageWith({ example: 'function.usage.js' }) 210 | // Pause the execution when the page is created. 211 | await debug(page) 212 | 213 | await page.evaluate(() => { 214 | console.log('Hey, some action!') 215 | }) 216 | 217 | // Pause the execution after some actions in the test. 218 | // See the result of those actions in the opened browser. 219 | await debug(page) 220 | }) 221 | ``` 222 | 223 | > Note that you need to run your test [in debug mode](#debug-mode) to see the automated browser open. 224 | 225 | ### Custom webpack configuration 226 | 227 | This library compiles your usage example in the local server. To extend the webpack configuration used to compile your example pass the partial webpack config to the `serverOptions.webpackConfig` option of `createBrowser`. 228 | 229 | ```js 230 | import path from 'path' 231 | import { createBrowser } from 'page-with' 232 | 233 | const browser = createBrowser({ 234 | serverOptions: { 235 | webpackConfig: { 236 | resolve: { 237 | alias: { 238 | 'my-lib': path.resolve(__dirname, '../lib'), 239 | }, 240 | }, 241 | }, 242 | }, 243 | }) 244 | ``` 245 | 246 | ## FAQ 247 | 248 | ### Why choose `playwright`? 249 | 250 | Playwright comes with a browser context feature that allows to spawn a single browser instance and execute various scenarios independently without having to create a new browser process per test. This decreases the testing time tremendously. 251 | 252 | ### Why not `webpack-dev-server`? 253 | 254 | Although `webpack-dev-server` can perform webpack compilations and serve static HTML with the compilation assets injected, it needs to know the entry point(s) prior to compilation. To prevent each test from spawning a new dev server, this library creates a single instance of an Express server that compiles given entry points on-demand on runtime. 255 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { pathsToModuleNameMapper } from 'ts-jest/utils' 2 | import { compilerOptions } from './tsconfig.json' 3 | 4 | export default { 5 | preset: 'ts-jest', 6 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 7 | prefix: '/', 8 | }), 9 | setupFilesAfterEnv: ['./test/jest.setup.ts'], 10 | } 11 | -------------------------------------------------------------------------------- /ossjs.release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | script: 'pnpm publish --no-git-checks', 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "page-with", 3 | "version": "0.6.1", 4 | "description": "A library for usage example-driven in-browser testing of your own libraries.", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "repository": "git@github.com:kettanaito/page-with.git", 8 | "homepage": "https://github.com/kettanaito/page-with#readme", 9 | "author": "Artem Zakharchenko ", 10 | "license": "MIT", 11 | "scripts": { 12 | "prepare": "husky install", 13 | "start": "tsc -w && pnpm copy", 14 | "lint": "eslint ./{src,test}/**/*.ts", 15 | "clean": "rimraf ./lib", 16 | "copy": "cpy '**/*' '!**/*.ts' ../lib --cwd=./src --no-overwrite --parents", 17 | "build": "pnpm lint && pnpm clean && tsc && pnpm copy", 18 | "test": "jest", 19 | "release": "release publish" 20 | }, 21 | "files": [ 22 | "lib", 23 | "README.md" 24 | ], 25 | "lint-staged": { 26 | "*.ts": [ 27 | "eslint", 28 | "prettier" 29 | ] 30 | }, 31 | "husky": { 32 | "hooks": { 33 | "pre-commit": "lint-staged" 34 | } 35 | }, 36 | "devDependencies": { 37 | "@ossjs/release": "^0.5.1", 38 | "@types/jest": "^27.0.2", 39 | "@types/node": "^16.11.6", 40 | "@typescript-eslint/eslint-plugin": "^5.2.0", 41 | "@typescript-eslint/parser": "^5.2.0", 42 | "cpy-cli": "^3.1.1", 43 | "eslint": "^8.1.0", 44 | "eslint-config-prettier": "^8.3.0", 45 | "eslint-plugin-jest": "^25.2.2", 46 | "eslint-plugin-prettier": "^4.0.0", 47 | "husky": "^8.0.3", 48 | "jest": "^27.3.1", 49 | "lint-staged": "^10.5.3", 50 | "prettier": "^2.4.1", 51 | "rimraf": "^3.0.2", 52 | "ts-jest": "^27.0.7", 53 | "ts-node": "^10.4.0", 54 | "typescript": "^4.4.4" 55 | }, 56 | "dependencies": { 57 | "@open-draft/until": "^2.0.0", 58 | "@types/debug": "^4.1.7", 59 | "@types/express": "^4.17.13", 60 | "@types/mustache": "^4.1.2", 61 | "@types/uuid": "^8.3.1", 62 | "debug": "^4.3.2", 63 | "express": "^4.17.1", 64 | "headers-polyfill": "^3.0.3", 65 | "memfs": "^3.4.7", 66 | "mustache": "^4.2.0", 67 | "playwright": "^1.16.2", 68 | "uuid": "^8.3.2", 69 | "webpack": "^5.61.0", 70 | "webpack-merge": "^5.8.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/createBrowser.ts: -------------------------------------------------------------------------------- 1 | import { until } from '@open-draft/until' 2 | import { ChromiumBrowser, LaunchOptions, chromium } from 'playwright' 3 | import { createLogger } from './internal/createLogger' 4 | import { ServerOptions, PreviewServer } from './server/PreviewServer' 5 | 6 | const log = createLogger('browser') 7 | 8 | export let browser: ChromiumBrowser 9 | export let server: PreviewServer 10 | 11 | export interface CreateBrowserApi { 12 | browser: ChromiumBrowser 13 | cleanup(): Promise 14 | } 15 | 16 | export interface CreateBrowserOptions { 17 | launchOptions?: LaunchOptions 18 | serverOptions?: ServerOptions 19 | } 20 | 21 | export async function createBrowser( 22 | options: CreateBrowserOptions = {}, 23 | ): Promise { 24 | log('spawning a browser...') 25 | 26 | browser = await chromium.launch( 27 | Object.assign( 28 | {}, 29 | { 30 | headless: !process.env.DEBUG, 31 | devtools: !!process.env.DEBUG, 32 | args: ['--no-sandbox'], 33 | }, 34 | options.launchOptions, 35 | ), 36 | ) 37 | 38 | log('successfully spawned the browser!') 39 | log('spawning a server...') 40 | 41 | server = new PreviewServer(options.serverOptions) 42 | const serverConnection = await until(() => server.listen()) 43 | 44 | if (serverConnection.error) { 45 | throw new Error(`Failed to create a server.\n${serverConnection.error}`) 46 | } 47 | 48 | const connection = serverConnection.data 49 | 50 | log('successfully spawned the server!', connection.url) 51 | 52 | async function cleanup() { 53 | log('cleaning up...') 54 | 55 | if (process.env.DEBUG) { 56 | log('cleanup prevented in DEBUG mode') 57 | return Promise.resolve() 58 | } 59 | 60 | return Promise.all([browser.close(), server.close()]).then(() => { 61 | log('successfully cleaned up all resources!') 62 | }) 63 | } 64 | 65 | process.on('exit', cleanup) 66 | process.on('SIGKILL', cleanup) 67 | 68 | return { 69 | browser, 70 | cleanup, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Page, Response } from 'playwright' 2 | 3 | export * from './createBrowser' 4 | export * from './server/PreviewServer' 5 | export * from './pageWith' 6 | export * from './utils/request' 7 | export * from './utils/spyOnConsole' 8 | export * from './utils/debug' 9 | -------------------------------------------------------------------------------- /src/internal/createLogger.ts: -------------------------------------------------------------------------------- 1 | import { debug } from 'debug' 2 | 3 | export function createLogger(name: string): ReturnType { 4 | return debug(`pageWith:${name}`) 5 | } 6 | -------------------------------------------------------------------------------- /src/middleware/staticFromMemory.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { IFs } from 'memfs' 3 | import { RequestHandler } from 'express' 4 | import { createLogger } from '../internal/createLogger' 5 | 6 | const log = createLogger('staticFromMemory') 7 | 8 | export function staticFromMemory(ifs: IFs): RequestHandler { 9 | return (req, res) => { 10 | const filePath = path.join('dist', req.url) 11 | log('reading file "%s"...', filePath) 12 | 13 | if (!ifs.existsSync(filePath)) { 14 | log('asset "%s" not found in memory', filePath) 15 | return res.status(404).end() 16 | } 17 | 18 | const stream = ifs.createReadStream(filePath, 'utf8') 19 | stream.pipe(res) 20 | 21 | stream.on('error', (error) => { 22 | log('error while reading "%s" from memory', filePath) 23 | console.error(error) 24 | }) 25 | 26 | stream.on('end', () => { 27 | log('successfully read the file!', filePath) 28 | res.end() 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/pageWith.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { Express } from 'express' 4 | import { ChromiumBrowserContext, Page } from 'playwright' 5 | import { browser, server } from './createBrowser' 6 | import { PreviewServer, ServerOptions } from './server/PreviewServer' 7 | import { createLogger } from './internal/createLogger' 8 | import { RequestHelperFn, createRequestUtil } from './utils/request' 9 | import { debug } from './utils/debug' 10 | import { ConsoleMessages, spyOnConsole } from './utils/spyOnConsole' 11 | 12 | const log = createLogger('page') 13 | 14 | export interface PageWithOptions { 15 | example: string 16 | markup?: string 17 | contentBase?: string 18 | routes?(app: Express): void 19 | title?: string 20 | env?: Record 21 | serverOptions?: ServerOptions 22 | } 23 | 24 | export interface ScenarioApi { 25 | page: Page 26 | origin: string 27 | makeUrl(chunk: string): string 28 | debug(page?: Page): Promise 29 | request: RequestHelperFn 30 | context: ChromiumBrowserContext 31 | consoleSpy: ConsoleMessages 32 | server: PreviewServer 33 | } 34 | 35 | /** 36 | * Open a new page with the given usage scenario. 37 | */ 38 | export async function pageWith(options: PageWithOptions): Promise { 39 | const { example, markup, contentBase, title } = options 40 | 41 | /** 42 | * @todo Pass that `pageId` to the server to establish a unique route 43 | * for this page! 44 | */ 45 | 46 | log(`loading example at "${example}"`) 47 | 48 | if (example) { 49 | const fullExamplePath = path.isAbsolute(example) 50 | ? example 51 | : path.resolve(process.cwd(), example) 52 | if (!fs.existsSync(fullExamplePath)) { 53 | throw new Error( 54 | `Failed to load a scenario at "${fullExamplePath}": given file does not exist.`, 55 | ) 56 | } 57 | } 58 | 59 | if (markup) { 60 | log('using a custom markup', markup) 61 | } 62 | 63 | if (contentBase) { 64 | log('using a custom content base', contentBase) 65 | } 66 | 67 | server.use((app) => { 68 | if (title) { 69 | app.set('title', title) 70 | } 71 | 72 | if (markup) { 73 | app.set('markup', markup) 74 | } 75 | 76 | if (contentBase) { 77 | app.set('contentBase', contentBase) 78 | } 79 | }) 80 | 81 | const cleanupRoutes = options.routes ? server.use(options.routes) : null 82 | 83 | if (options.serverOptions) { 84 | Object.entries(options.serverOptions).forEach(([name, value]) => { 85 | server.setOption(name as any, value) 86 | }) 87 | } 88 | 89 | const [context] = await Promise.all([ 90 | browser.newContext(), 91 | server.compile(example), 92 | ]) 93 | 94 | const serverContext = server.createContext(example, { 95 | title: options.title, 96 | markup: options.markup, 97 | }) 98 | 99 | log('compiled example running at', serverContext.previewUrl) 100 | 101 | const page = await context.newPage() 102 | const consoleSpy = spyOnConsole(page) 103 | 104 | log('navigating to the compiled example...', serverContext.previewUrl) 105 | await page.goto(serverContext.previewUrl, { waitUntil: 'networkidle' }) 106 | 107 | if (options.env) { 108 | await page.evaluate((variables) => { 109 | variables.forEach(([variableName, value]) => { 110 | // @ts-expect-error Adding a custom "window" property. 111 | window[variableName] = value 112 | }) 113 | }, Object.entries(options.env)) 114 | } 115 | 116 | page.on('close', () => { 117 | log('closing the page...') 118 | cleanupRoutes?.() 119 | }) 120 | 121 | return { 122 | page, 123 | origin: serverContext.previewUrl, 124 | context, 125 | makeUrl(chunk) { 126 | return new URL(chunk, server.connectionInfo?.url).toString() 127 | }, 128 | debug(customPage) { 129 | return debug(customPage || page) 130 | }, 131 | request: createRequestUtil(page, server), 132 | consoleSpy, 133 | server, 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/server/PreviewServer.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { Server } from 'http' 4 | import { AddressInfo } from 'net' 5 | import { until } from '@open-draft/until' 6 | import * as express from 'express' 7 | import { v4 } from 'uuid' 8 | import { IFs, createFsFromVolume, Volume } from 'memfs' 9 | import { webpack, Chunk, Configuration } from 'webpack' 10 | import merge from 'webpack-merge' 11 | import { render } from 'mustache' 12 | import { staticFromMemory } from '../middleware/staticFromMemory' 13 | import { asyncCompile } from '../utils/asyncCompile' 14 | import { createLogger } from '../internal/createLogger' 15 | import { webpackConfig } from '../webpack.config' 16 | 17 | export interface ServerOptions { 18 | router?(app: express.Express): void 19 | webpackConfig?: Configuration 20 | compileInMemory?: boolean 21 | } 22 | 23 | interface ServerConnectionInfo { 24 | port: number 25 | host: string 26 | url: string 27 | family: AddressInfo['family'] 28 | } 29 | 30 | interface CacheEntry { 31 | lastModified: number 32 | chunks: Set 33 | } 34 | type Cache = Map 35 | 36 | type PagesMap = Map< 37 | string, 38 | { 39 | entryPath: string 40 | options: PageOptions 41 | } 42 | > 43 | 44 | interface PageOptions { 45 | title?: string 46 | markup?: string 47 | } 48 | 49 | interface PageContext { 50 | previewUrl: string 51 | } 52 | 53 | const DEFAULT_SERVER_OPTIONS: ServerOptions = { 54 | compileInMemory: true, 55 | } 56 | 57 | export class PreviewServer { 58 | private options: ServerOptions 59 | private baseWebpackConfig: Configuration 60 | private memoryFs: IFs 61 | private app: express.Express 62 | private connection: Server | null 63 | private cache: Cache 64 | private pages: PagesMap 65 | private log: debug.Debugger 66 | 67 | public connectionInfo: ServerConnectionInfo | null 68 | 69 | public setOption( 70 | name: Name, 71 | value: ServerOptions[Name], 72 | ): void { 73 | this.options[name] = value 74 | 75 | if (name === 'webpackConfig') { 76 | const prevBaseConfig = this.baseWebpackConfig 77 | const nextWebpackConfig = value as unknown as Configuration 78 | this.baseWebpackConfig = merge(prevBaseConfig, nextWebpackConfig || {}) 79 | } 80 | } 81 | 82 | constructor(options: ServerOptions = {}) { 83 | this.log = createLogger('server') 84 | this.options = { ...DEFAULT_SERVER_OPTIONS, ...options } 85 | this.baseWebpackConfig = merge( 86 | webpackConfig, 87 | this.options.webpackConfig || {}, 88 | ) 89 | this.memoryFs = createFsFromVolume(new Volume()) 90 | this.cache = new Map() 91 | this.app = express() 92 | this.connection = null 93 | this.connectionInfo = null 94 | this.pages = new Map() 95 | 96 | this.initSettings() 97 | this.applyMiddleware() 98 | this.applyRoutes() 99 | } 100 | 101 | private initSettings() { 102 | this.app.set('contentBase', null) 103 | } 104 | 105 | public async listen( 106 | port = 0, 107 | host = 'localhost', 108 | ): Promise { 109 | return new Promise((resolve, reject) => { 110 | this.log('establishing server connection...') 111 | 112 | const connection = this.app.listen(port, host, () => { 113 | const { address, port, family } = connection.address() as AddressInfo 114 | // IPv6 host requires surrounding square brackets when serialized to URL 115 | // note: IPv6 host can take many forms, e.g. `::` and `::1` are both ok 116 | const serializedAddress = family === 'IPv6' ? `[${address}]` : address 117 | const url = `http://${serializedAddress}:${port}` 118 | this.connectionInfo = { 119 | port, 120 | host: address, 121 | url, 122 | family, 123 | } 124 | this.connection = connection 125 | this.log('preview server established at %s', url) 126 | resolve(this.connectionInfo) 127 | }) 128 | 129 | connection.on('error', reject) 130 | }) 131 | } 132 | 133 | public async compile(entryPath?: string): Promise> { 134 | this.log('compiling entry...', entryPath) 135 | this.log('compiling to memory?', this.options.compileInMemory) 136 | 137 | if (!entryPath) { 138 | this.log('no entry given, skipping the compilation') 139 | return new Set() 140 | } 141 | 142 | const absoluteEntryPath = path.isAbsolute(entryPath) 143 | ? entryPath 144 | : path.resolve(process.cwd(), entryPath) 145 | 146 | this.log('resolved absolute entry path', absoluteEntryPath) 147 | 148 | const entryStats = fs.statSync(absoluteEntryPath) 149 | const cachedEntry = this.cache.get(absoluteEntryPath) 150 | this.log('looking up a cached compilation...') 151 | 152 | if (cachedEntry?.lastModified === entryStats.mtimeMs) { 153 | this.log('found a cached compilation!', cachedEntry.lastModified) 154 | return cachedEntry.chunks 155 | } 156 | 157 | this.log('no cache found, compiling...') 158 | const webpackConfig = Object.assign({}, this.baseWebpackConfig, { 159 | entry: { 160 | main: absoluteEntryPath, 161 | }, 162 | }) 163 | const compiler = webpack(webpackConfig) 164 | 165 | this.log('resolved webpack configuration', webpackConfig) 166 | 167 | if (!this.options.compileInMemory) { 168 | this.log('compiling to dist:', webpackConfig.output) 169 | } 170 | 171 | if (this.options.compileInMemory) { 172 | compiler.outputFileSystem = this.memoryFs 173 | } 174 | 175 | const compilationResult = await until(() => asyncCompile(compiler)) 176 | 177 | if (compilationResult.error) { 178 | this.log('failed to compile', absoluteEntryPath) 179 | throw compilationResult.error 180 | } 181 | 182 | const stats = compilationResult.data 183 | 184 | const { chunks } = stats.compilation 185 | 186 | this.log('caching the compilation...', entryStats.mtimeMs, chunks.size) 187 | this.cache.set(absoluteEntryPath, { 188 | lastModified: entryStats.mtimeMs, 189 | chunks, 190 | }) 191 | 192 | return chunks 193 | } 194 | 195 | private createPreviewUrl(pageId: string): string { 196 | const url = new URL(`/preview/${pageId}`, this.connectionInfo?.url) 197 | this.log('created a preview URL for %s (%s)', pageId, url.toString()) 198 | 199 | return url.toString() 200 | } 201 | 202 | public createContext(entryPath: string, options: PageOptions): PageContext { 203 | const pageId = v4() 204 | this.pages.set(pageId, { 205 | entryPath, 206 | options, 207 | }) 208 | 209 | const previewUrl = this.createPreviewUrl(pageId) 210 | 211 | return { 212 | previewUrl, 213 | } 214 | } 215 | 216 | public use(middleware: (app: express.Express) => void): () => void { 217 | const prevRoutesCount = this.app._router.stack.length 218 | middleware(this.app) 219 | const nextRoutesCount = this.app._router.stack.length 220 | 221 | return () => { 222 | const runtimeRoutesCount = nextRoutesCount - prevRoutesCount 223 | this.app._router.stack.splice(-runtimeRoutesCount) 224 | } 225 | } 226 | 227 | public async close(): Promise { 228 | this.log('closing the server...') 229 | 230 | if (!this.connection) { 231 | throw new Error('Failed to close a server: server is not running.') 232 | } 233 | 234 | return new Promise((resolve, reject) => { 235 | this.connection?.close((error) => { 236 | if (error) { 237 | return reject(error) 238 | } 239 | 240 | this.log('successfully closed the server!') 241 | resolve() 242 | }) 243 | }) 244 | } 245 | 246 | private applyMiddleware() { 247 | this.applyContentBaseMiddleware() 248 | this.applyMemoryFsMiddleware() 249 | } 250 | 251 | private applyContentBaseMiddleware() { 252 | this.app.use((req, res, next) => { 253 | const contentBase = this.app.get('contentBase') 254 | 255 | if (contentBase) { 256 | return express.static(contentBase)(req, res, next) 257 | } 258 | 259 | return next() 260 | }) 261 | } 262 | 263 | private applyMemoryFsMiddleware() { 264 | this.app.use('/assets', staticFromMemory(this.memoryFs)) 265 | } 266 | 267 | private applyRoutes(): void { 268 | this.options.router?.(this.app) 269 | 270 | this.app.get<{ pageId: string }, string, void, { entry: string }>( 271 | '/preview/:pageId', 272 | async (req, res) => { 273 | this.log('[get] %s', req.url) 274 | const { pageId } = req.params 275 | const page = this.pages.get(pageId) 276 | 277 | if (!page) { 278 | return res.status(404).end() 279 | } 280 | 281 | const chunks = await this.compile(page.entryPath) 282 | const html = this.renderHtml(chunks, pageId) 283 | return res.send(html) 284 | }, 285 | ) 286 | } 287 | 288 | private renderHtml(chunks: Set | null, pageId: string): string { 289 | this.log('rendering html...') 290 | const page = this.pages.get(pageId) 291 | 292 | if (!page) { 293 | return `

Page with ID "${pageId}" not found.

` 294 | } 295 | 296 | const assets = [] 297 | 298 | for (const chunk of chunks || new Set()) { 299 | for (const filename of chunk.files) { 300 | assets.push(filename) 301 | } 302 | } 303 | 304 | const template = fs.readFileSync( 305 | path.resolve(__dirname, 'template.mustache'), 306 | 'utf8', 307 | ) 308 | let markup = '' 309 | const customMarkup = page?.options.markup 310 | 311 | if (customMarkup) { 312 | markup = fs.existsSync(customMarkup) 313 | ? fs.readFileSync(customMarkup, 'utf8') 314 | : customMarkup 315 | } 316 | 317 | const html = render(template, { 318 | title: page?.options.title, 319 | markup, 320 | assets, 321 | }) 322 | this.log('rendered html', '\n', html) 323 | 324 | return html 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/server/template.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | {{{ markup }}} 8 | {{#assets}} 9 | 10 | {{/assets}} 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/utils/asyncCompile.ts: -------------------------------------------------------------------------------- 1 | import { Compiler, Stats } from 'webpack' 2 | 3 | export function asyncCompile(compiler: Compiler): Promise { 4 | return new Promise((resolve, reject) => { 5 | compiler.run((error, stats) => { 6 | if (error) { 7 | return reject(error) 8 | } 9 | 10 | if (typeof stats === 'undefined') { 11 | return reject() 12 | } 13 | 14 | if (stats?.hasErrors()) { 15 | const statsJson = stats.toJson('errors') 16 | return reject(statsJson.errors) 17 | } 18 | 19 | resolve(stats) 20 | }) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/debug.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright' 2 | import { createLogger } from '../internal/createLogger' 3 | 4 | declare namespace window { 5 | export let resume: () => void 6 | } 7 | 8 | const log = createLogger('debug') 9 | 10 | export function debug(page: Page): Promise { 11 | if (!process.env.DEBUG) { 12 | throw new Error( 13 | 'Failed to add a debugging breakpoint: no "DEBUG" environmental variable found.', 14 | ) 15 | } 16 | 17 | log('paused test execution!') 18 | 19 | return new Promise((resolve) => { 20 | page.evaluate(() => { 21 | console.warn(`\ 22 | [pageWith] Paused test execution! 23 | Call "window.resume()" on this page to continue running the test.\ 24 | `) 25 | }) 26 | 27 | return page 28 | .evaluate(() => { 29 | return new Promise((resolve) => { 30 | window.resume = resolve 31 | }) 32 | }) 33 | .then(() => { 34 | log('resumed test execution!') 35 | resolve() 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid' 2 | import { Page, Response } from 'playwright' 3 | import { Headers, headersToObject } from 'headers-polyfill' 4 | import { PreviewServer } from 'src/server/PreviewServer' 5 | 6 | export type RequestHelperFn = ( 7 | url: string, 8 | init?: RequestInit, 9 | predicate?: (res: Response, url: string) => boolean, 10 | ) => Promise 11 | 12 | export function createRequestUtil( 13 | page: Page, 14 | server: PreviewServer, 15 | ): RequestHelperFn { 16 | return (url, init, predicate) => { 17 | const requestId = v4() 18 | const resolvedUrl = url.startsWith('/') 19 | ? new URL(url, server.connectionInfo?.url).toString() 20 | : url 21 | 22 | const fetchOptions = init || {} 23 | const initialHeaders = fetchOptions.headers || {} 24 | const requestHeaders = new Headers(initialHeaders) 25 | 26 | const identityHeaderName = 'accept-language' 27 | requestHeaders.set(identityHeaderName, requestId) 28 | 29 | const resolvedInit = Object.assign({}, fetchOptions, { 30 | headers: headersToObject(requestHeaders), 31 | }) 32 | 33 | page.evaluate(([url, init]) => fetch(url, init), [ 34 | resolvedUrl, 35 | resolvedInit, 36 | ] as [string, RequestInit]) 37 | 38 | const defaultResponsePredicate = (res: Response) => { 39 | const requestHeaders = res.request().headers() 40 | return requestHeaders[identityHeaderName] === requestId 41 | } 42 | 43 | return page.waitForResponse((res) => { 44 | return predicate 45 | ? predicate(res, resolvedUrl) 46 | : defaultResponsePredicate(res) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/spyOnConsole.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright' 2 | import { createLogger } from '../internal/createLogger' 3 | 4 | const log = createLogger('consoleSpy') 5 | 6 | export type ConsoleMessageType = 7 | | 'info' 8 | | 'log' 9 | | 'debug' 10 | | 'error' 11 | | 'warning' 12 | | 'profile' 13 | | 'profileEnd' 14 | | 'table' 15 | | 'trace' 16 | | 'timeEnd' 17 | | 'startGroup' 18 | | 'startGroupCollapsed' 19 | | 'endGroup' 20 | | 'dir' 21 | | 'dirxml' 22 | | 'clear' 23 | | 'count' 24 | | 'assert' 25 | 26 | export type ConsoleMessagesMap = Map 27 | 28 | export type ConsoleMessages = ConsoleMessagesMap & 29 | Map<'raw', ConsoleMessagesMap> 30 | 31 | function removeConsoleStyles(message: string): string { 32 | return message.replace(/\(*(%s|%c|color:\S+)\)*\s*/g, '').trim() 33 | } 34 | 35 | export function spyOnConsole(page: Page): ConsoleMessages { 36 | const messages: ConsoleMessages = new Map() 37 | messages.set('raw', new Map()) 38 | 39 | log('created a page console spy!') 40 | 41 | page.on('console', (message) => { 42 | const messageType = message.type() as ConsoleMessageType 43 | const text = message.text() 44 | const textWithoutStyles = removeConsoleStyles(message.text()) 45 | 46 | log('[%s] %s', messageType, text) 47 | 48 | // Preserve raw console messages. 49 | const prevRawMessages = messages.get('raw')?.get(messageType) || [] 50 | messages.get('raw')?.set(messageType, prevRawMessages.concat(text)) 51 | 52 | // Store formatted console messages (without style positionals). 53 | const prevMessages = messages.get(messageType) || [] 54 | messages.set(messageType, prevMessages.concat(textWithoutStyles)) 55 | }) 56 | 57 | return messages 58 | } 59 | -------------------------------------------------------------------------------- /src/webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { Configuration } from 'webpack' 3 | 4 | export const webpackConfig: Configuration = { 5 | mode: 'development', 6 | target: 'web', 7 | entry: { 8 | main: 'DEFINED_LATER', 9 | }, 10 | output: { 11 | filename: 'main.[chunkhash].js', 12 | }, 13 | resolve: { 14 | modules: ['node_modules', path.resolve(process.cwd(), 'node_modules')], 15 | }, 16 | devtool: false, 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/custom.html: -------------------------------------------------------------------------------- 1 |
Custom markup
2 | -------------------------------------------------------------------------------- /test/fixtures/goodbye.js: -------------------------------------------------------------------------------- 1 | const text = document.createElement('p') 2 | text.setAttribute('id', 'text') 3 | text.innerText = 'goodbye' 4 | document.body.appendChild(text) 5 | -------------------------------------------------------------------------------- /test/fixtures/hello.js: -------------------------------------------------------------------------------- 1 | const text = document.createElement('p') 2 | text.setAttribute('id', 'text') 3 | text.innerText = 'hello' 4 | document.body.appendChild(text) 5 | -------------------------------------------------------------------------------- /test/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { CreateBrowserApi, createBrowser } from '../src' 2 | 3 | let browser: CreateBrowserApi 4 | 5 | beforeAll(async () => { 6 | browser = await createBrowser() 7 | }) 8 | 9 | afterAll(async () => { 10 | await browser.cleanup() 11 | }) 12 | -------------------------------------------------------------------------------- /test/suites/pageWith.test.ts: -------------------------------------------------------------------------------- 1 | import { pageWith } from 'src/index' 2 | 3 | declare namespace window { 4 | export const string: string 5 | export const number: number 6 | export const boolean: boolean 7 | } 8 | 9 | test('opens a browser with the given usage example', async () => { 10 | const { page } = await pageWith({ 11 | example: 'test/fixtures/hello.js', 12 | }) 13 | 14 | const bodyText = await page.textContent('#text') 15 | 16 | expect(bodyText).toBe('hello') 17 | }) 18 | 19 | test('supports multiple independent pages', async () => { 20 | const first = await pageWith({ 21 | example: 'test/fixtures/hello.js', 22 | }) 23 | 24 | const second = await pageWith({ 25 | example: 'test/fixtures/goodbye.js', 26 | }) 27 | 28 | expect(await first.page.textContent('#text')).toBe('hello') 29 | expect(await second.page.textContent('#text')).toBe('goodbye') 30 | }) 31 | 32 | test('supports per-page routes', async () => { 33 | const { page, request } = await pageWith({ 34 | example: 'test/fixtures/hello.js', 35 | routes(app) { 36 | app.get('/user', (req, res) => { 37 | res.json({ firstName: 'John' }) 38 | }) 39 | }, 40 | }) 41 | 42 | const res = await request('/user') 43 | expect(res.status()).toBe(200) 44 | expect(await res.json()).toEqual({ firstName: 'John' }) 45 | 46 | // Close the page to remove its routes. 47 | await page.close() 48 | 49 | const second = await pageWith({ 50 | example: 'test/fixtures/hello.js', 51 | }) 52 | const secondResponse = await second.request('/user') 53 | expect(secondResponse.status()).toBe(404) 54 | }) 55 | 56 | test('supports custom markup', async () => { 57 | const { page } = await pageWith({ 58 | example: 'test/fixtures/hello.js', 59 | markup: 'test/fixtures/custom.html', 60 | }) 61 | 62 | const divContent = await page.textContent('#app') 63 | expect(divContent).toBe('Custom markup') 64 | }) 65 | 66 | test('supports custom content base for the server', async () => { 67 | const { request } = await pageWith({ 68 | example: 'test/fixtures/hello.js', 69 | contentBase: 'test/fixtures', 70 | }) 71 | 72 | const res = await request('/goodbye.js') 73 | expect(res.status()).toBe(200) 74 | expect(res.headers()['content-type']).toContain('application/javascript') 75 | }) 76 | 77 | test('sets the variables from the "env" option on the window', async () => { 78 | const { page } = await pageWith({ 79 | example: 'test/fixtures/hello.js', 80 | env: { 81 | string: 'yes', 82 | number: 2, 83 | boolean: true, 84 | }, 85 | }) 86 | 87 | const [string, number, boolean] = await page.evaluate(() => { 88 | return [window.string, window.number, window.boolean] 89 | }) 90 | 91 | expect(string).toEqual('yes') 92 | expect(number).toEqual(2) 93 | expect(boolean).toEqual(true) 94 | }) 95 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": ["**/*.test.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "allowJs": false, 5 | "module": "CommonJS", 6 | "target": "ES2019", 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "downlevelIteration": true, 11 | "resolveJsonModule": true, 12 | "noImplicitAny": true, 13 | "outDir": "lib", 14 | "skipLibCheck": true, 15 | "declaration": true, 16 | "declarationDir": "lib", 17 | "lib": ["ES2019", "dom"], 18 | "baseUrl": "./", 19 | "paths": { 20 | "src/*": ["src/*"] 21 | } 22 | }, 23 | "include": ["src/**/*.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | --------------------------------------------------------------------------------