├── .npmrc ├── .gitignore ├── .prettierignore ├── eslint.config.js ├── tsconfig.json ├── .editorconfig ├── src ├── debug.ts ├── decorators │ ├── pause.ts │ ├── use.ts │ ├── collection.ts │ ├── visit.ts │ └── assertions.ts ├── base │ ├── base_page.ts │ └── base_interaction.ts ├── plugin │ ├── get_launcher_options.ts │ ├── proxies.ts │ ├── hooks │ │ ├── create_context.ts │ │ └── trace_actions.ts │ ├── normalize_config.ts │ └── main.ts ├── decorate_context.ts ├── decorate_page.ts ├── decorate_browser.ts ├── types │ ├── main.ts │ └── extended.ts └── helpers.ts ├── .github └── workflows │ ├── checks.yml │ ├── labels.yml │ ├── stale.yml │ ├── release.yml │ └── test.yml ├── index.ts ├── tests ├── helpers.ts ├── context │ ├── main.spec.ts │ └── visit.spec.ts ├── plugin │ ├── get_launcher_options.spec.ts │ ├── create_context.spec.ts │ └── normalize_config.spec.ts ├── page │ ├── pause.spec.ts │ └── assertions.spec.ts ├── helpers.spec.ts ├── browser.spec.ts └── interaction.spec.ts ├── factories └── server.ts ├── LICENSE.md ├── bin └── test.ts ├── README.md └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.html 4 | coverage 5 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg({ 3 | ignores: ['coverage'], 4 | }) 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { debuglog } from 'node:util' 11 | 12 | export default debuglog('japa:browser-client') 13 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: japa/browser-client/.github/workflows/test.yml@2.x 10 | with: 11 | install-playwright-browsers: true 12 | 13 | lint: 14 | uses: japa/.github/.github/workflows/lint.yml@next 15 | 16 | typecheck: 17 | uses: japa/.github/.github/workflows/typecheck.yml@next 18 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | workflow_dispatch: 4 | permissions: 5 | issues: write 6 | jobs: 7 | labels: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: EndBug/label-sync@v2 12 | with: 13 | config-file: 'https://raw.githubusercontent.com/thetutlage/static/main/labels.yml' 14 | delete-other-labels: true 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import './src/types/extended.js' 11 | 12 | export { BasePage } from './src/base/base_page.js' 13 | export { browserClient } from './src/plugin/main.js' 14 | export { decorateBrowser } from './src/decorate_browser.js' 15 | export { BaseInteraction } from './src/base/base_interaction.js' 16 | export { decoratorsCollection } from './src/decorators/collection.js' 17 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * Creates markup for a basic HTML document (to avoid repetition in tests) 12 | */ 13 | export const basicDocument = ({ 14 | title = 'Hello world', 15 | body = '', 16 | }: { title?: string; body?: string } = {}) => { 17 | return ` 18 | 19 | 20 | ${title} 21 | 22 | 23 | ${body} 24 | 25 | ` 26 | } 27 | -------------------------------------------------------------------------------- /src/decorators/pause.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Decorator } from '../types/main.js' 11 | 12 | /** 13 | * Decorates the page object with "pauseIf" and "pauseUnless" 14 | * methods. 15 | */ 16 | export const addPauseMethods = { 17 | page(page) { 18 | page.pauseIf = async function (condition) { 19 | if (condition) { 20 | await this.pause() 21 | } 22 | } 23 | 24 | page.pauseUnless = async function (condition) { 25 | if (!condition) { 26 | await this.pause() 27 | } 28 | } 29 | }, 30 | } satisfies Decorator 31 | -------------------------------------------------------------------------------- /src/base/base_page.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { VisitOptions } from '../types/main.js' 11 | import type { Page, BrowserContext } from 'playwright' 12 | 13 | /** 14 | * Base page is used to create class based pages that can be 15 | * used to abstract page interactions. 16 | */ 17 | export class BasePage { 18 | /** 19 | * The URL to visit 20 | */ 21 | declare url: string 22 | 23 | /** 24 | * Options to pass to the visit method 25 | */ 26 | declare visitOptions: VisitOptions 27 | constructor( 28 | public page: Page, 29 | public context: BrowserContext 30 | ) {} 31 | } 32 | -------------------------------------------------------------------------------- /src/decorators/use.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { type BasePage } from '../base/base_page.js' 11 | import type { Decorator } from '../types/main.js' 12 | import { type BaseInteraction } from '../base/base_interaction.js' 13 | 14 | /** 15 | * Decorates the page object with "use" method. 16 | */ 17 | export const addUseMethod = { 18 | page(page) { 19 | page.use = function ( 20 | PageOrInteraction: T 21 | ): InstanceType { 22 | return new PageOrInteraction(this, this.context()) as InstanceType 23 | } 24 | }, 25 | } satisfies Decorator 26 | -------------------------------------------------------------------------------- /src/plugin/get_launcher_options.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { LaunchOptions } from 'playwright' 11 | import type { CLIArgs } from '@japa/runner/types' 12 | 13 | import debug from '../debug.js' 14 | 15 | /** 16 | * Creates launcher options from the tests runner config 17 | */ 18 | export function getLauncherOptions(cliArgs: CLIArgs): LaunchOptions { 19 | const options = { 20 | headless: !cliArgs?.headed, 21 | slowMo: cliArgs?.slow === true ? 100 : Number(cliArgs?.slow) || undefined, 22 | devtools: !!cliArgs?.devtools, 23 | } 24 | 25 | debug('using launcher options %O', options) 26 | return options 27 | } 28 | -------------------------------------------------------------------------------- /src/decorate_context.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { BrowserContext } from 'playwright' 11 | 12 | import { decoratePage } from './decorate_page.js' 13 | import type { Decorator, PluginConfig } from './types/main.js' 14 | 15 | /** 16 | * Decorates the playwright browser context 17 | */ 18 | export function decorateContext( 19 | context: BrowserContext, 20 | decorators: Decorator[], 21 | config: PluginConfig 22 | ): BrowserContext { 23 | decorators.forEach((decorator) => { 24 | if (decorator.context) { 25 | decorator.context(context) 26 | } 27 | }) 28 | 29 | context.on('page', (page) => decoratePage(page, context, decorators, config)) 30 | return context 31 | } 32 | -------------------------------------------------------------------------------- /factories/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' 11 | 12 | export class ServerFactory { 13 | host: string = 'localhost' 14 | port: number = 3000 15 | url: string = `http://${this.host}:${this.port}` 16 | declare server: Server 17 | 18 | create(callback: (req: IncomingMessage, res: ServerResponse) => void | Promise) { 19 | this.server = createServer(callback) 20 | 21 | return new Promise((resolve) => { 22 | this.server.listen(this.port, this.host, () => { 23 | resolve() 24 | }) 25 | }) 26 | } 27 | 28 | close() { 29 | return new Promise((resolve) => this.server.close(() => resolve())) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/decorate_page.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { BrowserContext, Page } from 'playwright' 11 | import { type Decorator, type PluginConfig } from './types/main.js' 12 | 13 | /** 14 | * Decorates the playwright page object 15 | */ 16 | export function decoratePage( 17 | page: Page, 18 | context: BrowserContext, 19 | decorators: Decorator[], 20 | config: PluginConfig 21 | ): Page { 22 | decorators.forEach((decorator) => { 23 | if (decorator.page) { 24 | decorator.page(page, context, config) 25 | } 26 | }) 27 | 28 | page.on('response', (response) => { 29 | decorators.forEach((decorator) => { 30 | if (decorator.response) { 31 | decorator.response(response) 32 | } 33 | }) 34 | }) 35 | 36 | return page 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 0 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue' 13 | stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request' 14 | close-issue-message: 'This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue' 15 | close-pr-message: 'This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request' 16 | days-before-stale: 21 17 | days-before-close: 5 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | permissions: 4 | contents: write 5 | id-token: write 6 | jobs: 7 | checks: 8 | uses: ./.github/workflows/checks.yml 9 | release: 10 | needs: checks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 24 19 | - name: git config 20 | run: | 21 | git config user.name "${GITHUB_ACTOR}" 22 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 23 | - name: Init npm config 24 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: npm install 28 | - run: npm run release -- --ci 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2023 Japa.dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/base/base_interaction.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Page, BrowserContext } from 'playwright' 11 | 12 | /** 13 | * Interactions can be used to de-compose actions on a page to its 14 | * own chainable API. 15 | * 16 | * Each interaction is a promise and actions must be added to the queue 17 | * using the "this.defer" method. 18 | */ 19 | export class BaseInteraction { 20 | #queue: Set<() => Promise> = new Set() 21 | 22 | constructor( 23 | public page: Page, 24 | public context: BrowserContext 25 | ) {} 26 | 27 | /** 28 | * Queue an action to the interaction queue 29 | */ 30 | defer(action: () => Promise): this { 31 | this.#queue.add(action) 32 | return this 33 | } 34 | 35 | /** 36 | * Execute interaction actions 37 | */ 38 | async exec() { 39 | for (let action of this.#queue) { 40 | await action() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { configure, processCLIArgs, run } from '@japa/runner' 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Configure tests 7 | |-------------------------------------------------------------------------- 8 | | 9 | | The configure method accepts the configuration to configure the Japa 10 | | tests runner. 11 | | 12 | | The first method call "processCliArgs" process the command line arguments 13 | | and turns them into a config object. Using this method is not mandatory. 14 | | 15 | | Please consult japa.dev/runner-config for the config docs. 16 | */ 17 | processCLIArgs(process.argv.slice(2)) 18 | configure({ 19 | files: ['tests/**/*.spec.ts'], 20 | plugins: [assert()], 21 | timeout: 8000, 22 | forceExit: true, 23 | }) 24 | 25 | /* 26 | |-------------------------------------------------------------------------- 27 | | Run tests 28 | |-------------------------------------------------------------------------- 29 | | 30 | | The following "run" method is required to execute all the tests. 31 | | 32 | */ 33 | run() 34 | -------------------------------------------------------------------------------- /src/decorators/collection.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { addUseMethod } from './use.js' 11 | import { addVisitMethod } from './visit.js' 12 | import { addPauseMethods } from './pause.js' 13 | import { addAssertions } from './assertions.js' 14 | import type { Decorator } from '../types/main.js' 15 | 16 | /** 17 | * Collection of decorators to apply on an instance of playwright page, 18 | * context, or the response objects. 19 | * 20 | * Since, Playwright does not offer any extensible APIs, we have to apply 21 | * decorators on every instance. 22 | */ 23 | class DecoratorsCollection { 24 | #list: Decorator[] = [addAssertions, addPauseMethods, addUseMethod, addVisitMethod] 25 | 26 | /** 27 | * Register a custom decorator 28 | */ 29 | register(decorator: Decorator): this { 30 | this.#list.push(decorator) 31 | return this 32 | } 33 | 34 | /** 35 | * Returns decorators list 36 | */ 37 | toList() { 38 | return this.#list 39 | } 40 | } 41 | 42 | const decoratorsCollection = new DecoratorsCollection() 43 | export { decoratorsCollection } 44 | -------------------------------------------------------------------------------- /src/plugin/proxies.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * A proxy instance to raise meaningful error when browser 12 | * is accessed from a suite that is not running a browser. 13 | */ 14 | export class BrowserProxy { 15 | constructor(suite: string) { 16 | return new Proxy(this, { 17 | get(_, property) { 18 | throw new Error( 19 | `Cannot access "browser.${String( 20 | property 21 | )}". The browser is not configured to run for "${suite}" suite` 22 | ) 23 | }, 24 | }) 25 | } 26 | } 27 | 28 | /** 29 | * A proxy instance to raise meaningful error when browser context 30 | * is accessed from a suite that is not running a browser. 31 | */ 32 | export class BrowserContextProxy { 33 | constructor(suite: string) { 34 | return new Proxy(this, { 35 | get(_, property) { 36 | throw new Error( 37 | `Cannot access "browserContext.${String( 38 | property 39 | )}". The browser is not configured to run for "${suite}" suite` 40 | ) 41 | }, 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/context/main.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import cookie from 'cookie' 11 | import { test } from '@japa/runner' 12 | import { chromium } from 'playwright' 13 | 14 | import { ServerFactory } from '../../factories/server.js' 15 | import { decorateBrowser } from '../../src/decorate_browser.js' 16 | 17 | test.group('Browser context', () => { 18 | test('get response cookies reflected on context', async ({ assert, cleanup }) => { 19 | const server = new ServerFactory() 20 | await server.create((_, res) => { 21 | res.setHeader('set-cookie', cookie.serialize('user_id', '1')) 22 | res.write('hello world') 23 | res.end() 24 | }) 25 | 26 | const browser = decorateBrowser(await chromium.launch(), []) 27 | 28 | cleanup(async () => { 29 | await browser.close() 30 | await server.close() 31 | }) 32 | 33 | const context = await browser.newContext() 34 | const page = await context.newPage() 35 | assert.deepEqual(await context.cookies(), []) 36 | 37 | await page.goto(server.url) 38 | 39 | const cookies = await context.cookies() 40 | assert.lengthOf(cookies, 1) 41 | assert.equal(cookies[0].value, '1') 42 | assert.equal(cookies[0].name, 'user_id') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/decorate_browser.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Browser as PlayWrightBrowser, BrowserContextOptions } from 'playwright' 11 | 12 | import { decoratePage } from './decorate_page.js' 13 | import { decorateContext } from './decorate_context.js' 14 | import type { Decorator, PluginConfig } from './types/main.js' 15 | 16 | /** 17 | * Decorates the browser by re-writing "newContext" and "newPage" 18 | * methods and making them pass through custom decorators. 19 | */ 20 | export function decorateBrowser( 21 | browser: PlayWrightBrowser, 22 | decorators: Decorator[], 23 | config: PluginConfig = {} 24 | ): PlayWrightBrowser { 25 | const originalNewContext: typeof browser.newContext = browser.newContext.bind(browser) 26 | const originalNewPage: typeof browser.newPage = browser.newPage.bind(browser) 27 | 28 | browser.newContext = async function (options?: BrowserContextOptions) { 29 | const context = await originalNewContext(options) 30 | return decorateContext(context, decorators, config) 31 | } 32 | 33 | browser.newPage = async function (...args: Parameters) { 34 | const page = await originalNewPage(...args) 35 | return decoratePage(page, page.context(), decorators, config) 36 | } 37 | 38 | return browser 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | inputs: 4 | disable-windows: 5 | description: Disable running tests on Windows 6 | type: boolean 7 | default: false 8 | required: false 9 | 10 | install-playwright-browsers: 11 | description: Install playwright browsers before running tests 12 | type: boolean 13 | default: false 14 | required: false 15 | 16 | jobs: 17 | test_linux: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | matrix: 21 | node-version: [24.x, latest] 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | 32 | - name: Install dependencies 33 | run: npm install 34 | 35 | - name: Install Playwright Browsers 36 | if: ${{ inputs.install-playwright-browsers }} 37 | run: npx playwright install --with-deps 38 | 39 | - name: Run tests 40 | run: npm test 41 | 42 | test_windows: 43 | if: ${{ !inputs.disable-windows }} 44 | runs-on: windows-latest 45 | strategy: 46 | matrix: 47 | node-version: [24.x, latest] 48 | 49 | steps: 50 | - name: Checkout code 51 | uses: actions/checkout@v4 52 | 53 | - name: Setup Node.js 54 | uses: actions/setup-node@v4 55 | with: 56 | node-version: ${{ matrix.node-version }} 57 | 58 | - name: Install dependencies 59 | run: npm install 60 | 61 | - name: Install Playwright Browsers 62 | if: ${{ inputs.install-playwright-browsers }} 63 | run: npx playwright install --with-deps 64 | 65 | - name: Run tests 66 | run: npm test 67 | -------------------------------------------------------------------------------- /src/decorators/visit.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Page } from 'playwright' 11 | import type { BasePage } from '../base/base_page.js' 12 | import type { Decorator, VisitOptions } from '../types/main.js' 13 | 14 | /** 15 | * Decorates the context with the visit method. 16 | */ 17 | export const addVisitMethod = { 18 | context(context) { 19 | context.visit = async function ( 20 | UrlOrPage: string | PageModel, 21 | callbackOrOptions?: ((page: InstanceType) => void | Promise) | VisitOptions 22 | ): Promise | Page> { 23 | const page = await context.newPage() 24 | 25 | /** 26 | * If Url is a string, then visit the page 27 | * and return value 28 | */ 29 | if (typeof UrlOrPage === 'string') { 30 | await page.goto(UrlOrPage, callbackOrOptions as VisitOptions) 31 | return page 32 | } 33 | 34 | /** 35 | * Create an instance of the page model 36 | */ 37 | const pageInstance = new UrlOrPage(page, context) 38 | 39 | /** 40 | * Visit the url of the base model 41 | */ 42 | await page.goto(pageInstance.url, pageInstance.visitOptions) 43 | 44 | /** 45 | * Invoke callback if exists 46 | */ 47 | if (typeof callbackOrOptions === 'function') { 48 | await callbackOrOptions(pageInstance as InstanceType) 49 | return 50 | } 51 | 52 | /** 53 | * Otherwise return the page instance back 54 | */ 55 | return pageInstance as InstanceType 56 | } 57 | }, 58 | } satisfies Decorator 59 | -------------------------------------------------------------------------------- /tests/plugin/get_launcher_options.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { getLauncherOptions } from '../../src/plugin/get_launcher_options.js' 12 | 13 | test.group('Launcher options', () => { 14 | test('get launcher options without CLI flags', ({ assert }) => { 15 | assert.deepEqual(getLauncherOptions({}), { 16 | headless: true, 17 | slowMo: undefined, 18 | devtools: false, 19 | }) 20 | }) 21 | 22 | test('parse headed flag', ({ assert }) => { 23 | assert.deepEqual(getLauncherOptions({ headed: true }), { 24 | headless: false, 25 | slowMo: undefined, 26 | devtools: false, 27 | }) 28 | }) 29 | 30 | test('parse slow flag', ({ assert }) => { 31 | assert.deepEqual(getLauncherOptions({ headed: true, slow: true }), { 32 | headless: false, 33 | slowMo: 100, 34 | devtools: false, 35 | }) 36 | }) 37 | 38 | test('parse slow flag numeric value', ({ assert }) => { 39 | assert.deepEqual(getLauncherOptions({ headed: true, slow: '200' }), { 40 | headless: false, 41 | slowMo: 200, 42 | devtools: false, 43 | }) 44 | }) 45 | 46 | test('parse slow flag invalid value', ({ assert }) => { 47 | assert.deepEqual(getLauncherOptions({ headed: true, slow: 'foo' }), { 48 | headless: false, 49 | slowMo: undefined, 50 | devtools: false, 51 | }) 52 | }) 53 | 54 | test('parse devtools flag', ({ assert }) => { 55 | assert.deepEqual(getLauncherOptions({ headed: true, slow: 'foo', devtools: true }), { 56 | headless: false, 57 | slowMo: undefined, 58 | devtools: true, 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/types/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { 11 | Page, 12 | Browser, 13 | Response, 14 | LaunchOptions, 15 | BrowserContext, 16 | BrowserContextOptions, 17 | } from 'playwright' 18 | 19 | /** 20 | * Decorators are used to extend the `page`, `context`, and the 21 | * `response` objects. Since Playwright does not exports classes, 22 | * we cannot use inheritance and have to decorate objects 23 | * directly 24 | */ 25 | export type Decorator = { 26 | page?: (page: Page, context: BrowserContext, config: PluginConfig) => void 27 | context?: (context: BrowserContext) => void 28 | response?: (response: Response) => void 29 | } 30 | 31 | /** 32 | * Options for the visit method 33 | */ 34 | export type VisitOptions = Exclude[1], undefined> 35 | 36 | /** 37 | * Configuration accepted by the plugin. 38 | */ 39 | export type PluginConfig = { 40 | /** 41 | * Control automatic tracing of tests 42 | */ 43 | tracing?: { 44 | enabled: boolean 45 | event: 'onError' | 'onTest' 46 | cleanOutputDirectory: boolean 47 | outputDirectory: string 48 | } 49 | 50 | /** 51 | * Options for the context created for every test 52 | */ 53 | contextOptions?: BrowserContextOptions 54 | 55 | /** 56 | * Options for built-in assertions 57 | */ 58 | assertions?: { 59 | timeout?: number 60 | pollIntervals?: number[] 61 | } 62 | 63 | /** 64 | * Lazily launch a browser. 65 | */ 66 | launcher?: (config: Pick) => Promise 67 | 68 | /** 69 | * An optional array of suites that will be interacting with the 70 | * browser. It is recommended to configure this plugin for 71 | * specific suite, otherwise a browser instance will be 72 | * created for all tests across all the suites. 73 | */ 74 | runInSuites?: string[] 75 | } 76 | -------------------------------------------------------------------------------- /src/plugin/hooks/create_context.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Test } from '@japa/runner/core' 11 | import type { Browser as PlayWrightBrowser } from 'playwright' 12 | 13 | import debug from '../../debug.js' 14 | import type { PluginConfig } from '../../types/main.js' 15 | import { BrowserContextProxy, BrowserProxy } from '../proxies.js' 16 | 17 | /** 18 | * Test hook to create a fresh browser context for each 19 | * test 20 | */ 21 | export async function createContextHook( 22 | browser: PlayWrightBrowser, 23 | config: PluginConfig, 24 | test: Test 25 | ) { 26 | const host = process.env.HOST 27 | const port = process.env.PORT 28 | const context = test.context! 29 | debug('creating browser context for test "%s"', context.test.title) 30 | 31 | /** 32 | * Share browser, context and visit method with the test 33 | * context. 34 | */ 35 | context.browser = browser 36 | context.browserContext = await browser.newContext({ 37 | baseURL: host && port ? `http://${host}:${port}` : undefined, 38 | ...config.contextOptions, 39 | }) 40 | 41 | context.visit = context.browserContext.visit.bind(context.browserContext) 42 | 43 | return () => { 44 | debug('closing browser context for test "%s"', context.test.title) 45 | return context.browserContext.close() 46 | } 47 | } 48 | 49 | /** 50 | * Test hook to create a fake browser context for tests that are 51 | * not configured to interact with browsers 52 | */ 53 | export function createFakeContextHook(test: Test) { 54 | const suiteName = test.options.meta.suite.name 55 | 56 | test.context!.browser = new BrowserProxy(suiteName) as any 57 | test.context!.browserContext = new BrowserContextProxy(suiteName) as any 58 | test.context!.visit = async function () { 59 | throw new Error( 60 | `Cannot access call "visit". The browser is not configured to run for "${suiteName}" suite` 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/page/pause.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import sinon from 'sinon' 11 | import { test } from '@japa/runner' 12 | import { chromium } from 'playwright' 13 | 14 | import { addPauseMethods } from '../../src/decorators/pause.js' 15 | import { decorateBrowser } from '../../index.js' 16 | 17 | test.group('Page | pauseIf', () => { 18 | test('pause if condition is true', async ({ assert, cleanup }) => { 19 | const browser = decorateBrowser(await chromium.launch(), [addPauseMethods]) 20 | cleanup(() => browser.close()) 21 | 22 | const page = await browser.newPage() 23 | 24 | const pause = sinon.spy(page, 'pause') 25 | await page.pauseIf(true) 26 | 27 | assert.isTrue(pause.calledOnce) 28 | }) 29 | 30 | test('do not pause if condition is false', async ({ assert, cleanup }) => { 31 | const browser = decorateBrowser(await chromium.launch(), [addPauseMethods]) 32 | cleanup(() => browser.close()) 33 | 34 | const page = await browser.newPage() 35 | 36 | const pause = sinon.spy(page, 'pause') 37 | await page.pauseIf(false) 38 | 39 | assert.isFalse(pause.calledOnce) 40 | }) 41 | }) 42 | 43 | test.group('Page | pauseUnless', () => { 44 | test('pause if condition is false', async ({ assert, cleanup }) => { 45 | const browser = decorateBrowser(await chromium.launch(), [addPauseMethods]) 46 | cleanup(() => browser.close()) 47 | 48 | const page = await browser.newPage() 49 | 50 | const pause = sinon.spy(page, 'pause') 51 | await page.pauseUnless(false) 52 | 53 | assert.isTrue(pause.calledOnce) 54 | }) 55 | 56 | test('do not pause if condition is true', async ({ assert, cleanup }) => { 57 | const browser = decorateBrowser(await chromium.launch(), [addPauseMethods]) 58 | cleanup(() => browser.close()) 59 | 60 | const page = await browser.newPage() 61 | 62 | const pause = sinon.spy(page, 'pause') 63 | await page.pauseUnless(true) 64 | 65 | assert.isFalse(pause.calledOnce) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/plugin/hooks/trace_actions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { rm } from 'node:fs/promises' 12 | import slugify from '@sindresorhus/slugify' 13 | import type { Suite, Test } from '@japa/runner/core' 14 | 15 | import debug from '../../debug.js' 16 | import type { PluginConfig } from '../../types/main.js' 17 | 18 | /** 19 | * Tests hook to trace actions 20 | */ 21 | export async function traceActionsHook( 22 | tracingConfig: Exclude, 23 | test: Test 24 | ) { 25 | const suiteName = test.options.meta.suite.name 26 | 27 | /** 28 | * Trace action when tracing is enabled 29 | */ 30 | await test.context!.browserContext.tracing.start({ 31 | title: test.title, 32 | screenshots: true, 33 | snapshots: true, 34 | sources: true, 35 | }) 36 | 37 | /** 38 | * Store tracing artefacts on disk on error 39 | * when "tracing.event === 'onError'" 40 | */ 41 | if (tracingConfig.event === 'onError') { 42 | return async (hasError: boolean) => { 43 | if (hasError) { 44 | await test.context!.browserContext.tracing.stop({ 45 | path: join(tracingConfig.outputDirectory, suiteName, `${slugify(test.title)}.zip`), 46 | }) 47 | } else { 48 | await test.context!.browserContext.tracing.stop() 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Store tracing artifact on disk everyone 55 | * when "tracing.event === 'onTest'" 56 | */ 57 | return async () => { 58 | await test.context!.browserContext.tracing.stop({ 59 | path: join(tracingConfig.outputDirectory, suiteName, `${slugify(test.title)}.zip`), 60 | }) 61 | } 62 | } 63 | 64 | /** 65 | * Suite hook to clean traces output directory 66 | */ 67 | export async function cleanTracesHook(suite: Suite, outputDirectory: string) { 68 | const suiteDirectory = join(outputDirectory, suite.name) 69 | debug('removing traces output from %s location', suiteDirectory) 70 | await rm(suiteDirectory, { recursive: true, force: true, maxRetries: 4 }) 71 | } 72 | -------------------------------------------------------------------------------- /tests/plugin/create_context.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { chromium } from 'playwright' 11 | import { test } from '@japa/runner' 12 | import { Test, Emitter, Refiner, TestContext } from '@japa/runner/core' 13 | 14 | import { decorateBrowser } from '../../index.js' 15 | import { addVisitMethod } from '../../src/decorators/visit.js' 16 | import { createContextHook, createFakeContextHook } from '../../src/plugin/hooks/create_context.js' 17 | 18 | test.group('Create context', () => { 19 | test('create browser context and assign it to test context', async ({ assert, cleanup }) => { 20 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod]) 21 | cleanup(() => browser.close()) 22 | 23 | const emitter = new Emitter() 24 | const refiner = new Refiner() 25 | 26 | const createTestContext = (self: Test) => new TestContext(self) 27 | const t = new Test('a sample test', createTestContext, emitter, refiner) 28 | t.context = createTestContext(t) 29 | 30 | await createContextHook(browser, {}, t) 31 | 32 | assert.isDefined(t.context.browserContext) 33 | assert.strictEqual(t.context.browser, browser) 34 | }) 35 | 36 | test('assign fake context and browser to test context', async ({ assert, cleanup }) => { 37 | const browser = await chromium.launch() 38 | cleanup(() => browser.close()) 39 | 40 | const emitter = new Emitter() 41 | const refiner = new Refiner() 42 | 43 | const createTestContext = (self: Test) => new TestContext(self) 44 | const t = new Test('a sample test', createTestContext, emitter, refiner) 45 | t.context = createTestContext(t) 46 | t.options.meta = { 47 | suite: { 48 | name: 'unit', 49 | }, 50 | } 51 | 52 | createFakeContextHook(t) 53 | assert.throws( 54 | () => t.context!.browser.newPage(), 55 | 'Cannot access "browser.newPage". The browser is not configured to run for "unit" suite' 56 | ) 57 | 58 | assert.throws( 59 | () => t.context!.browserContext.newPage(), 60 | 'Cannot access "browserContext.newPage". The browser is not configured to run for "unit" suite' 61 | ) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/plugin/normalize_config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import type { CLIArgs } from '@japa/runner/types' 12 | import { chromium, firefox, webkit } from 'playwright' 13 | 14 | import type { PluginConfig } from '../types/main.js' 15 | 16 | /** 17 | * Default launchers that can be selected using the '--browser' flag 18 | */ 19 | const DEFAULT_LAUNCHERS: Record = { 20 | chromium: (launcherOptions) => chromium.launch(launcherOptions), 21 | firefox: (launcherOptions) => firefox.launch(launcherOptions), 22 | webkit: (launcherOptions) => webkit.launch(launcherOptions), 23 | } 24 | 25 | /** 26 | * Default configuration for assertions added to `page` 27 | */ 28 | export const DEFAULT_ASSERTIONS_CONFIG: Required> = { 29 | timeout: 5000, 30 | pollIntervals: [100, 250, 500, 1000], 31 | } 32 | 33 | /** 34 | * Normalizes the user defined config 35 | */ 36 | export function normalizeConfig(cliArgs: CLIArgs, config: PluginConfig) { 37 | const tracingEvent = cliArgs?.trace as string | undefined 38 | if (tracingEvent && !['onError', 'onTest'].includes(tracingEvent)) { 39 | throw new Error( 40 | `Invalid tracing event "${tracingEvent}". Use --trace="onTest" or --trace="onError"` 41 | ) 42 | } 43 | 44 | /** 45 | * Enable tracing when tracing event is defined 46 | */ 47 | if (tracingEvent) { 48 | config.tracing = Object.assign( 49 | config.tracing || { 50 | outputDirectory: join(process.cwd(), './'), 51 | cleanOutputDirectory: true, 52 | }, 53 | { 54 | enabled: true, 55 | event: tracingEvent as 'onError' | 'onTest', 56 | } 57 | ) 58 | } 59 | 60 | return { 61 | ...config, 62 | launcher: 63 | config.launcher || 64 | (async (launcherOptions) => { 65 | const browser = cliArgs?.browser || 'chromium' 66 | const launcher = DEFAULT_LAUNCHERS[browser as keyof typeof DEFAULT_LAUNCHERS] 67 | 68 | /** 69 | * Invalid browser specified via "--browser" flag 70 | */ 71 | if (!launcher) { 72 | throw new Error( 73 | `Invalid browser "${browser}". Allowed values are ${Object.keys(DEFAULT_LAUNCHERS).join( 74 | ', ' 75 | )}` 76 | ) 77 | } 78 | 79 | return launcher(launcherOptions) 80 | }), 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @japa/browser-client 2 | 3 | > Browser client to write end to end browser tests. Uses playwright under the hood 4 | 5 | [![gh-actions-image]][gh-actions-url] [![npm-image]][npm-url] [![license-image]][license-url] ![typescript-image] 6 | 7 | The browser client of Japa is built on top of [Playwright library](https://playwright.dev/docs/library) and integrates seamlessly with the Japa test runner. Following are some reasons to use this plugin over manually interacting with the Playwright API. 8 | 9 | - Automatic management of browsers and browser contexts. 10 | - Built-in assertions. 11 | - Ability to extend the `browser`, `context`, and `page` objects using [decorators](#decorators). 12 | - Class-based pages and interactions to de-compose the page under test into smaller and reusable components. 13 | - Toggle headless mode, tracing, and browsers using CLI flags. 14 | 15 | #### [Complete documentation](https://japa.dev/docs/plugins/browser-client) 16 | 17 | ## Installation 18 | 19 | Install the package from the npm registry as follows: 20 | 21 | ```sh 22 | npm i -D playwright @japa/browser-client 23 | 24 | yarn add -D playwright @japa/browser-client 25 | ``` 26 | 27 | ## Usage 28 | 29 | You can use the browser client package with the `@japa/runner` as follows. 30 | 31 | ```ts 32 | import { assert } from '@japa/assert' 33 | import { browserClient } from '@japa/browser-client' 34 | import { configure, processCliArgs } from '@japa/runner' 35 | 36 | configure({ 37 | ...processCliArgs(process.argv.slice(2)), 38 | ...{ 39 | plugins: [ 40 | assert(), 41 | browserClient({ 42 | runInSuites: ['browser'], 43 | }), 44 | ], 45 | }, 46 | }) 47 | ``` 48 | 49 | Once done, you will be able to access the `visit`, `browser` and `browserContext` property from the test context. 50 | 51 | ```ts 52 | test('test title', ({ browser, browserContext, visit }) => { 53 | // Create new page 54 | const page = await browserContext.newPage() 55 | await page.goto(url) 56 | 57 | // Or use visit helper 58 | const page = await visit(url) 59 | 60 | // Create multiple contexts 61 | const context1 = await browser.newContext() 62 | const context2 = await browser.newContext() 63 | }) 64 | ``` 65 | 66 | [gh-actions-image]: https://img.shields.io/github/actions/workflow/status/japa/browser-client/checks.yml?style=for-the-badge 67 | [gh-actions-url]: https://github.com/japa/browser-client/actions/workflows/checks.yml 'Github action' 68 | [npm-image]: https://img.shields.io/npm/v/@japa/browser-client/latest.svg?style=for-the-badge&logo=npm 69 | [npm-url]: https://www.npmjs.com/package/@japa/browser-client/v/latest 'npm' 70 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 71 | [license-url]: LICENSE.md 72 | [license-image]: https://img.shields.io/github/license/japa/browser-client?style=for-the-badge 73 | -------------------------------------------------------------------------------- /tests/plugin/normalize_config.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { test } from '@japa/runner' 12 | import { normalizeConfig } from '../../src/plugin/normalize_config.js' 13 | 14 | test.group('Normalize config', () => { 15 | test('launch chromium browser', async ({ assert, cleanup }) => { 16 | const browser = await normalizeConfig({}, {}).launcher({}) 17 | 18 | cleanup(() => browser.close()) 19 | assert.equal(browser.browserType().name(), 'chromium') 20 | }) 21 | 22 | test('launch firefox browser', async ({ assert, cleanup }) => { 23 | const browser = await normalizeConfig({ browser: 'firefox' }, {}).launcher({}) 24 | 25 | cleanup(() => browser.close()) 26 | assert.equal(browser.browserType().name(), 'firefox') 27 | }) 28 | 29 | test('launch webkit browser', async ({ assert, cleanup }) => { 30 | const browser = await normalizeConfig({ browser: 'webkit' }, {}).launcher({}) 31 | 32 | cleanup(() => browser.close()) 33 | assert.equal(browser.browserType().name(), 'webkit') 34 | }) 35 | 36 | test('raise error when invalid browser is mentioned', async ({ assert }) => { 37 | await assert.rejects( 38 | () => normalizeConfig({ browser: 'chrome' }, {}).launcher({}), 39 | 'Invalid browser "chrome". Allowed values are chromium, firefox, webkit' 40 | ) 41 | }) 42 | 43 | test('enable tracing when --trace flag is used', async ({ assert }) => { 44 | const config = normalizeConfig({ browser: 'webkit', trace: 'onError' }, {}) 45 | 46 | assert.deepEqual(config.tracing, { 47 | enabled: true, 48 | event: 'onError', 49 | cleanOutputDirectory: true, 50 | outputDirectory: join(process.cwd(), './'), 51 | }) 52 | }) 53 | 54 | test('overwrite inline tracing config when --trace flag is mentioned', async ({ assert }) => { 55 | const config = normalizeConfig( 56 | { browser: 'webkit', trace: 'onTest' }, 57 | { 58 | tracing: { 59 | enabled: false, 60 | event: 'onError', 61 | cleanOutputDirectory: true, 62 | outputDirectory: './foo', 63 | }, 64 | } 65 | ) 66 | 67 | assert.deepEqual(config.tracing, { 68 | cleanOutputDirectory: true, 69 | enabled: true, 70 | event: 'onTest', 71 | outputDirectory: './foo', 72 | }) 73 | }) 74 | 75 | test('throw error when tracing event is invalid', async ({ assert }) => { 76 | assert.throws( 77 | () => 78 | normalizeConfig( 79 | { browser: 'webkit', trace: 'yes' }, 80 | { 81 | tracing: { 82 | enabled: false, 83 | event: 'onError', 84 | cleanOutputDirectory: true, 85 | outputDirectory: './foo', 86 | }, 87 | } 88 | ), 89 | 'Invalid tracing event "yes". Use --trace="onTest" or --trace="onError"' 90 | ) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { setTimeout } from 'node:timers/promises' 11 | 12 | /** 13 | * Checks if one object is a subset of an another object 14 | */ 15 | export const isSubsetOf = (superset: Record, subset: Record): boolean => { 16 | if ( 17 | typeof superset !== 'object' || 18 | superset === null || 19 | typeof subset !== 'object' || 20 | subset === null 21 | ) { 22 | return false 23 | } 24 | 25 | return Object.keys(subset).every((key) => { 26 | if (!superset.propertyIsEnumerable(key)) { 27 | return false 28 | } 29 | 30 | const subsetItem = subset[key] 31 | const supersetItem = superset[key] 32 | 33 | if (typeof subsetItem === 'object' && subsetItem !== null) { 34 | return isSubsetOf(supersetItem, subsetItem) 35 | } 36 | if (supersetItem !== subsetItem) { 37 | return false 38 | } 39 | return true 40 | }) 41 | } 42 | 43 | /** 44 | * Retries async function until an invocation of the callback does not throw any error or the time 45 | * runs out. 46 | * If the time runs out, the last thrown error will be re-thrown 47 | */ 48 | export async function retryTest( 49 | options: { 50 | pollIntervals: number[] 51 | timeout: number 52 | }, 53 | callback: () => Promise 54 | ) { 55 | type ResultObject = { error?: Error } 56 | 57 | const { pollIntervals, timeout } = options 58 | const remainingIntervals = pollIntervals.slice(0, -1) 59 | const repeatedLastInterval = pollIntervals[pollIntervals.length - 1] 60 | let hasStopped = false 61 | let lastError: Error 62 | let lastAttemptCallback: Promise 63 | 64 | // Attempt until callback does not throw, or `hasStopped` has been set due to timeout 65 | const attempt = async () => { 66 | try { 67 | lastAttemptCallback = callback() 68 | await lastAttemptCallback 69 | hasStopped = true 70 | return {} 71 | } catch (error) { 72 | if (hasStopped) return {} // Better not schedule a useless timeout if we can avoid it 73 | 74 | lastError = error 75 | const currentInterval = remainingIntervals.shift() ?? repeatedLastInterval 76 | await setTimeout(currentInterval) 77 | 78 | if (hasStopped) return {} 79 | return attempt() 80 | } 81 | } 82 | 83 | // Fail after timeout, but only if `hasStopped` was not set by a successful attempt 84 | const failAfterTimeout = () => 85 | setTimeout(timeout).then(async () => { 86 | if (hasStopped) return {} 87 | 88 | hasStopped = true 89 | 90 | // If there's no last error, it means that the first callback invocation never finished, 91 | // let's not wait for it. 92 | if (!lastError) { 93 | return { error: new Error('retryTest: Callback ran out of time') } 94 | } else { 95 | // Otherwise lets wait for the last attempt to finish cleanly 96 | await lastAttemptCallback.catch((e) => (lastError = e)) 97 | return { error: lastError } 98 | } 99 | }) 100 | 101 | const { error } = await Promise.race([attempt(), failAfterTimeout()]) 102 | 103 | if (error) { 104 | error.message += `, timed out after ${timeout}ms` 105 | throw error 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/plugin/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { type Suite } from '@japa/runner/core' 11 | import { type PluginFn } from '@japa/runner/types' 12 | import type { Browser as PlayWrightBrowser } from 'playwright' 13 | 14 | import debug from '../debug.js' 15 | import type { PluginConfig } from '../types/main.js' 16 | import { normalizeConfig } from './normalize_config.js' 17 | import { decorateBrowser } from '../decorate_browser.js' 18 | import { getLauncherOptions } from './get_launcher_options.js' 19 | import { decoratorsCollection } from '../decorators/collection.js' 20 | import { cleanTracesHook, traceActionsHook } from './hooks/trace_actions.js' 21 | import { createContextHook, createFakeContextHook } from './hooks/create_context.js' 22 | 23 | /** 24 | * Browser client plugin configures the lifecycle hooks to 25 | * create playwright browser instances and browser context 26 | * when running a test or a suite. 27 | */ 28 | export function browserClient(config: PluginConfig) { 29 | const clientPlugin: PluginFn = function (japa) { 30 | const normalizedConfig = normalizeConfig(japa.cliArgs, config) 31 | const launcherOptions = getLauncherOptions(japa.cliArgs) 32 | 33 | /** 34 | * Hooking into a suite to launch the browser and context 35 | */ 36 | japa.runner.onSuite((suite: Suite) => { 37 | let browser: PlayWrightBrowser 38 | 39 | const shouldInitiateBrowser = !config.runInSuites || config.runInSuites.includes(suite.name) 40 | const isTracingEnabled = normalizedConfig.tracing && normalizedConfig.tracing.enabled 41 | const shouldCleanOutputDirectory = 42 | isTracingEnabled && 43 | normalizedConfig.tracing!.cleanOutputDirectory && 44 | normalizedConfig.tracing!.outputDirectory 45 | 46 | /** 47 | * Launching the browser on the suite setup and closing 48 | * after all tests of the suite are done. 49 | */ 50 | if (shouldInitiateBrowser) { 51 | /** 52 | * Clean output directory before running suite tests 53 | */ 54 | if (shouldCleanOutputDirectory) { 55 | suite.setup(() => cleanTracesHook(suite, normalizedConfig.tracing!.outputDirectory)) 56 | } 57 | 58 | suite.setup(async () => { 59 | debug('initiating browser for suite "%s"', suite.name) 60 | browser = decorateBrowser( 61 | await normalizedConfig.launcher(launcherOptions), 62 | decoratorsCollection.toList(), 63 | config 64 | ) 65 | 66 | return () => { 67 | debug('closing browser for suite "%s"', suite.name) 68 | return browser.close() 69 | } 70 | }) 71 | } 72 | 73 | /** 74 | * Hooks for all the tests inside a group 75 | */ 76 | suite.onGroup((group) => { 77 | if (shouldInitiateBrowser) { 78 | group.each.setup((test) => createContextHook(browser, config, test)) 79 | if (isTracingEnabled) { 80 | group.each.setup((test) => traceActionsHook(normalizedConfig.tracing!, test)) 81 | } 82 | } else { 83 | group.each.setup((test) => createFakeContextHook(test)) 84 | } 85 | }) 86 | 87 | /** 88 | * Hooks for all top level tests inside a suite 89 | */ 90 | suite.onTest((test) => { 91 | if (shouldInitiateBrowser) { 92 | test.setup((self) => createContextHook(browser, config, self)) 93 | if (isTracingEnabled) { 94 | test.setup((self) => traceActionsHook(normalizedConfig.tracing!, self)) 95 | } 96 | } else { 97 | test.setup((self) => createFakeContextHook(self)) 98 | } 99 | }) 100 | }) 101 | } 102 | 103 | return clientPlugin 104 | } 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@japa/browser-client", 3 | "description": "Browser client built on top of Playwright for writing end to end tests", 4 | "version": "2.2.0", 5 | "engines": { 6 | "node": ">=18.16.0" 7 | }, 8 | "type": "module", 9 | "files": [ 10 | "build", 11 | "!build/bin", 12 | "!build/factories", 13 | "!build/tests" 14 | ], 15 | "exports": { 16 | ".": "./build/index.js", 17 | "./types": "./build/src/types/main.js" 18 | }, 19 | "scripts": { 20 | "pretest": "npm run lint && npm run typecheck", 21 | "test": "cross-env NODE_DEBUG=japa:browser-client c8 npm run quick:test", 22 | "lint": "eslint .", 23 | "format": "prettier --write .", 24 | "typecheck": "tsc --noEmit", 25 | "clean": "del-cli build", 26 | "precompile": "npm run lint && npm run clean", 27 | "compile": "tsdown && tsc --emitDeclarationOnly --declaration", 28 | "build": "npm run compile", 29 | "version": "npm run build", 30 | "prepublishOnly": "npm run build", 31 | "release": "release-it", 32 | "quick:test": "node --import=@poppinss/ts-exec --enable-source-maps bin/test.ts" 33 | }, 34 | "devDependencies": { 35 | "@adonisjs/eslint-config": "^3.0.0-next.5", 36 | "@adonisjs/prettier-config": "^1.4.5", 37 | "@adonisjs/tsconfig": "^2.0.0-next.3", 38 | "@japa/assert": "^4.2.0", 39 | "@japa/runner": "^5.0.0", 40 | "@poppinss/ts-exec": "^1.4.1", 41 | "@release-it/conventional-changelog": "^10.0.3", 42 | "@types/cookie": "^1.0.0", 43 | "@types/node": "^25.0.1", 44 | "@types/sinon": "^21.0.0", 45 | "c8": "^10.1.3", 46 | "cookie": "^1.1.1", 47 | "cross-env": "^10.1.0", 48 | "del-cli": "^7.0.0", 49 | "eslint": "^9.39.2", 50 | "playwright": "^1.57.0", 51 | "prettier": "^3.7.4", 52 | "release-it": "^19.1.0", 53 | "sinon": "^21.0.0", 54 | "tsdown": "^0.17.3", 55 | "typescript": "^5.9.3" 56 | }, 57 | "dependencies": { 58 | "@poppinss/qs": "^6.15.0", 59 | "@sindresorhus/slugify": "^3.0.0" 60 | }, 61 | "peerDependencies": { 62 | "@japa/assert": "^2.0.0 || ^3.0.0 || ^4.0.0", 63 | "@japa/runner": "^3.1.2 || ^4.0.0 || ^5.0.0", 64 | "playwright": "^1.57.0" 65 | }, 66 | "homepage": "https://github.com/japa/browser-client#readme", 67 | "repository": { 68 | "type": "git", 69 | "url": "git+https://github.com/japa/browser-client.git" 70 | }, 71 | "bugs": { 72 | "url": "https://github.com/japa/browser-client/issues" 73 | }, 74 | "keywords": [ 75 | "playwright", 76 | "japa", 77 | "japa-plugin" 78 | ], 79 | "author": "Harminder Virk ", 80 | "license": "MIT", 81 | "publishConfig": { 82 | "access": "public", 83 | "provenance": true 84 | }, 85 | "tsdown": { 86 | "entry": [ 87 | "./index.ts", 88 | "./src/types/main.ts" 89 | ], 90 | "outDir": "./build", 91 | "clean": true, 92 | "format": "esm", 93 | "minify": "dce-only", 94 | "fixedExtension": false, 95 | "dts": false, 96 | "treeshake": false, 97 | "sourcemaps": false, 98 | "target": "esnext" 99 | }, 100 | "release-it": { 101 | "git": { 102 | "requireCleanWorkingDir": true, 103 | "requireUpstream": true, 104 | "commitMessage": "chore(release): ${version}", 105 | "tagAnnotation": "v${version}", 106 | "push": true, 107 | "tagName": "v${version}" 108 | }, 109 | "github": { 110 | "release": true 111 | }, 112 | "npm": { 113 | "publish": true, 114 | "skipChecks": true 115 | }, 116 | "plugins": { 117 | "@release-it/conventional-changelog": { 118 | "preset": { 119 | "name": "angular" 120 | } 121 | } 122 | } 123 | }, 124 | "c8": { 125 | "reporter": [ 126 | "text", 127 | "html" 128 | ], 129 | "exclude": [ 130 | "tests/**" 131 | ] 132 | }, 133 | "prettier": "@adonisjs/prettier-config" 134 | } 135 | -------------------------------------------------------------------------------- /tests/context/visit.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { chromium } from 'playwright' 12 | 13 | import { BasePage } from '../../src/base/base_page.js' 14 | import { ServerFactory } from '../../factories/server.js' 15 | import { addVisitMethod } from '../../src/decorators/visit.js' 16 | import { decorateBrowser } from '../../src/decorate_browser.js' 17 | 18 | test.group('Visit', () => { 19 | test('visit a url', async ({ assert, cleanup }) => { 20 | const server = new ServerFactory() 21 | await server.create((_, res) => { 22 | res.write('hello world') 23 | res.end() 24 | }) 25 | 26 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod]) 27 | cleanup(async () => { 28 | await browser.close() 29 | await server.close() 30 | }) 31 | 32 | const context = await browser.newContext() 33 | const page = await context.visit(server.url) 34 | assert.equal(await page.locator('body').innerText(), 'hello world') 35 | }) 36 | 37 | test('define options via visit method', async ({ assert, cleanup }) => { 38 | const server = new ServerFactory() 39 | await server.create((req, res) => { 40 | res.write(req.headers.referer) 41 | res.end() 42 | }) 43 | 44 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod]) 45 | cleanup(async () => { 46 | await browser.close() 47 | await server.close() 48 | }) 49 | 50 | const context = await browser.newContext() 51 | const page = await context.visit(server.url, { referer: 'http://foo.com' }) 52 | assert.equal(await page.locator('body').innerText(), 'http://foo.com/') 53 | }) 54 | 55 | test('visit a page model', async ({ assert, cleanup }) => { 56 | const server = new ServerFactory() 57 | await server.create((_, res) => { 58 | res.write('hello world') 59 | res.end() 60 | }) 61 | 62 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod]) 63 | cleanup(async () => { 64 | await browser.close() 65 | await server.close() 66 | }) 67 | 68 | class HomePage extends BasePage { 69 | url = server.url 70 | 71 | async assertBody() { 72 | assert.equal(await this.page.locator('body').innerText(), 'hello world') 73 | } 74 | } 75 | 76 | const context = await browser.newContext() 77 | const page = await context.visit(HomePage) 78 | await page.assertBody() 79 | }) 80 | 81 | test('define options using visitOptions class property', async ({ assert, cleanup }) => { 82 | const server = new ServerFactory() 83 | await server.create((req, res) => { 84 | res.write(req.headers.referer) 85 | res.end() 86 | }) 87 | 88 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod]) 89 | cleanup(async () => { 90 | await browser.close() 91 | await server.close() 92 | }) 93 | 94 | class HomePage extends BasePage { 95 | url = server.url 96 | visitOptions = { referer: 'http://foo.com' } 97 | 98 | async assertBody() { 99 | assert.equal(await this.page.locator('body').innerText(), 'http://foo.com/') 100 | } 101 | } 102 | 103 | const context = await browser.newContext() 104 | const page = await context.visit(HomePage) 105 | await page.assertBody() 106 | }) 107 | 108 | test('get access to page model inside callback', async ({ assert, cleanup }) => { 109 | assert.plan(1) 110 | 111 | const server = new ServerFactory() 112 | await server.create((_, res) => { 113 | res.write('hello world') 114 | res.end() 115 | }) 116 | 117 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod]) 118 | cleanup(async () => { 119 | await browser.close() 120 | await server.close() 121 | }) 122 | 123 | class HomePage extends BasePage { 124 | url = server.url 125 | 126 | async assertBody() { 127 | assert.equal(await this.page.locator('body').innerText(), 'hello world') 128 | } 129 | } 130 | 131 | const context = await browser.newContext() 132 | await context.visit(HomePage, (page) => page.assertBody()) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /tests/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { setTimeout } from 'node:timers/promises' 11 | 12 | import { test } from '@japa/runner' 13 | import { retryTest } from '../src/helpers.js' 14 | 15 | test.group('Helpers', () => { 16 | test('retryTest retries until callback succeeds', async ({ assert }) => { 17 | const attemptTimes: Date[] = [] 18 | const startTime = new Date() 19 | 20 | await assert.doesNotReject(() => 21 | retryTest( 22 | { 23 | timeout: 500, 24 | pollIntervals: [100], 25 | }, 26 | async () => { 27 | attemptTimes.push(new Date()) 28 | const timeSinceStart = new Date().getTime() - startTime.getTime() 29 | if (timeSinceStart < 300) { 30 | throw new Error('Expected error') 31 | } 32 | } 33 | ) 34 | ) 35 | 36 | assert.lengthOf(attemptTimes, 4) // 0, 100, 200, 300 37 | }) 38 | 39 | test('retryTest fails when not successful before end of timeout', async ({ assert }) => { 40 | const startTime = new Date() 41 | 42 | await assert.rejects( 43 | () => 44 | retryTest( 45 | { 46 | timeout: 100, 47 | pollIntervals: [20], 48 | }, 49 | async () => { 50 | throw new Error('Expected error') 51 | } 52 | ), 53 | /Expected error, timed out after 100ms/ 54 | ) 55 | const endTime = new Date() 56 | 57 | const actualTimeout = endTime.getTime() - startTime.getTime() 58 | const allowedDeviation = 50 59 | 60 | assert.isAtLeast(actualTimeout, 100 - allowedDeviation) 61 | assert.isAtMost(actualTimeout, 100 + allowedDeviation) 62 | }) 63 | 64 | test('retryTest fails when callback does not finish before timeout', async ({ assert }) => { 65 | const startTime = new Date() 66 | 67 | await assert.rejects( 68 | () => 69 | retryTest( 70 | { 71 | timeout: 100, 72 | pollIntervals: [20], 73 | }, 74 | async () => { 75 | await setTimeout(200) 76 | } 77 | ), 78 | /retryTest: Callback ran out of time, timed out after 100ms/ 79 | ) 80 | const endTime = new Date() 81 | 82 | const actualTimeout = endTime.getTime() - startTime.getTime() 83 | const allowedDeviation = 50 84 | 85 | assert.isAtLeast(actualTimeout, 100 - allowedDeviation) 86 | assert.isAtMost(actualTimeout, 100 + allowedDeviation) 87 | }) 88 | 89 | test('retryTest retries at defined poll intervals', async ({ assert }) => { 90 | const attemptTimes: Date[] = [] 91 | const startTime = new Date() 92 | 93 | await assert.rejects( 94 | () => 95 | retryTest( 96 | { 97 | timeout: 500, 98 | pollIntervals: [25, 100, 150], 99 | }, 100 | async () => { 101 | attemptTimes.push(new Date()) 102 | throw new Error('Expected error') 103 | } 104 | ), 105 | /Expected error, timed out after 500ms/ 106 | ) 107 | 108 | const endTime = new Date() 109 | 110 | const actualTimeout = endTime.getTime() - startTime.getTime() 111 | const [firstAttemptDelay, ...actualPollIntervals] = attemptTimes.map((attemptTime, i) => { 112 | const lastAttemptTime = i === 0 ? startTime : attemptTimes[i - 1] 113 | return attemptTime.getTime() - lastAttemptTime.getTime() 114 | }) 115 | const allowedDeviation = 25 116 | 117 | assert.isAtLeast(actualTimeout, 500 - allowedDeviation) 118 | assert.isAtMost(actualTimeout, 500 + allowedDeviation) 119 | 120 | assert.isAtLeast(firstAttemptDelay, 0) 121 | assert.isAtMost(firstAttemptDelay, 0 + allowedDeviation) 122 | 123 | assert.lengthOf(actualPollIntervals, 4) 124 | 125 | assert.isAtLeast(actualPollIntervals[0], 25 - allowedDeviation) 126 | assert.isAtMost(actualPollIntervals[0], 25 + allowedDeviation) 127 | 128 | assert.isAtLeast(actualPollIntervals[1], 100 - allowedDeviation) 129 | assert.isAtMost(actualPollIntervals[1], 100 + allowedDeviation) 130 | 131 | assert.isAtLeast(actualPollIntervals[2], 150 - allowedDeviation) 132 | assert.isAtMost(actualPollIntervals[2], 150 + allowedDeviation) 133 | 134 | assert.isAtLeast(actualPollIntervals[3], 150 - allowedDeviation) 135 | assert.isAtMost(actualPollIntervals[3], 150 + allowedDeviation) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /tests/browser.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { chromium } from 'playwright' 12 | 13 | import { ServerFactory } from '../factories/server.js' 14 | import { decorateBrowser } from '../src/decorate_browser.js' 15 | 16 | test.group('Browser', () => { 17 | test('open page', async ({ assert, cleanup }) => { 18 | const server = new ServerFactory() 19 | await server.create((_, res) => { 20 | res.write('hello world') 21 | res.end() 22 | }) 23 | 24 | const browser = decorateBrowser(await chromium.launch(), []) 25 | 26 | cleanup(async () => { 27 | await browser.close() 28 | await server.close() 29 | }) 30 | 31 | const context = await browser.newContext() 32 | const page = await context.newPage() 33 | 34 | await page.goto(server.url) 35 | assert.equal(await page.locator('body').innerText(), 'hello world') 36 | }) 37 | 38 | test('decorate context', async ({ assert, cleanup }) => { 39 | const server = new ServerFactory() 40 | await server.create((req, res) => { 41 | res.write(`X-Key=${req.headers['x-key']}`) 42 | res.end() 43 | }) 44 | 45 | const browser = decorateBrowser(await chromium.launch(), [ 46 | { 47 | context(ctx) { 48 | const originalNewPage: typeof ctx.newPage = ctx.newPage.bind(ctx) 49 | ctx.newPage = async function () { 50 | const page = await originalNewPage() 51 | page.setExtraHTTPHeaders({ 'X-Key': '22' }) 52 | return page 53 | } 54 | return ctx 55 | }, 56 | }, 57 | ]) 58 | 59 | cleanup(async () => { 60 | await server.close() 61 | await browser.close() 62 | }) 63 | 64 | const context = await browser.newContext() 65 | const page = await context.newPage() 66 | 67 | await page.goto(server.url) 68 | assert.equal(await page.locator('body').innerText(), 'X-Key=22') 69 | }) 70 | 71 | test('decorate page', async ({ assert, cleanup }) => { 72 | const server = new ServerFactory() 73 | await server.create((req, res) => { 74 | res.write(`X-Key=${req.headers['x-key']}`) 75 | res.end() 76 | }) 77 | 78 | const browser = decorateBrowser(await chromium.launch(), [ 79 | { 80 | page(page) { 81 | page.route('**/**', (route, request) => { 82 | route.continue({ 83 | headers: { 84 | ...request.headers(), 85 | ...{ 86 | 'X-key': '33', 87 | }, 88 | }, 89 | }) 90 | }) 91 | return page 92 | }, 93 | }, 94 | ]) 95 | 96 | cleanup(async () => { 97 | await server.close() 98 | await browser.close() 99 | }) 100 | 101 | const context = await browser.newContext() 102 | const page = await context.newPage() 103 | 104 | await page.goto(server.url) 105 | assert.equal(await page.locator('body').innerText(), 'X-Key=33') 106 | }) 107 | 108 | test('decorate page when newPage method is used', async ({ assert, cleanup }) => { 109 | const server = new ServerFactory() 110 | await server.create((req, res) => { 111 | res.write(`X-Key=${req.headers['x-key']}`) 112 | res.end() 113 | }) 114 | 115 | const browser = decorateBrowser(await chromium.launch(), [ 116 | { 117 | page(page) { 118 | page.route('**/**', (route, request) => { 119 | route.continue({ 120 | headers: { 121 | ...request.headers(), 122 | ...{ 123 | 'X-key': '33', 124 | }, 125 | }, 126 | }) 127 | }) 128 | return page 129 | }, 130 | }, 131 | ]) 132 | 133 | cleanup(async () => { 134 | await server.close() 135 | await browser.close() 136 | }) 137 | 138 | const page = await browser.newPage() 139 | 140 | await page.goto(server.url) 141 | assert.equal(await page.locator('body').innerText(), 'X-Key=33') 142 | }) 143 | 144 | test('decorate response', async ({ assert, cleanup }) => { 145 | const server = new ServerFactory() 146 | await server.create((_, res) => { 147 | res.setHeader('content-type', 'text/html') 148 | res.write('hello world') 149 | res.end() 150 | }) 151 | 152 | const browser = decorateBrowser(await chromium.launch(), [ 153 | { 154 | response(response) { 155 | ;(response as any).isHTML = function () { 156 | return this.headers()['content-type'] === 'text/html' 157 | } 158 | }, 159 | }, 160 | ]) 161 | 162 | cleanup(async () => { 163 | await server.close() 164 | await browser.close() 165 | }) 166 | 167 | const context = await browser.newContext() 168 | const page = await context.newPage() 169 | 170 | const response = await page.goto(server.url) 171 | assert.isTrue((response as any).isHTML()) 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /tests/interaction.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { chromium } from 'playwright' 12 | 13 | import { BasePage } from '../src/base/base_page.js' 14 | import { ServerFactory } from '../factories/server.js' 15 | import { addUseMethod } from '../src/decorators/use.js' 16 | import { addVisitMethod } from '../src/decorators/visit.js' 17 | import { decorateBrowser } from '../src/decorate_browser.js' 18 | import { BaseInteraction } from '../src/base/base_interaction.js' 19 | 20 | test.group('Interaction', () => { 21 | test('use interaction to run async actions', async ({ assert, cleanup }) => { 22 | const server = new ServerFactory() 23 | await server.create((_, res) => { 24 | res.setHeader('content-type', 'text/html') 25 | res.write(` 26 | 27 | Hello world 28 | 29 | 30 |
31 | Terms and conditions 32 |
33 | 34 | `) 35 | res.end() 36 | }) 37 | 38 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod]) 39 | cleanup(async () => { 40 | await browser.close() 41 | await server.close() 42 | }) 43 | 44 | class FormInteraction extends BaseInteraction { 45 | checkTerms() { 46 | return this.defer(() => this.page.locator('input[name="terms"]').check()) 47 | } 48 | 49 | assertIsChecked() { 50 | return this.defer(async () => { 51 | assert.isTrue(await this.page.locator('input[name="terms"]').isChecked()) 52 | }) 53 | } 54 | } 55 | 56 | class HomePage extends BasePage { 57 | url: string = server.url 58 | form = new FormInteraction(this.page, this.context) 59 | } 60 | 61 | const context = await browser.newContext() 62 | const page = await context.visit(HomePage) 63 | await page.form.checkTerms().assertIsChecked().exec() 64 | }) 65 | 66 | test('mount interaction to an existing page', async ({ assert, cleanup }) => { 67 | const server = new ServerFactory() 68 | await server.create((_, res) => { 69 | res.setHeader('content-type', 'text/html') 70 | res.write(` 71 | 72 | Hello world 73 | 74 | 75 |
76 | Terms and conditions 77 |
78 | 79 | `) 80 | res.end() 81 | }) 82 | 83 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod, addUseMethod]) 84 | cleanup(async () => { 85 | await browser.close() 86 | await server.close() 87 | }) 88 | 89 | class FormInteraction extends BaseInteraction { 90 | checkTerms() { 91 | return this.defer(() => this.page.locator('input[name="terms"]').check()) 92 | } 93 | 94 | assertIsChecked() { 95 | return this.defer(async () => { 96 | assert.isTrue(await this.page.locator('input[name="terms"]').isChecked()) 97 | }) 98 | } 99 | } 100 | 101 | const context = await browser.newContext() 102 | const page = await context.visit(server.url) 103 | await page.use(FormInteraction).checkTerms().assertIsChecked().exec() 104 | }) 105 | 106 | test('handle interaction failures', async ({ assert, cleanup }, done) => { 107 | const server = new ServerFactory() 108 | await server.create((_, res) => { 109 | res.setHeader('content-type', 'text/html') 110 | res.write(` 111 | 112 | Hello world 113 | 114 | 115 | 116 | `) 117 | res.end() 118 | }) 119 | 120 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod, addUseMethod]) 121 | cleanup(async () => { 122 | await browser.close() 123 | await server.close() 124 | }) 125 | 126 | class FormInteraction extends BaseInteraction { 127 | checkTerms() { 128 | return this.defer(() => this.page.locator('input[name="terms"]').check({ timeout: 100 })) 129 | } 130 | 131 | assertIsChecked() { 132 | return this.defer(async () => { 133 | assert.isTrue(await this.page.locator('input[name="terms"]').isChecked()) 134 | }) 135 | } 136 | } 137 | 138 | const context = await browser.newContext() 139 | const page = await context.visit(server.url) 140 | page 141 | .use(FormInteraction) 142 | .checkTerms() 143 | .assertIsChecked() 144 | .exec() 145 | .catch((error) => { 146 | assert.match(error.message, /locator.check: Timeout 100ms/) 147 | done() 148 | }) 149 | }).waitForDone() 150 | 151 | test('wait for interaction to finish or fail', async ({ assert, cleanup }, done) => { 152 | const server = new ServerFactory() 153 | await server.create((_, res) => { 154 | res.setHeader('content-type', 'text/html') 155 | res.write(` 156 | 157 | Hello world 158 | 159 | 160 | Terms and conditions 161 | 162 | `) 163 | res.end() 164 | }) 165 | 166 | const browser = decorateBrowser(await chromium.launch(), [addVisitMethod, addUseMethod]) 167 | cleanup(async () => { 168 | await browser.close() 169 | await server.close() 170 | }) 171 | 172 | class FormInteraction extends BaseInteraction { 173 | checkTerms() { 174 | return this.defer(() => this.page.locator('input[name="terms"]').check({ timeout: 100 })) 175 | } 176 | 177 | assertIsChecked() { 178 | return this.defer(async () => { 179 | assert.isTrue(await this.page.locator('input[name="terms"]').isChecked()) 180 | }) 181 | } 182 | } 183 | 184 | const context = await browser.newContext() 185 | const page = await context.visit(server.url) 186 | page 187 | .use(FormInteraction) 188 | .checkTerms() 189 | .assertIsChecked() 190 | .exec() 191 | .finally(() => { 192 | done() 193 | }) 194 | }).waitForDone() 195 | }) 196 | -------------------------------------------------------------------------------- /src/types/extended.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import type { Locator } from 'playwright' 11 | 12 | import type { VisitOptions } from './main.js' 13 | import type { BasePage } from '../base/base_page.js' 14 | import type { BaseInteraction } from '../base/base_interaction.js' 15 | import type { BrowserContext, Browser as PlayWrightBrowser } from 'playwright' 16 | 17 | /** 18 | * Types for custom methods we attach on playwright via 19 | * inbuilt decorators 20 | */ 21 | declare module 'playwright' { 22 | export interface Page { 23 | // assert: Assert 24 | 25 | /** 26 | * Use a page or an interaction to perform actions 27 | */ 28 | use(pageOrInteraction: T): InstanceType 29 | 30 | /** 31 | * Pause page when condition is true 32 | */ 33 | pauseIf(condition: boolean): Promise 34 | 35 | /** 36 | * Pause page when condition is false 37 | */ 38 | pauseUnless(condition: boolean): Promise 39 | 40 | /** 41 | * Assert an element to exists 42 | */ 43 | assertExists(selector: string | Locator): Promise 44 | 45 | /** 46 | * Assert an element to not exist 47 | */ 48 | assertNotExists(selector: string | Locator): Promise 49 | 50 | /** 51 | * Assert an element to exists and have matching count 52 | */ 53 | assertElementsCount(selector: string | Locator, expectedCount: number): Promise 54 | 55 | /** 56 | * Assert an element to be visible. Elements with display: none 57 | * and visibility:hidden are not visible. 58 | */ 59 | assertVisible(selector: string | Locator): Promise 60 | 61 | /** 62 | * Assert an element to be not visible. Elements with display: none 63 | * and visibility:hidden are not visible. 64 | */ 65 | assertNotVisible(selector: string | Locator): Promise 66 | 67 | /** 68 | * Assert the page title to match the expected 69 | * value 70 | */ 71 | assertTitle(expectedTitle: string): Promise 72 | 73 | /** 74 | * Assert the page title to include a substring value 75 | */ 76 | assertTitleContains(expectedSubstring: string): Promise 77 | 78 | /** 79 | * Assert the page URL to match the expected 80 | * value 81 | */ 82 | assertUrl(expectedUrl: string): Promise 83 | 84 | /** 85 | * Assert the page URL to contain the expected substring 86 | */ 87 | assertUrlContains(expectedSubstring: string): Promise 88 | 89 | /** 90 | * Assert the page URL to match regex 91 | */ 92 | assertUrlMatches(regex: RegExp): Promise 93 | 94 | /** 95 | * Assert the page path to match the expected value. The URL 96 | * is parsed using the Node.js URL parser and the pathname 97 | * value is used for assertion. 98 | */ 99 | assertPath(expectedPathName: string): Promise 100 | 101 | /** 102 | * Assert the page path to contain the expected substring. The URL 103 | * is parsed using the Node.js URL parser and the pathname value 104 | * is used for assertion. 105 | */ 106 | assertPathContains(expectedSubstring: string): Promise 107 | 108 | /** 109 | * Assert the page path to match the expected regex. The URL is 110 | * parsed using the Node.js URL parser and the pathname value 111 | * is used for assertion. 112 | */ 113 | assertPathMatches(regex: RegExp): Promise 114 | 115 | /** 116 | * Asserts the page URL querystring to match the subset 117 | * object 118 | */ 119 | assertQueryString(expectedSubset: Record): Promise 120 | 121 | /** 122 | * Assert cookie to exist and optionally match the expected 123 | * value 124 | */ 125 | assertCookie(cookieName: string, value?: any): Promise 126 | 127 | /** 128 | * Assert cookie to be missing 129 | */ 130 | assertCookieMissing(cookieName: string): Promise 131 | 132 | /** 133 | * Assert innerText of a given selector to equal 134 | * the expected value 135 | */ 136 | assertText(selector: string | Locator, expectedValue: string): Promise 137 | 138 | /** 139 | * Assert innerText of a given selector elements to match 140 | * the expected values 141 | */ 142 | assertElementsText(selector: string | Locator, expectedValues: string[]): Promise 143 | 144 | /** 145 | * Assert innerText of a given selector to include 146 | * substring 147 | */ 148 | assertTextContains(selector: string | Locator, expectedSubstring: string): Promise 149 | 150 | /** 151 | * Assert a checkbox to be checked 152 | */ 153 | assertChecked(selector: string | Locator): Promise 154 | 155 | /** 156 | * Assert a checkbox not to be checked 157 | */ 158 | assertNotChecked(selector: string | Locator): Promise 159 | 160 | /** 161 | * Assert an element to be disabled. All elements are considered 162 | * enabled, unless it is a button, select, input or a textarea 163 | * with disabled attribute 164 | */ 165 | assertDisabled(selector: string | Locator): Promise 166 | 167 | /** 168 | * Assert an element to be not disabled. All elements are considered 169 | * enabled, unless it is a button, select, input or a textarea 170 | * with disabled attribute 171 | */ 172 | assertNotDisabled(selector: string | Locator): Promise 173 | 174 | /** 175 | * Assert the input value to match the expected value. The assertion 176 | * must be performed against an `input`, `textarea` or a `select` 177 | * dropdown. 178 | */ 179 | assertInputValue(selector: string | Locator, expectedValue: string): Promise 180 | 181 | /** 182 | * Assert the select box selected options to match the expected values. 183 | */ 184 | assertSelectedOptions(selector: string, expectedValues: string[]): Promise 185 | } 186 | 187 | export interface BrowserContext { 188 | /** 189 | * Open a new page and visit a URL 190 | */ 191 | visit(url: string, options?: VisitOptions): Promise 192 | 193 | /** 194 | * Open a new page using a page model 195 | */ 196 | visit(page: PageModel): Promise> 197 | 198 | /** 199 | * Open a new page using a page model and access it's 200 | * instance inside the callback. 201 | */ 202 | visit( 203 | page: PageModel, 204 | callback: (page: InstanceType) => void | Promise 205 | ): Promise 206 | } 207 | } 208 | 209 | /** 210 | * Extending types 211 | */ 212 | declare module '@japa/runner/core' { 213 | export interface TestContext { 214 | /** 215 | * Playwright browser 216 | */ 217 | browser: PlayWrightBrowser 218 | 219 | /** 220 | * Playwright browser context 221 | */ 222 | browserContext: BrowserContext 223 | 224 | /** 225 | * Opens a new page and visit the URL 226 | */ 227 | visit: BrowserContext['visit'] 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/decorators/assertions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import qs from '@poppinss/qs' 11 | import { inspect } from 'node:util' 12 | import { AssertionError } from 'node:assert' 13 | import type { Locator, Page } from 'playwright' 14 | import type { Decorator } from '../types/main.js' 15 | import { isSubsetOf, retryTest } from '../helpers.js' 16 | import { DEFAULT_ASSERTIONS_CONFIG } from '../plugin/normalize_config.js' 17 | 18 | /** 19 | * Returns locator for a selector 20 | */ 21 | function getLocator(selector: string | Locator, page: Page): Locator { 22 | return typeof selector === 'string' ? page.locator(selector) : selector 23 | } 24 | 25 | /** 26 | * Decorates the page object with custom assertions 27 | */ 28 | export const addAssertions = { 29 | page(page, _context, config) { 30 | const retrySettings = { ...DEFAULT_ASSERTIONS_CONFIG, ...config?.assertions } 31 | 32 | page.assertExists = function assertExists(selector) { 33 | return retryTest(retrySettings, async () => { 34 | const matchingCount = await getLocator(selector, this).count() 35 | 36 | if (matchingCount <= 0) { 37 | throw new AssertionError({ 38 | message: `expected ${inspect(selector)} element to exist`, 39 | stackStartFn: assertExists, 40 | }) 41 | } 42 | }) 43 | } 44 | 45 | page.assertNotExists = function assertNotExists(selector) { 46 | return retryTest(retrySettings, async () => { 47 | const matchingCount = await getLocator(selector, this).count() 48 | 49 | if (matchingCount !== 0) { 50 | throw new AssertionError({ 51 | message: `expected ${inspect(selector)} element to not exist`, 52 | stackStartFn: assertNotExists, 53 | }) 54 | } 55 | }) 56 | } 57 | 58 | page.assertElementsCount = function assertElementsCount(selector, expectedCount) { 59 | return retryTest(retrySettings, async () => { 60 | const matchingCount = await getLocator(selector, this).count() 61 | 62 | if (matchingCount !== expectedCount) { 63 | throw new AssertionError({ 64 | message: `expected ${inspect(selector)} to have '${expectedCount}' elements`, 65 | stackStartFn: assertElementsCount, 66 | actual: matchingCount, 67 | expected: expectedCount, 68 | }) 69 | } 70 | }) 71 | } 72 | 73 | page.assertVisible = function assertVisible(selector) { 74 | return retryTest(retrySettings, async () => { 75 | const isVisible = await getLocator(selector, this).isVisible() 76 | 77 | if (!isVisible) { 78 | throw new AssertionError({ 79 | message: `expected ${inspect(selector)} element to be visible`, 80 | stackStartFn: assertVisible, 81 | }) 82 | } 83 | }) 84 | } 85 | 86 | page.assertNotVisible = function assertNotVisible(selector) { 87 | return retryTest(retrySettings, async () => { 88 | const isVisible = await getLocator(selector, this).isVisible() 89 | 90 | if (isVisible) { 91 | throw new AssertionError({ 92 | message: `expected ${inspect(selector)} element to be not visible`, 93 | stackStartFn: assertNotVisible, 94 | }) 95 | } 96 | }) 97 | } 98 | 99 | page.assertTitle = function assertTitle(expectedTitle) { 100 | return retryTest(retrySettings, async () => { 101 | const title = await this.title() 102 | 103 | if (title !== expectedTitle) { 104 | throw new AssertionError({ 105 | message: `expected page title '${title}' to equal '${expectedTitle}'`, 106 | stackStartFn: assertTitle, 107 | actual: title, 108 | expected: expectedTitle, 109 | }) 110 | } 111 | }) 112 | } 113 | 114 | page.assertTitleContains = function assertTitleContains(expectedSubstring) { 115 | return retryTest(retrySettings, async () => { 116 | const pageTitle = await this.title() 117 | 118 | if (!pageTitle.includes(expectedSubstring)) { 119 | throw new AssertionError({ 120 | message: `expected page title '${pageTitle}' to include '${expectedSubstring}'`, 121 | stackStartFn: assertTitleContains, 122 | }) 123 | } 124 | }) 125 | } 126 | 127 | page.assertUrl = function assertUrl(expectedUrl) { 128 | return retryTest(retrySettings, async () => { 129 | const url = this.url() 130 | 131 | if (url !== expectedUrl) { 132 | throw new AssertionError({ 133 | message: `expected page URL '${url}' to equal '${expectedUrl}'`, 134 | stackStartFn: assertUrl, 135 | actual: url, 136 | expected: expectedUrl, 137 | }) 138 | } 139 | }) 140 | } 141 | 142 | page.assertUrlContains = function assertUrlContains(expectedSubstring) { 143 | return retryTest(retrySettings, async () => { 144 | const pageUrl = this.url() 145 | 146 | if (!pageUrl.includes(expectedSubstring)) { 147 | throw new AssertionError({ 148 | message: `expected page URL '${pageUrl}' to include '${expectedSubstring}'`, 149 | stackStartFn: assertUrlContains, 150 | }) 151 | } 152 | }) 153 | } 154 | 155 | page.assertUrlMatches = function assertUrlMatches(regex) { 156 | return retryTest(retrySettings, async () => { 157 | const url = this.url() 158 | 159 | if (!regex.test(url)) { 160 | throw new AssertionError({ 161 | message: `expected page URL '${url}' to match '${regex}'`, 162 | stackStartFn: assertUrlMatches, 163 | actual: url, 164 | expected: regex, 165 | }) 166 | } 167 | }) 168 | } 169 | 170 | page.assertPath = function assertPath(expectedPathName) { 171 | return retryTest(retrySettings, async () => { 172 | const { pathname } = new URL(this.url()) 173 | 174 | if (pathname !== expectedPathName) { 175 | throw new AssertionError({ 176 | message: `expected page pathname '${pathname}' to equal '${expectedPathName}'`, 177 | stackStartFn: assertPath, 178 | actual: pathname, 179 | expected: expectedPathName, 180 | }) 181 | } 182 | }) 183 | } 184 | 185 | page.assertPathContains = function assertPathContains(expectedSubstring) { 186 | return retryTest(retrySettings, async () => { 187 | const { pathname } = new URL(this.url()) 188 | 189 | if (!pathname.includes(expectedSubstring)) { 190 | throw new AssertionError({ 191 | message: `expected page pathname '${pathname}' to include '${expectedSubstring}'`, 192 | stackStartFn: assertPathContains, 193 | }) 194 | } 195 | }) 196 | } 197 | 198 | page.assertPathMatches = function assertPathMatches(regex) { 199 | return retryTest(retrySettings, async () => { 200 | const { pathname } = new URL(this.url()) 201 | 202 | if (!regex.test(pathname)) { 203 | throw new AssertionError({ 204 | message: `expected page pathname '${pathname}' to match '${regex}'`, 205 | stackStartFn: assertPathMatches, 206 | }) 207 | } 208 | }) 209 | } 210 | 211 | page.assertQueryString = function assertQueryString(expectedSubset) { 212 | return retryTest(retrySettings, async () => { 213 | const pageURL = new URL(this.url()) 214 | const queryString = qs.parse(pageURL.search, { ignoreQueryPrefix: true }) 215 | 216 | if (!isSubsetOf(queryString, expectedSubset)) { 217 | throw new AssertionError({ 218 | message: `expected '${inspect(queryString)}' to contain '${inspect(expectedSubset)}'`, 219 | stackStartFn: assertQueryString, 220 | actual: queryString, 221 | expected: expectedSubset, 222 | }) 223 | } 224 | }) 225 | } 226 | 227 | page.assertCookie = function assertCookie(cookieName, value?) { 228 | return retryTest(retrySettings, async () => { 229 | const pageCookies = await this.context().cookies() 230 | const matchingCookie = pageCookies.find(({ name }) => name === cookieName) 231 | 232 | if (!matchingCookie) { 233 | throw new AssertionError({ 234 | message: `expected '${cookieName}' cookie to exist`, 235 | stackStartFn: assertCookie, 236 | }) 237 | } 238 | 239 | if (value && matchingCookie.value !== value) { 240 | throw new AssertionError({ 241 | message: `expected '${cookieName}' cookie value to equal '${value}'`, 242 | stackStartFn: assertCookie, 243 | actual: matchingCookie.value, 244 | expected: value, 245 | }) 246 | } 247 | }) 248 | } 249 | 250 | page.assertCookieMissing = function assertCookieMissing(cookieName) { 251 | return retryTest(retrySettings, async () => { 252 | const pageCookies = await this.context().cookies() 253 | const matchingCookie = pageCookies.find(({ name }) => name === cookieName) 254 | 255 | if (matchingCookie) { 256 | throw new AssertionError({ 257 | message: `expected '${cookieName}' cookie to not exist`, 258 | stackStartFn: assertCookieMissing, 259 | }) 260 | } 261 | }) 262 | } 263 | 264 | page.assertText = function assertText(selector, expectedValue) { 265 | return retryTest(retrySettings, async () => { 266 | const actual = await getLocator(selector, this).innerText({ timeout: 2000 }) 267 | if (actual !== expectedValue) { 268 | throw new AssertionError({ 269 | message: `expected ${inspect(selector)} inner text to equal '${expectedValue}'`, 270 | stackStartFn: assertText, 271 | actual: actual, 272 | expected: expectedValue, 273 | }) 274 | } 275 | }) 276 | } 277 | 278 | page.assertElementsText = function assertElementsText(selector, expectedValues) { 279 | return retryTest(retrySettings, async () => { 280 | const innertTexts = await getLocator(selector, this).allInnerTexts() 281 | 282 | innertTexts.forEach((text, index) => { 283 | if (text !== expectedValues[index]) { 284 | throw new AssertionError({ 285 | message: `expected ${inspect(selector)} value to deeply equal ${inspect( 286 | expectedValues 287 | )} in same order`, 288 | stackStartFn: assertElementsText, 289 | actual: innertTexts, 290 | expected: expectedValues, 291 | }) 292 | } 293 | }) 294 | }) 295 | } 296 | 297 | page.assertTextContains = function assertTextContains(selector, expectedSubstring) { 298 | return retryTest(retrySettings, async () => { 299 | const actual = await getLocator(selector, this).innerText({ timeout: 2000 }) 300 | 301 | if (!actual.includes(expectedSubstring)) { 302 | throw new AssertionError({ 303 | message: `expected ${inspect(selector)} inner text to include '${expectedSubstring}'`, 304 | stackStartFn: assertTextContains, 305 | }) 306 | } 307 | }) 308 | } 309 | 310 | page.assertChecked = function assertChecked(selector) { 311 | return retryTest(retrySettings, async () => { 312 | let isChecked: boolean | undefined 313 | 314 | try { 315 | isChecked = await getLocator(selector, this).isChecked({ timeout: 2000 }) 316 | } catch (error) { 317 | if (error.message.includes('Not a checkbox')) { 318 | throw new AssertionError({ 319 | message: `expected ${inspect(selector)} to be a checkbox`, 320 | stackStartFn: assertChecked, 321 | }) 322 | } 323 | 324 | throw error 325 | } 326 | 327 | /** 328 | * Assert only when we are able to locate the checkbox 329 | */ 330 | if (isChecked === false) { 331 | throw new AssertionError({ 332 | message: `expected ${inspect(selector)} checkbox to be checked`, 333 | stackStartFn: assertChecked, 334 | }) 335 | } 336 | }) 337 | } 338 | 339 | page.assertNotChecked = function assertNotChecked(selector) { 340 | return retryTest(retrySettings, async () => { 341 | let isChecked: boolean | undefined 342 | 343 | try { 344 | isChecked = await getLocator(selector, this).isChecked({ timeout: 2000 }) 345 | } catch (error) { 346 | if (error.message.includes('Not a checkbox')) { 347 | throw new AssertionError({ 348 | message: `expected ${inspect(selector)} to be a checkbox`, 349 | stackStartFn: assertNotChecked, 350 | }) 351 | } 352 | 353 | throw error 354 | } 355 | 356 | /** 357 | * Assert only when we are able to locate the checkbox 358 | */ 359 | if (isChecked === true) { 360 | throw new AssertionError({ 361 | message: `expected ${inspect(selector)} checkbox to be not checked`, 362 | stackStartFn: assertNotChecked, 363 | }) 364 | } 365 | }) 366 | } 367 | 368 | page.assertDisabled = function assertDisabled(selector) { 369 | return retryTest(retrySettings, async () => { 370 | const isDisabled = await getLocator(selector, this).isDisabled({ timeout: 2000 }) 371 | 372 | if (!isDisabled) { 373 | throw new AssertionError({ 374 | message: `expected ${inspect(selector)} element to be disabled`, 375 | stackStartFn: assertDisabled, 376 | }) 377 | } 378 | }) 379 | } 380 | 381 | page.assertNotDisabled = function assertNotDisabled(selector) { 382 | return retryTest(retrySettings, async () => { 383 | const isDisabled = await getLocator(selector, this).isDisabled({ timeout: 2000 }) 384 | 385 | if (isDisabled) { 386 | throw new AssertionError({ 387 | message: `expected ${inspect(selector)} element to be not disabled`, 388 | stackStartFn: assertNotDisabled, 389 | }) 390 | } 391 | }) 392 | } 393 | 394 | page.assertInputValue = function assertInputValue(selector, expectedValue) { 395 | return retryTest(retrySettings, async () => { 396 | let inputValue: string | undefined 397 | 398 | try { 399 | inputValue = await getLocator(selector, this).inputValue({ timeout: 2000 }) 400 | } catch (error) { 401 | if (error.message.includes('Node is not')) { 402 | throw new AssertionError({ 403 | message: `expected ${inspect(selector)} element to be an input, select or a textarea`, 404 | stackStartFn: assertInputValue, 405 | }) 406 | } 407 | 408 | throw error 409 | } 410 | 411 | if (inputValue !== expectedValue) { 412 | throw new AssertionError({ 413 | message: `expected ${inspect(selector)} value to equal '${expectedValue}'`, 414 | stackStartFn: assertInputValue, 415 | actual: inputValue, 416 | expected: expectedValue, 417 | }) 418 | } 419 | }) 420 | } 421 | 422 | page.assertSelectedOptions = function assertSelectedOptions(selector, expectedValues) { 423 | return retryTest(retrySettings, async () => { 424 | const element = await this.$eval(selector, (node) => { 425 | /* c8 ignore start */ 426 | if (node.nodeName === 'SELECT') { 427 | const options = [...node.options] 428 | return { 429 | multiple: node.multiple, 430 | selected: options 431 | .filter((option: any) => option.selected) 432 | .map((option: any) => option.value), 433 | } 434 | } 435 | /* c8 ignore end */ 436 | }) 437 | 438 | /** 439 | * Not a select box 440 | */ 441 | if (!element) { 442 | throw new AssertionError({ 443 | message: `expected ${inspect(selector)} element to be a select box`, 444 | stackStartFn: assertSelectedOptions, 445 | }) 446 | } 447 | 448 | /** 449 | * Using different assertions for multiple and single 450 | * select boxes 451 | */ 452 | if (element.multiple) { 453 | element.selected.forEach((elem) => { 454 | if (!expectedValues.includes(elem)) { 455 | throw new AssertionError({ 456 | message: `expected ${inspect(selector)} value to deeply equal ${inspect( 457 | expectedValues 458 | )}`, 459 | stackStartFn: assertSelectedOptions, 460 | actual: element.selected, 461 | expected: expectedValues, 462 | }) 463 | } 464 | }) 465 | } else { 466 | if (element.selected[0] !== expectedValues[0]) { 467 | throw new AssertionError({ 468 | message: `expected ${inspect(selector)} value to equal ${inspect(expectedValues)}`, 469 | stackStartFn: assertSelectedOptions, 470 | actual: element.selected[0], 471 | expected: expectedValues[0], 472 | }) 473 | } 474 | } 475 | }) 476 | } 477 | }, 478 | } satisfies Decorator 479 | -------------------------------------------------------------------------------- /tests/page/assertions.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @japa/browser-client 3 | * 4 | * (c) Japa.dev 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { chromium } from 'playwright' 12 | 13 | import { decorateBrowser } from '../../index.js' 14 | import { ServerFactory } from '../../factories/server.js' 15 | import { addAssertions } from '../../src/decorators/assertions.js' 16 | import type { PluginConfig } from '../../src/types/main.js' 17 | import { basicDocument } from '../helpers.js' 18 | 19 | test.group('Assertions', () => { 20 | // Use short timeout for expect in order to speed up tests for failing assertions 21 | const pluginConfig: PluginConfig = { 22 | assertions: { 23 | timeout: 200, 24 | }, 25 | } 26 | 27 | test('assert element to exist', async ({ assert, cleanup }) => { 28 | const server = new ServerFactory() 29 | await server.create((_, res) => { 30 | res.setHeader('content-type', 'text/html') 31 | res.write( 32 | basicDocument({ 33 | body: ` 34 |

