├── .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 |
--------------------------------------------------------------------------------