Hello world

35 |

Title

36 | `, 37 | }) 38 | ) 39 | res.end() 40 | }) 41 | 42 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 43 | cleanup(async () => { 44 | await server.close() 45 | await browser.close() 46 | }) 47 | 48 | const page = await browser.newPage() 49 | 50 | await page.goto(server.url) 51 | await page.assertExists('p') 52 | await page.assertExists('h1') 53 | await assert.rejects(() => page.assertExists('span'), /expected 'span' element to exist/) 54 | 55 | await page.evaluate(() => { 56 | setTimeout(() => { 57 | // @ts-expect-error 58 | document.body.appendChild(document.createElement('h2')) 59 | }, 50) 60 | }) 61 | await page.assertExists(page.locator('h2')) 62 | }) 63 | 64 | test('assert elements to have expected count', async ({ assert, cleanup }) => { 65 | const server = new ServerFactory() 66 | await server.create((_, res) => { 67 | res.setHeader('content-type', 'text/html') 68 | res.write( 69 | basicDocument({ 70 | body: ` 71 |

Hello world

72 |

Hi world

73 | 74 |

Title

75 | 76 |
    77 |
  • Hello world
  • 78 |
  • Hi world
  • 79 |
  • Bye world
  • 80 |
81 | `, 82 | }) 83 | ) 84 | res.end() 85 | }) 86 | 87 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 88 | cleanup(async () => { 89 | await server.close() 90 | await browser.close() 91 | }) 92 | 93 | const page = await browser.newPage() 94 | 95 | await page.goto(server.url) 96 | await page.assertElementsCount('p', 2) 97 | await page.assertElementsCount('h1', 1) 98 | await page.assertElementsCount('ul > li ', 3) 99 | await assert.rejects( 100 | () => page.assertElementsCount('span', 1), 101 | /expected 'span' to have '1' elements/ 102 | ) 103 | 104 | await page.evaluate(() => { 105 | setTimeout(() => { 106 | // @ts-expect-error 107 | document.querySelector('ul').appendChild(document.createElement('li')) 108 | }, 50) 109 | }) 110 | await page.assertElementsCount('ul > li ', 4) 111 | }) 112 | 113 | test('assert element to not exist', async ({ assert, cleanup }) => { 114 | const server = new ServerFactory() 115 | await server.create((_, res) => { 116 | res.setHeader('content-type', 'text/html') 117 | res.write(basicDocument({ body: `

Title

` })) 118 | res.end() 119 | }) 120 | 121 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 122 | cleanup(async () => { 123 | await server.close() 124 | await browser.close() 125 | }) 126 | 127 | const page = await browser.newPage() 128 | 129 | await page.goto(server.url) 130 | await page.assertNotExists('p') 131 | await page.assertNotExists('span') 132 | await assert.rejects(() => page.assertNotExists('h1'), /expected 'h1' element to not exist/) 133 | 134 | await page.evaluate(() => { 135 | setTimeout(() => { 136 | // @ts-expect-error 137 | document.querySelector('h1').remove() 138 | }, 50) 139 | }) 140 | await page.assertNotExists('h1') 141 | }) 142 | 143 | test('assert element to be visible', async ({ assert, cleanup }) => { 144 | const server = new ServerFactory() 145 | await server.create((_, res) => { 146 | res.setHeader('content-type', 'text/html') 147 | res.write( 148 | basicDocument({ 149 | body: ` 150 |

Hello world

151 |

Title

152 | `, 153 | }) 154 | ) 155 | res.end() 156 | }) 157 | 158 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 159 | cleanup(async () => { 160 | await server.close() 161 | await browser.close() 162 | }) 163 | 164 | const page = await browser.newPage() 165 | 166 | await page.goto(server.url) 167 | await page.assertVisible('p') 168 | await assert.rejects(() => page.assertVisible('h1'), /expected 'h1' element to be visible/) 169 | await assert.rejects(() => page.assertVisible('span'), /expected 'span' element to be visible/) 170 | 171 | await page.evaluate(() => { 172 | setTimeout(() => { 173 | // @ts-expect-error 174 | document.querySelector('h1').style.display = '' 175 | }, 50) 176 | }) 177 | await page.assertVisible('h1') 178 | }) 179 | 180 | test('assert element to be not visible', async ({ assert, cleanup }) => { 181 | const server = new ServerFactory() 182 | await server.create((_, res) => { 183 | res.setHeader('content-type', 'text/html') 184 | res.write( 185 | basicDocument({ 186 | body: ` 187 |

Hello world

188 |

Title

189 | `, 190 | }) 191 | ) 192 | res.end() 193 | }) 194 | 195 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 196 | cleanup(async () => { 197 | await server.close() 198 | await browser.close() 199 | }) 200 | 201 | const page = await browser.newPage() 202 | 203 | await page.goto(server.url) 204 | await page.assertNotVisible('h1') 205 | await page.assertNotVisible('span') 206 | await assert.rejects(() => page.assertNotVisible('p'), /expected 'p' element to be not visible/) 207 | 208 | await page.evaluate(() => { 209 | setTimeout(() => { 210 | // @ts-expect-error 211 | document.querySelector('p').style.display = 'none' 212 | }, 50) 213 | }) 214 | await page.assertNotVisible('p') 215 | }) 216 | 217 | test('assert page title', async ({ assert, cleanup }) => { 218 | const server = new ServerFactory() 219 | await server.create((_, res) => { 220 | res.setHeader('content-type', 'text/html') 221 | res.write(basicDocument({ title: 'Hello world' })) 222 | res.end() 223 | }) 224 | 225 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 226 | cleanup(async () => { 227 | await server.close() 228 | await browser.close() 229 | }) 230 | 231 | const page = await browser.newPage() 232 | 233 | await page.goto(server.url) 234 | await page.assertTitle('Hello world') 235 | await assert.rejects( 236 | () => page.assertTitle('Foo'), 237 | /expected page title 'Hello world' to equal 'Foo'/ 238 | ) 239 | 240 | await page.evaluate(() => { 241 | setTimeout(() => { 242 | // @ts-expect-error 243 | document.title = 'New title' 244 | }, 50) 245 | }) 246 | await page.assertTitle('New title') 247 | }) 248 | 249 | test('assert page title to include a substr', async ({ assert, cleanup }) => { 250 | const server = new ServerFactory() 251 | await server.create((_, res) => { 252 | res.setHeader('content-type', 'text/html') 253 | res.write(basicDocument({ title: 'Hello world' })) 254 | res.end() 255 | }) 256 | 257 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 258 | cleanup(async () => { 259 | await server.close() 260 | await browser.close() 261 | }) 262 | 263 | const page = await browser.newPage() 264 | 265 | await page.goto(server.url) 266 | await page.assertTitleContains('world') 267 | await assert.rejects( 268 | () => page.assertTitleContains('Foo'), 269 | /expected page title 'Hello world' to include 'Foo'/ 270 | ) 271 | 272 | await page.evaluate(() => { 273 | setTimeout(() => { 274 | // @ts-expect-error 275 | document.title = 'New title' 276 | }, 50) 277 | }) 278 | await page.assertTitleContains('New ') 279 | }) 280 | 281 | test('assert page URL', async ({ assert, cleanup }) => { 282 | assert.plan(1) 283 | 284 | const server = new ServerFactory() 285 | await server.create((_, res) => { 286 | res.setHeader('content-type', 'text/html') 287 | res.write(basicDocument()) 288 | res.end() 289 | }) 290 | 291 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 292 | cleanup(async () => { 293 | await server.close() 294 | await browser.close() 295 | }) 296 | 297 | const page = await browser.newPage() 298 | 299 | await page.goto(`${server.url}/foo/bar`) 300 | await page.assertUrl(`${server.url}/foo/bar`) 301 | await assert.rejects( 302 | () => page.assertUrl('Foo'), 303 | new RegExp("expected page URL 'http://localhost:3000/foo/bar' to equal 'Foo'") 304 | ) 305 | 306 | await page.evaluate(() => { 307 | setTimeout(() => { 308 | // @ts-expect-error 309 | location.href = '/bar/foo' 310 | }, 50) 311 | }) 312 | await page.assertUrl(`${server.url}/bar/foo`) 313 | }) 314 | 315 | test('assert page URL with query string', async ({ assert, cleanup }) => { 316 | assert.plan(1) 317 | 318 | const server = new ServerFactory() 319 | await server.create((_, res) => { 320 | res.setHeader('content-type', 'text/html') 321 | res.write(` 322 | 323 | Hello world 324 | 325 | 326 | 327 | `) 328 | res.end() 329 | }) 330 | 331 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 332 | cleanup(async () => { 333 | await server.close() 334 | await browser.close() 335 | }) 336 | 337 | const page = await browser.newPage() 338 | 339 | await page.goto(`${server.url}/foo/bar?sort=id`) 340 | await page.assertUrl(`${server.url}/foo/bar?sort=id`) 341 | await assert.rejects( 342 | () => page.assertUrl('Foo?sort=id'), 343 | new RegExp( 344 | "expected page URL 'http://localhost:3000/foo/bar\\?sort=id' to equal 'Foo\\?sort=id'" 345 | ) 346 | ) 347 | }) 348 | 349 | test('assert page URL to include a substring', async ({ assert, cleanup }) => { 350 | assert.plan(1) 351 | 352 | const server = new ServerFactory() 353 | await server.create((_, res) => { 354 | res.setHeader('content-type', 'text/html') 355 | res.write(basicDocument()) 356 | res.end() 357 | }) 358 | 359 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 360 | cleanup(async () => { 361 | await server.close() 362 | await browser.close() 363 | }) 364 | 365 | const page = await browser.newPage() 366 | 367 | await page.goto(`${server.url}/foo/bar?sort=id`) 368 | await page.assertUrlContains(`${server.url}/foo/bar`) 369 | await assert.rejects( 370 | () => page.assertUrlContains('baz'), 371 | new RegExp("expected page URL 'http://localhost:3000/foo/bar\\?sort=id' to include 'baz'") 372 | ) 373 | 374 | await page.evaluate(() => { 375 | setTimeout(() => { 376 | // @ts-expect-error 377 | location.href = '/bar/foo' 378 | }, 50) 379 | }) 380 | await page.assertUrlContains('ar/fo') 381 | }) 382 | 383 | test('assert page URL to match regex', async ({ assert, cleanup }) => { 384 | const server = new ServerFactory() 385 | await server.create((_, res) => { 386 | res.setHeader('content-type', 'text/html') 387 | res.write(basicDocument()) 388 | res.end() 389 | }) 390 | 391 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 392 | cleanup(async () => { 393 | await server.close() 394 | await browser.close() 395 | }) 396 | 397 | const page = await browser.newPage() 398 | 399 | await page.goto(`${server.url}/foo/bar?sort=id`) 400 | await page.assertUrlMatches(/foo/) 401 | await assert.rejects( 402 | () => page.assertUrlMatches(/baz/), 403 | new RegExp("expected page URL 'http://localhost:3000/foo/bar\\?sort=id' to match '/baz/'") 404 | ) 405 | 406 | await page.evaluate(() => { 407 | setTimeout(() => { 408 | // @ts-expect-error 409 | location.href = '/bar/foo' 410 | }, 50) 411 | }) 412 | await page.assertUrlMatches(/ar\/fo/) 413 | }) 414 | 415 | test('assert page path', async ({ assert, cleanup }) => { 416 | const server = new ServerFactory() 417 | await server.create((_, res) => { 418 | res.setHeader('content-type', 'text/html') 419 | res.write(basicDocument()) 420 | res.end() 421 | }) 422 | 423 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 424 | cleanup(async () => { 425 | await server.close() 426 | await browser.close() 427 | }) 428 | 429 | const page = await browser.newPage() 430 | 431 | await page.goto(`${server.url}/foo/bar`) 432 | await page.assertPath('/foo/bar') 433 | await assert.rejects( 434 | () => page.assertPath('baz'), 435 | new RegExp("expected page pathname '/foo/bar' to equal 'baz'") 436 | ) 437 | 438 | await page.evaluate(() => { 439 | setTimeout(() => { 440 | // @ts-expect-error 441 | location.href = '/bar/foo' 442 | }, 50) 443 | }) 444 | await page.assertPath('/bar/foo') 445 | }) 446 | 447 | test('assert page path to contain a substring', async ({ assert, cleanup }) => { 448 | const server = new ServerFactory() 449 | await server.create((_, res) => { 450 | res.setHeader('content-type', 'text/html') 451 | res.write(basicDocument()) 452 | res.end() 453 | }) 454 | 455 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 456 | cleanup(async () => { 457 | await server.close() 458 | await browser.close() 459 | }) 460 | 461 | const page = await browser.newPage() 462 | 463 | await page.goto(`${server.url}/foo/bar`) 464 | await page.assertPathContains('foo') 465 | await assert.rejects( 466 | () => page.assertPathContains('baz'), 467 | new RegExp("expected page pathname '/foo/bar' to include 'baz'") 468 | ) 469 | 470 | await page.evaluate(() => { 471 | setTimeout(() => { 472 | // @ts-expect-error 473 | location.href = '/bar/foo' 474 | }, 50) 475 | }) 476 | await page.assertPathContains('ar/fo') 477 | }) 478 | 479 | test('assert page path to match regex', async ({ assert, cleanup }) => { 480 | const server = new ServerFactory() 481 | await server.create((_, res) => { 482 | res.setHeader('content-type', 'text/html') 483 | res.write(basicDocument()) 484 | res.end() 485 | }) 486 | 487 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 488 | cleanup(async () => { 489 | await server.close() 490 | await browser.close() 491 | }) 492 | 493 | const page = await browser.newPage() 494 | 495 | await page.goto(`${server.url}/foo/bar`) 496 | await page.assertPathMatches(/foo/) 497 | await assert.rejects( 498 | () => page.assertPathMatches(/baz/), 499 | new RegExp("expected page pathname '/foo/bar' to match '/baz/'") 500 | ) 501 | 502 | await page.evaluate(() => { 503 | setTimeout(() => { 504 | // @ts-expect-error 505 | location.href = '/bar/foo' 506 | }, 50) 507 | }) 508 | await page.assertPathMatches(/ar\/fo/) 509 | }) 510 | 511 | test('assert page path with query string', async ({ assert, cleanup }) => { 512 | const server = new ServerFactory() 513 | await server.create((_, res) => { 514 | res.setHeader('content-type', 'text/html') 515 | res.write(basicDocument()) 516 | res.end() 517 | }) 518 | 519 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 520 | cleanup(async () => { 521 | await server.close() 522 | await browser.close() 523 | }) 524 | 525 | const page = await browser.newPage() 526 | 527 | await page.goto(`${server.url}/foo/bar?sort=id`) 528 | await page.assertPath('/foo/bar') 529 | await assert.rejects( 530 | () => page.assertPath('baz'), 531 | /expected page pathname '\/foo\/bar' to equal 'baz'/ 532 | ) 533 | }) 534 | 535 | test('assert page query string', async ({ assert, cleanup }) => { 536 | const server = new ServerFactory() 537 | await server.create((_, res) => { 538 | res.setHeader('content-type', 'text/html') 539 | res.write(basicDocument()) 540 | res.end() 541 | }) 542 | 543 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 544 | cleanup(async () => { 545 | await server.close() 546 | await browser.close() 547 | }) 548 | 549 | const page = await browser.newPage() 550 | 551 | await page.goto(`${server.url}/foo/bar?sort=id&sortDir=asc`) 552 | await page.assertQueryString({ sort: 'id' }) 553 | await page.assertQueryString({ sortDir: 'asc' }) 554 | await assert.rejects( 555 | () => page.assertQueryString({ orderBy: 'id' }), 556 | /expected '\{ sort: 'id', sortDir: 'asc' \}' to contain '\{ orderBy: 'id' \}'/ 557 | ) 558 | 559 | await page.evaluate(() => { 560 | setTimeout(() => { 561 | // @ts-expect-error 562 | location.href = '/foo/bar?sort=id&sortDir=desc' 563 | }, 50) 564 | }) 565 | await page.assertQueryString({ sortDir: 'desc' }) 566 | }) 567 | 568 | test('assert page to have a cookie', async ({ assert, cleanup }) => { 569 | const server = new ServerFactory() 570 | await server.create((req, res) => { 571 | if (req.url === '/set_cookie') { 572 | res.setHeader('set-cookie', 'cart_items=3') 573 | } 574 | 575 | if (req.url === '/set_other_cookie') { 576 | res.setHeader('set-cookie', 'cart_items=4') 577 | } 578 | 579 | res.setHeader('content-type', 'text/html') 580 | res.write(basicDocument()) 581 | res.end() 582 | }) 583 | 584 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 585 | cleanup(async () => { 586 | await server.close() 587 | await browser.close() 588 | }) 589 | 590 | const page = await browser.newPage() 591 | 592 | await page.goto(server.url) 593 | await assert.rejects( 594 | () => page.assertCookie('cart_items'), 595 | /expected 'cart_items' cookie to exist/ 596 | ) 597 | 598 | await page.goto(`${server.url}/set_cookie`) 599 | await page.assertCookie('cart_items') 600 | 601 | await page.goto(`${server.url}/set_cookie`) 602 | await assert.rejects( 603 | () => page.assertCookie('cart_items', '2'), 604 | /expected 'cart_items' cookie value to equal '2'/ 605 | ) 606 | 607 | await page.goto(`${server.url}/set_cookie`) 608 | await page.assertCookie('cart_items', '3') 609 | 610 | await page.evaluate(() => { 611 | setTimeout(() => { 612 | // @ts-expect-error 613 | location.href = '/set_other_cookie' 614 | }, 50) 615 | }) 616 | await page.assertCookie('cart_items', '4') 617 | }) 618 | 619 | test('assert cookie to be missing', async ({ assert, cleanup }) => { 620 | const server = new ServerFactory() 621 | await server.create((req, res) => { 622 | if (req.url === '/set_cookie') { 623 | res.setHeader('set-cookie', 'cart_items=3') 624 | } 625 | 626 | res.setHeader('content-type', 'text/html') 627 | res.write(basicDocument()) 628 | res.end() 629 | }) 630 | 631 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 632 | cleanup(async () => { 633 | await server.close() 634 | await browser.close() 635 | }) 636 | 637 | const page = await browser.newPage() 638 | 639 | await page.goto(server.url) 640 | await page.assertCookieMissing('cart_items') 641 | 642 | await page.goto(`${server.url}/set_cookie`) 643 | await assert.rejects( 644 | () => page.assertCookieMissing('cart_items'), 645 | /expected 'cart_items' cookie to not exist/ 646 | ) 647 | 648 | await page.evaluate(() => { 649 | setTimeout(() => { 650 | // @ts-expect-error 651 | document.cookie = 652 | 'cart_items=; Max-Age=0; path=/; domain=localhost;expires=Thu, 01 Jan 1970 00:00:01 GMT' 653 | }, 50) 654 | }) 655 | await page.assertCookieMissing('cart_items') 656 | }) 657 | 658 | test('assert element innerText', async ({ assert, cleanup }) => { 659 | const server = new ServerFactory() 660 | await server.create((_, res) => { 661 | res.setHeader('content-type', 'text/html') 662 | res.write( 663 | basicDocument({ 664 | body: ` 665 |

It works!

666 |

Hello world

667 | `, 668 | }) 669 | ) 670 | res.end() 671 | }) 672 | 673 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 674 | cleanup(async () => { 675 | await server.close() 676 | await browser.close() 677 | }) 678 | 679 | const page = await browser.newPage() 680 | 681 | await page.goto(server.url) 682 | await page.assertText('body', ['It works!', '', 'Hello world'].join('\n')) 683 | await page.assertText('h1', 'It works!') 684 | await page.assertText('p', 'Hello world') 685 | 686 | await assert.rejects( 687 | () => page.assertText('p', 'Aloha'), 688 | /expected 'p' inner text to equal 'Aloha'/ 689 | ) 690 | 691 | await page.evaluate(() => { 692 | setTimeout(() => { 693 | // @ts-expect-error 694 | document.querySelector('p').innerText = 'Aloha' 695 | }, 50) 696 | }) 697 | await page.assertText('p', 'Aloha') 698 | }) 699 | 700 | test('assert elementsText', async ({ assert, cleanup }) => { 701 | const server = new ServerFactory() 702 | await server.create((_, res) => { 703 | res.setHeader('content-type', 'text/html') 704 | res.write( 705 | basicDocument({ 706 | body: ` 707 |
    708 |
  • Hello world
  • 709 |
  • Hi world
  • 710 |
  • Bye world
  • 711 |
712 | `, 713 | }) 714 | ) 715 | res.end() 716 | }) 717 | 718 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 719 | cleanup(async () => { 720 | await server.close() 721 | await browser.close() 722 | }) 723 | 724 | const page = await browser.newPage() 725 | 726 | await page.goto(server.url) 727 | await page.assertElementsText('ul > li', ['Hello world', 'Hi world', 'Bye world']) 728 | 729 | await assert.rejects( 730 | () => page.assertElementsText('ul > li', ['Hello world', 'Hi world', 'Goodbye world']), 731 | /expected 'ul > li' value to deeply equal \[ 'Hello world', 'Hi world', 'Goodbye world' \] in same order/ 732 | ) 733 | 734 | await page.evaluate(() => { 735 | setTimeout(() => { 736 | // @ts-expect-error 737 | document.querySelector('li:last-child').innerText = 'Goodbye world' 738 | }, 50) 739 | }) 740 | await page.assertElementsText('ul > li', ['Hello world', 'Hi world', 'Goodbye world']) 741 | }) 742 | 743 | test('assert element innerText to include substring', async ({ assert, cleanup }) => { 744 | const server = new ServerFactory() 745 | await server.create((_, res) => { 746 | res.setHeader('content-type', 'text/html') 747 | res.write( 748 | basicDocument({ 749 | body: ` 750 |

It works!

751 |

Hello world

752 | `, 753 | }) 754 | ) 755 | res.end() 756 | }) 757 | 758 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 759 | cleanup(async () => { 760 | await server.close() 761 | await browser.close() 762 | }) 763 | 764 | const page = await browser.newPage() 765 | 766 | await page.goto(server.url) 767 | 768 | await page.assertTextContains('body', 'Hello world') 769 | await page.assertTextContains('h1', 'works') 770 | await page.assertTextContains('p', 'world') 771 | await assert.rejects( 772 | () => page.assertTextContains('p', 'Aloha'), 773 | /expected 'p' inner text to include 'Aloha'/ 774 | ) 775 | 776 | await page.evaluate(() => { 777 | setTimeout(() => { 778 | // @ts-expect-error 779 | document.querySelector('p').innerText = 'Aloha world' 780 | }, 50) 781 | }) 782 | await page.assertTextContains('p', 'Aloha') 783 | }) 784 | 785 | test('assert a checkbox is checked', async ({ assert, cleanup }) => { 786 | const server = new ServerFactory() 787 | await server.create((_, res) => { 788 | res.setHeader('content-type', 'text/html') 789 | res.write( 790 | basicDocument({ 791 | body: ` 792 |
793 | Terms and conditions 794 | Subscribe to newsletter 795 | 796 |
797 | `, 798 | }) 799 | ) 800 | res.end() 801 | }) 802 | 803 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 804 | cleanup(async () => { 805 | await server.close() 806 | await browser.close() 807 | }) 808 | 809 | const page = await browser.newPage() 810 | 811 | await page.goto(server.url) 812 | 813 | await page.assertChecked('input[name="terms"]') 814 | await assert.rejects( 815 | () => page.assertChecked('input[name="newsletter"]'), 816 | /expected 'input\[name="newsletter"\]' checkbox to be checked/ 817 | ) 818 | 819 | await assert.rejects( 820 | () => page.assertChecked('input[name="foo"]'), 821 | /expected 'input\[name="foo"\]' to be a checkbox/ 822 | ) 823 | 824 | await page.evaluate(() => { 825 | setTimeout(() => { 826 | // @ts-expect-error 827 | document.querySelector('input[name="newsletter"]').checked = true 828 | }, 50) 829 | }) 830 | await page.assertChecked('input[name="newsletter"]') 831 | }) 832 | 833 | test('assert a checkbox is not checked', async ({ assert, cleanup }) => { 834 | const server = new ServerFactory() 835 | await server.create((_, res) => { 836 | res.setHeader('content-type', 'text/html') 837 | res.write( 838 | basicDocument({ 839 | body: ` 840 |
841 | Terms and conditions 842 | Subscribe to newsletter 843 | 844 |
845 | `, 846 | }) 847 | ) 848 | res.end() 849 | }) 850 | 851 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 852 | cleanup(async () => { 853 | await server.close() 854 | await browser.close() 855 | }) 856 | 857 | const page = await browser.newPage() 858 | 859 | await page.goto(server.url) 860 | 861 | await page.assertNotChecked('input[name="newsletter"]') 862 | await assert.rejects( 863 | () => page.assertNotChecked('input[name="terms"]'), 864 | /expected 'input\[name="terms"\]' checkbox to be not checked/ 865 | ) 866 | 867 | await assert.rejects( 868 | () => page.assertNotChecked('input[name="foo"]'), 869 | /expected 'input\[name="foo"\]' to be a checkbox/ 870 | ) 871 | 872 | await page.evaluate(() => { 873 | setTimeout(() => { 874 | // @ts-expect-error 875 | document.querySelector('input[name="terms"]').checked = false 876 | }, 50) 877 | }) 878 | await page.assertNotChecked('input[name="terms"]') 879 | }) 880 | 881 | test('assert element is disabled', async ({ assert, cleanup }) => { 882 | const server = new ServerFactory() 883 | await server.create((_, res) => { 884 | res.setHeader('content-type', 'text/html') 885 | res.write( 886 | basicDocument({ 887 | body: ` 888 | Terms and conditions 889 | Subscribe to newsletter 890 |
891 | `, 892 | }) 893 | ) 894 | res.end() 895 | }) 896 | 897 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 898 | cleanup(async () => { 899 | await server.close() 900 | await browser.close() 901 | }) 902 | 903 | const page = await browser.newPage() 904 | 905 | await page.goto(server.url) 906 | 907 | await page.assertDisabled('input[name="terms"]') 908 | await assert.rejects( 909 | () => page.assertDisabled('input[name="newsletter"]'), 910 | /expected 'input\[name="newsletter"\]' element to be disabled/ 911 | ) 912 | await assert.rejects( 913 | () => page.assertDisabled('#foo'), 914 | /expected '#foo' element to be disabled/ 915 | ) 916 | 917 | await page.evaluate(() => { 918 | setTimeout(() => { 919 | // @ts-expect-error 920 | document.querySelector('input[name="newsletter"]').toggleAttribute('disabled', true) 921 | }, 50) 922 | }) 923 | await page.assertDisabled('input[name="newsletter"]') 924 | }) 925 | 926 | test('assert element is not disabled', async ({ assert, cleanup }) => { 927 | const server = new ServerFactory() 928 | await server.create((_, res) => { 929 | res.setHeader('content-type', 'text/html') 930 | res.write( 931 | basicDocument({ 932 | body: ` 933 | Terms and conditions 934 | Subscribe to newsletter 935 |
936 | `, 937 | }) 938 | ) 939 | res.end() 940 | }) 941 | 942 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 943 | cleanup(async () => { 944 | await server.close() 945 | await browser.close() 946 | }) 947 | 948 | const page = await browser.newPage() 949 | 950 | await page.goto(server.url) 951 | 952 | await page.assertNotDisabled('input[name="newsletter"]') 953 | await page.assertNotDisabled('#foo') 954 | await assert.rejects( 955 | () => page.assertNotDisabled('input[name="terms"]'), 956 | /expected 'input\[name="terms"\]' element to be not disabled/ 957 | ) 958 | 959 | await page.evaluate(() => { 960 | setTimeout(() => { 961 | // @ts-expect-error 962 | document.querySelector('input[name="terms"]').toggleAttribute('disabled', false) 963 | }, 50) 964 | }) 965 | await page.assertNotDisabled('input[name="terms"]') 966 | }) 967 | 968 | test('assert input value', async ({ assert, cleanup }) => { 969 | const server = new ServerFactory() 970 | await server.create((_, res) => { 971 | res.setHeader('content-type', 'text/html') 972 | res.write( 973 | basicDocument({ 974 | body: ` 975 | 976 | 977 | 982 |
983 | `, 984 | }) 985 | ) 986 | res.end() 987 | }) 988 | 989 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 990 | cleanup(async () => { 991 | await server.close() 992 | await browser.close() 993 | }) 994 | 995 | const page = await browser.newPage() 996 | 997 | await page.goto(server.url) 998 | 999 | await page.fill('input[name="fullname"]', 'virk') 1000 | await page.assertInputValue('input[name="fullname"]', 'virk') 1001 | 1002 | await page.selectOption('select[name="country"]', 'IND') 1003 | await page.assertInputValue('select[name="country"]', 'IND') 1004 | 1005 | await page.fill('input[name="age"]', '32') 1006 | await page.assertInputValue('input[name="age"]', '32') 1007 | 1008 | await assert.rejects( 1009 | () => page.assertInputValue('#foo', 'IND'), 1010 | /expected '#foo' element to be an input, select or a textarea/ 1011 | ) 1012 | await assert.rejects( 1013 | () => page.assertInputValue('input[name="fullname"]', 'john doe'), 1014 | /expected 'input\[name="fullname"\]' value to equal 'john doe'/ 1015 | ) 1016 | 1017 | await page.evaluate(() => { 1018 | setTimeout(() => { 1019 | // @ts-expect-error 1020 | document.querySelector('input[name="fullname"]').value = 'john doe' 1021 | }, 50) 1022 | }) 1023 | await page.assertInputValue('input[name="fullname"]', 'john doe') 1024 | }) 1025 | 1026 | test('assert select options', async ({ assert, cleanup }) => { 1027 | const server = new ServerFactory() 1028 | await server.create((_, res) => { 1029 | res.setHeader('content-type', 'text/html') 1030 | res.write( 1031 | basicDocument({ 1032 | body: ` 1033 | 1038 | 1039 | 1046 | 1047 |
1048 | `, 1049 | }) 1050 | ) 1051 | res.end() 1052 | }) 1053 | 1054 | const browser = decorateBrowser(await chromium.launch(), [addAssertions], pluginConfig) 1055 | cleanup(async () => { 1056 | await server.close() 1057 | await browser.close() 1058 | }) 1059 | 1060 | const page = await browser.newPage() 1061 | 1062 | await page.goto(server.url) 1063 | 1064 | await page.selectOption('select[name="country"]', 'IND') 1065 | await page.assertSelectedOptions('select[name="country"]', ['IND']) 1066 | 1067 | await page.selectOption('select[name="tags"]', ['js', 'css']) 1068 | await page.assertSelectedOptions('select[name="tags"]', ['css', 'js']) 1069 | 1070 | await assert.rejects( 1071 | () => page.assertSelectedOptions('#foo', []), 1072 | /expected '#foo' element to be a select box/ 1073 | ) 1074 | await assert.rejects( 1075 | () => page.assertSelectedOptions('select[name="country"]', ['FR']), 1076 | /expected 'select\[name="country"\]' value to equal \[ 'FR' \]/ 1077 | ) 1078 | 1079 | await page.evaluate(() => { 1080 | setTimeout(() => { 1081 | // @ts-expect-error 1082 | document.querySelector('select[name="country"]').value = 'FR' 1083 | }, 50) 1084 | }) 1085 | await page.assertSelectedOptions('select[name="country"]', ['FR']) 1086 | }) 1087 | }) 1088 | --------------------------------------------------------------------------------