diff --git a/.all-contributorsrc b/.all-contributorsrc index 45384aa1..7c01b095 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -236,7 +236,8 @@ "profile": "https://twitter.com/diegohaz", "contributions": [ "bug", - "code" + "code", + "ideas" ] }, { @@ -325,7 +326,8 @@ "avatar_url": "https://avatars3.githubusercontent.com/u/10261750?v=4", "profile": "https://github.com/pwolaq", "contributions": [ - "test" + "test", + "code" ] }, { @@ -697,9 +699,155 @@ "code", "ideas" ] + }, + { + "login": "fpapado", + "name": "Fotis Papadogeorgopoulos", + "avatar_url": "https://avatars.githubusercontent.com/u/3210764?v=4", + "profile": "http://fotis.xyz", + "contributions": [ + "code", + "doc", + "test" + ] + }, + { + "login": "jakeboone02", + "name": "Jake Boone", + "avatar_url": "https://avatars.githubusercontent.com/u/366438?v=4", + "profile": "https://github.com/jakeboone02", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "SteKoe", + "name": "Stephan Köninger", + "avatar_url": "https://avatars.githubusercontent.com/u/1809221?v=4", + "profile": "http://www.stekoe.de", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "kryops", + "name": "Michael Manzinger", + "avatar_url": "https://avatars.githubusercontent.com/u/1042594?v=4", + "profile": "https://github.com/kryops", + "contributions": [ + "bug", + "code", + "test" + ] + }, + { + "login": "Dennis273", + "name": "Dennis Chen", + "avatar_url": "https://avatars.githubusercontent.com/u/19815164?v=4", + "profile": "https://github.com/Dennis273", + "contributions": [ + "code" + ] + }, + { + "login": "tonyhallett", + "name": "Tony Hallett", + "avatar_url": "https://avatars.githubusercontent.com/u/11292998?v=4", + "profile": "https://github.com/tonyhallett", + "contributions": [ + "bug" + ] + }, + { + "login": "ddolcimascolo", + "name": "David DOLCIMASCOLO", + "avatar_url": "https://avatars.githubusercontent.com/u/5468291?v=4", + "profile": "https://github.com/ddolcimascolo", + "contributions": [ + "maintenance" + ] + }, + { + "login": "aleks-elkin", + "name": "Aleksandr Elkin", + "avatar_url": "https://avatars.githubusercontent.com/u/55530374?v=4", + "profile": "https://github.com/aleks-elkin", + "contributions": [ + "maintenance" + ] + }, + { + "login": "vorant94", + "name": "Mordechai Dror", + "avatar_url": "https://avatars.githubusercontent.com/u/9719319?v=4", + "profile": "https://www.vorant94.io/", + "contributions": [ + "code" + ] + }, + { + "login": "waynevanson", + "name": "Wayne Van Son", + "avatar_url": "https://avatars.githubusercontent.com/u/29592214?v=4", + "profile": "http://www.waynevanson.com", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "idanen", + "name": "Idan Entin", + "avatar_url": "https://avatars.githubusercontent.com/u/1687893?v=4", + "profile": "https://github.com/idanen", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "mibcadet", + "name": "mibcadet", + "avatar_url": "https://avatars.githubusercontent.com/u/925500?v=4", + "profile": "https://github.com/mibcadet", + "contributions": [ + "doc" + ] + }, + { + "login": "silviuaavram", + "name": "Silviu Alexandru Avram", + "avatar_url": "https://avatars.githubusercontent.com/u/11275392?v=4", + "profile": "https://silviuaavram.com", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "G-Rath", + "name": "Gareth Jones", + "avatar_url": "https://avatars.githubusercontent.com/u/3151613?v=4", + "profile": "https://github.com/G-Rath", + "contributions": [ + "code" + ] + }, + { + "login": "billyjanitsch", + "name": "Billy Janitsch", + "avatar_url": "https://avatars.githubusercontent.com/u/1158733?v=4", + "profile": "https://billyjanitsch.com", + "contributions": [ + "bug" + ] } ], "repoHost": "https://github.com", "contributorsPerLine": 7, - "skipCi": false + "skipCi": false, + "commitType": "docs", + "commitConvention": "angular" } diff --git a/.github/ISSUE_TEMPLATE/Bug_Report.md b/.github/ISSUE_TEMPLATE/Bug_Report.md index 42b68d23..d6c5c0d3 100644 --- a/.github/ISSUE_TEMPLATE/Bug_Report.md +++ b/.github/ISSUE_TEMPLATE/Bug_Report.md @@ -20,6 +20,7 @@ tutorial to learn how: http://kcd.im/pull-request - `@testing-library/jest-dom` version: - `node` version: +- `jest` (or `vitest`) version: - `npm` (or `yarn`) version: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Kent C. Dodds

💻 📖 🚇 ⚠️

Ryan Castner

📖

Daniel Sandiego

💻

Paweł Mikołajczyk

💻

Alejandro Ñáñez Ortiz

📖

Matt Parrish

🐛 💻 📖 ⚠️

Justin Hall

📦

Anto Aravinth

💻 ⚠️ 📖

Jonah Moses

📖

Łukasz Gandecki

💻 ⚠️ 📖

Ivan Babak

🐛 🤔

Jesse Day

💻

Ernesto García

💻 📖 ⚠️

Mark Volkmann

🐛 💻

smacpherson64

💻 📖 ⚠️

John Gozde

🐛 💻

Iwona

💻 📖 ⚠️

Lewis

💻

Leandro Lourenci

🐛 📖 💻 ⚠️

Shukhrat Mukimov

🐛

Roman Usherenko

💻 ⚠️

Joe Hsu

📖

Haz

🐛 💻

Revath S Kumar

💻

hiwelo.

💻 🤔 ⚠️

Łukasz Fiszer

💻

Jean Chung

💻 ⚠️

CarlaTeo

💻 ⚠️

Yarden Shoham

📖

Jaga Santagostino

🐛 ⚠️ 📖

Connor Meredith

💻 ⚠️ 📖

Pawel Wolak

⚠️

Michaël De Boey

🚇

Jānis Zaržeckis

📖

koala-lava

📖

Juan Pablo Blanco

📖

Ben Monro

📖

Jeff Bernstein

📖

Sergi

💻 ⚠️

Spencer Miskoviak

📖

Jon Rimmer

💻 ⚠️

Luca Barone

💻 ⚠️ 🤔

Malte Felmy

💻 ⚠️

Championrunner

📖

Patrick Smith

💻 ⚠️ 📖

Rubén Moya

💻 ⚠️ 📖

Daniela Valero

💻 ⚠️ 📖

Vladislav Katsura

💻 ⚠️

Tim Fischbach

💻 ⚠️ 🤔

Katie Boedges

🚇

Brian Alexis

⚠️

Boris Serdiuk

🐛 💻 ⚠️

Dana Woodman

📖

Mo Sattler

📖

Geoff Rich

💻 ⚠️ 🤔 🐛

Syneva

💻

Nick McCurdy

📖 🐛 💻

Obed Marquez Parlapiano

📖

Caleb Eby

📖 💻 ⚠️

Marcel Barner

💻 ⚠️

Doma

💻 ⚠️

Julien Wajsberg

💻 ⚠️

steven nguyen

📖

tu4mo

📖

Matan Borenkraout

📦

Yann Braga

💻

Ian VanSchooten

💻

Chantal Broeren

📖

Jérémie Astori

💻 🤔

Ashley Ryan

💻 🤔
Kent C. Dodds
Kent C. Dodds

💻 📖 🚇 ⚠️
Ryan Castner
Ryan Castner

📖
Daniel Sandiego
Daniel Sandiego

💻
Paweł Mikołajczyk
Paweł Mikołajczyk

💻
Alejandro Ñáñez Ortiz
Alejandro Ñáñez Ortiz

📖
Matt Parrish
Matt Parrish

🐛 💻 📖 ⚠️
Justin Hall
Justin Hall

📦
Anto Aravinth
Anto Aravinth

💻 ⚠️ 📖
Jonah Moses
Jonah Moses

📖
Łukasz Gandecki
Łukasz Gandecki

💻 ⚠️ 📖
Ivan Babak
Ivan Babak

🐛 🤔
Jesse Day
Jesse Day

💻
Ernesto García
Ernesto García

💻 📖 ⚠️
Mark Volkmann
Mark Volkmann

🐛 💻
smacpherson64
smacpherson64

💻 📖 ⚠️
John Gozde
John Gozde

🐛 💻
Iwona
Iwona

💻 📖 ⚠️
Lewis
Lewis

💻
Leandro Lourenci
Leandro Lourenci

🐛 📖 💻 ⚠️
Shukhrat Mukimov
Shukhrat Mukimov

🐛
Roman Usherenko
Roman Usherenko

💻 ⚠️
Joe Hsu
Joe Hsu

📖
Haz
Haz

🐛 💻 🤔
Revath S Kumar
Revath S Kumar

💻
hiwelo.
hiwelo.

💻 🤔 ⚠️
Łukasz Fiszer
Łukasz Fiszer

💻
Jean Chung
Jean Chung

💻 ⚠️
CarlaTeo
CarlaTeo

💻 ⚠️
Yarden Shoham
Yarden Shoham

📖
Jaga Santagostino
Jaga Santagostino

🐛 ⚠️ 📖
Connor Meredith
Connor Meredith

💻 ⚠️ 📖
Pawel Wolak
Pawel Wolak

⚠️ 💻
Michaël De Boey
Michaël De Boey

🚇
Jānis Zaržeckis
Jānis Zaržeckis

📖
koala-lava
koala-lava

📖
Juan Pablo Blanco
Juan Pablo Blanco

📖
Ben Monro
Ben Monro

📖
Jeff Bernstein
Jeff Bernstein

📖
Sergi
Sergi

💻 ⚠️
Spencer Miskoviak
Spencer Miskoviak

📖
Jon Rimmer
Jon Rimmer

💻 ⚠️
Luca Barone
Luca Barone

💻 ⚠️ 🤔
Malte Felmy
Malte Felmy

💻 ⚠️
Championrunner
Championrunner

📖
Patrick Smith
Patrick Smith

💻 ⚠️ 📖
Rubén Moya
Rubén Moya

💻 ⚠️ 📖
Daniela Valero
Daniela Valero

💻 ⚠️ 📖
Vladislav Katsura
Vladislav Katsura

💻 ⚠️
Tim Fischbach
Tim Fischbach

💻 ⚠️ 🤔
Katie Boedges
Katie Boedges

🚇
Brian Alexis
Brian Alexis

⚠️
Boris Serdiuk
Boris Serdiuk

🐛 💻 ⚠️
Dana Woodman
Dana Woodman

📖
Mo Sattler
Mo Sattler

📖
Geoff Rich
Geoff Rich

💻 ⚠️ 🤔 🐛
Syneva
Syneva

💻
Nick McCurdy
Nick McCurdy

📖 🐛 💻
Obed Marquez Parlapiano
Obed Marquez Parlapiano

📖
Caleb Eby
Caleb Eby

📖 💻 ⚠️
Marcel Barner
Marcel Barner

💻 ⚠️
Doma
Doma

💻 ⚠️
Julien Wajsberg
Julien Wajsberg

💻 ⚠️
steven nguyen
steven nguyen

📖
tu4mo
tu4mo

📖
Matan Borenkraout
Matan Borenkraout

📦
Yann Braga
Yann Braga

💻
Ian VanSchooten
Ian VanSchooten

💻
Chantal Broeren
Chantal Broeren

📖
Jérémie Astori
Jérémie Astori

💻 🤔
Ashley Ryan
Ashley Ryan

💻 🤔
Fotis Papadogeorgopoulos
Fotis Papadogeorgopoulos

💻 📖 ⚠️
Jake Boone
Jake Boone

💻 ⚠️
Stephan Köninger
Stephan Köninger

🐛 💻
Michael Manzinger
Michael Manzinger

🐛 💻 ⚠️
Dennis Chen
Dennis Chen

💻
Tony Hallett
Tony Hallett

🐛
David DOLCIMASCOLO
David DOLCIMASCOLO

🚧
Aleksandr Elkin
Aleksandr Elkin

🚧
Mordechai Dror
Mordechai Dror

💻
Wayne Van Son
Wayne Van Son

💻 ⚠️
Idan Entin
Idan Entin

💻 ⚠️
mibcadet
mibcadet

📖
Silviu Alexandru Avram
Silviu Alexandru Avram

💻 ⚠️
Gareth Jones
Gareth Jones

💻
Billy Janitsch
Billy Janitsch

🐛
diff --git a/extend-expect.js b/extend-expect.js deleted file mode 100644 index e7d19c10..00000000 --- a/extend-expect.js +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line -require('./dist/extend-expect') diff --git a/jest-globals.d.ts b/jest-globals.d.ts new file mode 100644 index 00000000..6c8e0b60 --- /dev/null +++ b/jest-globals.d.ts @@ -0,0 +1 @@ +/// diff --git a/jest-globals.js b/jest-globals.js new file mode 100644 index 00000000..eeaf6ac9 --- /dev/null +++ b/jest-globals.js @@ -0,0 +1,4 @@ +const globals = require('@jest/globals') +const extensions = require('./dist/matchers') + +globals.expect.extend(extensions) diff --git a/matchers.d.ts b/matchers.d.ts new file mode 100644 index 00000000..c1ec8ce5 --- /dev/null +++ b/matchers.d.ts @@ -0,0 +1,3 @@ +import * as matchers from './types/matchers' + +export = matchers diff --git a/package.json b/package.json index fe373baa..a9ddf16c 100644 --- a/package.json +++ b/package.json @@ -3,24 +3,73 @@ "version": "0.0.0-semantically-released", "description": "Custom jest matchers to test the state of the DOM", "main": "dist/index.js", + "module": "dist/index.mjs", + "exports": { + ".": { + "require": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + }, + "import": { + "types": "./types/index.d.ts", + "default": "./dist/index.mjs" + } + }, + "./jest-globals": { + "require": { + "types": "./types/jest-globals.d.ts", + "default": "./dist/jest-globals.js" + }, + "import": { + "types": "./types/jest-globals.d.ts", + "default": "./dist/jest-globals.mjs" + } + }, + "./matchers": { + "require": { + "types": "./types/matchers-standalone.d.ts", + "default": "./dist/matchers.js" + }, + "import": { + "types": "./types/matchers-standalone.d.ts", + "default": "./dist/matchers.mjs" + } + }, + "./vitest": { + "require": { + "types": "./types/vitest.d.ts", + "default": "./dist/vitest.js" + }, + "import": { + "types": "./types/vitest.d.ts", + "default": "./dist/vitest.mjs" + } + }, + "./package.json": "./package.json" + }, + "types": "types/index.d.ts", "engines": { - "node": ">=8", + "node": ">=14", "npm": ">=6", "yarn": ">=1" }, "scripts": { - "build": "kcd-scripts build", + "build": "rollup -c", "format": "kcd-scripts format", "lint": "kcd-scripts lint", "setup": "npm install && npm run validate -s", "test": "kcd-scripts test", "test:update": "npm test -- --updateSnapshot --coverage", - "validate": "kcd-scripts validate" + "test:types": "tsc -p types/__tests__/jest && tsc -p types/__tests__/jest-globals && tsc -p types/__tests__/vitest && tsc -p types/__tests__/bun", + "validate": "kcd-scripts validate && npm run test:types" }, "files": [ "dist", - "extend-expect.js", - "matchers.js" + "types", + "*.d.ts", + "jest-globals.js", + "matchers.js", + "vitest.js" ], "keywords": [ "testing", @@ -31,27 +80,38 @@ "author": "Ernesto Garcia (http://gnapse.github.io)", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.9.2", - "@types/testing-library__jest-dom": "^5.9.1", + "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", - "css": "^3.0.0", "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.5.6", - "lodash": "^4.17.15", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", "redent": "^3.0.0" }, "devDependencies": { + "@jest/globals": "^29.6.2", + "@rollup/plugin-commonjs": "^25.0.4", + "@types/bun": "latest", + "@types/web": "latest", + "expect": "^29.6.2", "jest-environment-jsdom-sixteen": "^1.0.3", "jest-watch-select-projects": "^2.0.0", "jsdom": "^16.2.1", - "kcd-scripts": "^11.1.0", - "pretty-format": "^25.1.0" + "kcd-scripts": "^14.0.0", + "pretty-format": "^25.1.0", + "rollup": "^3.28.1", + "rollup-plugin-delete": "^2.0.0", + "typescript": "^5.1.6", + "vitest": "^0.34.1" }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", + "parserOptions": { + "sourceType": "module", + "ecmaVersion": 2020 + }, "rules": { - "@babel/no-invalid-this": "off" + "no-invalid-this": "off" }, "overrides": [ { @@ -61,13 +121,26 @@ "rules": { "max-lines-per-function": "off" } + }, + { + "files": [ + "**/*.d.ts" + ], + "rules": { + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-invalid-void-type": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/triple-slash-reference": "off" + } } ] }, "eslintIgnore": [ "node_modules", "coverage", - "dist" + "dist", + "types/__tests__" ], "repository": { "type": "git", diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 00000000..80afe641 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,32 @@ +const del = require('rollup-plugin-delete') +const commonjs = require('@rollup/plugin-commonjs') + +const entries = [ + './src/index.js', + './src/jest-globals.js', + './src/matchers.js', + './src/vitest.js', +] + +module.exports = [ + { + input: entries, + output: [ + { + dir: 'dist', + entryFileNames: '[name].mjs', + chunkFileNames: '[name]-[hash].mjs', + format: 'esm', + }, + { + dir: 'dist', + entryFileNames: '[name].js', + chunkFileNames: '[name]-[hash].js', + format: 'cjs', + }, + ], + external: id => + !id.startsWith('\0') && !id.startsWith('.') && !id.startsWith('/'), + plugins: [del({targets: 'dist/*'}), commonjs()], + }, +] diff --git a/src/__tests__/to-have-accessible-description.js b/src/__tests__/to-have-accessible-description.js index 49c7f20d..794d5645 100644 --- a/src/__tests__/to-have-accessible-description.js +++ b/src/__tests__/to-have-accessible-description.js @@ -57,6 +57,26 @@ describe('.toHaveAccessibleDescription', () => { }).toThrow(/expected element not to have accessible description/i) }) + it('works with aria-description attribute', () => { + const {queryByTestId} = render(` + Company logo + `) + + const logo = queryByTestId('logo') + expect(logo).not.toHaveAccessibleDescription('Company logo') + expect(logo).toHaveAccessibleDescription('The logo of Our Company') + expect(logo).toHaveAccessibleDescription(/logo of our company/i) + expect(logo).toHaveAccessibleDescription( + expect.stringContaining('logo of Our Company'), + ) + expect(() => { + expect(logo).toHaveAccessibleDescription("Our company's logo") + }).toThrow(/expected element to have accessible description/i) + expect(() => { + expect(logo).not.toHaveAccessibleDescription('The logo of Our Company') + }).toThrow(/expected element not to have accessible description/i) + }) + it('handles multiple ids', () => { const {queryByTestId} = render(`
diff --git a/src/__tests__/to-have-accessible-errormessage.js b/src/__tests__/to-have-accessible-errormessage.js new file mode 100644 index 00000000..1586020f --- /dev/null +++ b/src/__tests__/to-have-accessible-errormessage.js @@ -0,0 +1,277 @@ +import {render} from './helpers/test-utils' + +describe('.toHaveAccessibleErrorMessage', () => { + const input = 'input' + const errorId = 'error-id' + const error = 'This field is invalid' + const strings = {true: String(true), false: String(false)} + + describe('Positive Test Cases', () => { + it("Fails the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => { + const secondId = 'id2' + const secondError = 'LISTEN TO ME!!!' + + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" /> + + +
+ `) + + const field = queryByTestId('input') + expect(() => expect(field).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element's \`aria-errormessage\` attribute to be empty or a single, valid ID: + + Received: + aria-errormessage="error-id id2" + `) + + // Assume the remaining error messages are the EXACT same as above + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(error[0])), + ).toThrow() + + expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage(secondError), + ).toThrow() + + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(secondError[0])), + ).toThrow() + }) + + it('Fails the test if the target element is valid according to the WAI-ARIA spec', () => { + const noAriaInvalidAttribute = 'no-aria-invalid-attribute' + const validFieldState = 'false' + const invalidFieldStates = [ + 'true', + '', + 'grammar', + 'spelling', + 'asfdafbasdfasa', + ] + + function renderFieldWithState(state) { + return render(` +
+ <${input} data-testid="${input}" aria-invalid="${state}" aria-errormessage="${errorId}" /> + + + +
+ `) + } + + // Success Cases + invalidFieldStates.forEach(invalidState => { + const {queryByTestId} = renderFieldWithState(invalidState) + const field = queryByTestId('input') + + expect(field).toHaveAccessibleErrorMessage() + expect(field).toHaveAccessibleErrorMessage(error) + }) + + // Failure Case + const {queryByTestId} = renderFieldWithState(validFieldState) + const field = queryByTestId('input') + const fieldWithoutAttribute = queryByTestId(noAriaInvalidAttribute) + + expect(() => expect(fieldWithoutAttribute).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to be marked as invalid with attribute: + aria-invalid="true" + Received: + null + `) + + expect(() => expect(field).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to be marked as invalid with attribute: + aria-invalid="true" + Received: + aria-invalid="false + `) + + // Assume the remaining error messages are the EXACT same as above + expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(error, 'i')), + ).toThrow() + }) + + it('Passes the test if the target element has ANY recognized, non-empty error message', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).toHaveAccessibleErrorMessage() + }) + + it('Fails the test if NO recognized, non-empty error message was found for the target element', () => { + const empty = 'empty' + const emptyErrorId = 'empty-error' + const missing = 'missing' + + const {queryByTestId} = render(` +
+ + + + +
+ `) + + const fieldWithEmptyError = queryByTestId(empty) + const fieldMissingError = queryByTestId(missing) + + expect(() => expect(fieldWithEmptyError).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + + `) + + expect(() => expect(fieldMissingError).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + + `) + }) + + it('Passes the test if the target element has the error message that was SPECIFIED', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + const halfOfError = error.slice(0, Math.floor(error.length * 0.5)) + + expect(field).toHaveAccessibleErrorMessage(error) + expect(field).toHaveAccessibleErrorMessage(new RegExp(halfOfError), 'i') + expect(field).toHaveAccessibleErrorMessage( + expect.stringContaining(halfOfError), + ) + expect(field).toHaveAccessibleErrorMessage( + expect.stringMatching(new RegExp(halfOfError), 'i'), + ) + }) + + it('Fails the test if the target element DOES NOT have the error message that was SPECIFIED', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + const msg = 'asdflkje2984fguyvb bnafdsasfa;lj' + + expect(() => expect(field).toHaveAccessibleErrorMessage('')) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + This field is invalid + `) + + // Assume this error is SIMILAR to the error above + expect(() => expect(field).toHaveAccessibleErrorMessage(msg)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage( + error.slice(0, Math.floor(error.length * 0.5)), + ), + ).toThrow() + + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(msg), 'i'), + ).toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + /asdflkje2984fguyvb bnafdsasfa;lj/ + Received: + This field is invalid + `) + }) + + it('Normalizes the whitespace of the received error message', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).toHaveAccessibleErrorMessage('Step 1 of 9000') + }) + }) + + // These tests for the `.not` use cases will help us cover our bases and complete test coverage + describe('Negated Test Cases', () => { + it("Passes the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => { + const secondId = 'id2' + const secondError = 'LISTEN TO ME!!!' + + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" /> + + +
+ `) + + const field = queryByTestId('input') + expect(field).not.toHaveAccessibleErrorMessage() + expect(field).not.toHaveAccessibleErrorMessage(error) + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0])) + expect(field).not.toHaveAccessibleErrorMessage(secondError) + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(secondError[0])) + }) + + it('Passes the test if the target element is valid according to the WAI-ARIA spec', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).not.toHaveAccessibleErrorMessage() + expect(field).not.toHaveAccessibleErrorMessage(error) + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0])) + }) + }) +}) diff --git a/src/__tests__/to-have-class.js b/src/__tests__/to-have-class.js index 85bf8538..abfcf5d9 100644 --- a/src/__tests__/to-have-class.js +++ b/src/__tests__/to-have-class.js @@ -93,6 +93,32 @@ test('.toHaveClass', () => { ).toThrowError(/(none)/) }) +test('.toHaveClass with regular expressions', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(queryByTestId('delete-button')).toHaveClass(/btn/) + expect(queryByTestId('delete-button')).toHaveClass(/danger/) + expect(queryByTestId('delete-button')).toHaveClass( + /-danger$/, + 'extra', + /^btn-[a-z]+$/, + /\bbtn/, + ) + + // It does not match with "btn extra", even though it is a substring of the + // class "btn extra btn-danger". This is because the regular expression is + // matched against each class individually. + expect(queryByTestId('delete-button')).not.toHaveClass(/btn extra/) + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass(/danger/), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(/dangerous/), + ).toThrowError() +}) + test('.toHaveClass with exact mode option', () => { const {queryByTestId} = renderElementWithClasses() @@ -102,9 +128,10 @@ test('.toHaveClass with exact mode option', () => { expect(queryByTestId('delete-button')).not.toHaveClass('btn extra', { exact: true, }) - expect( - queryByTestId('delete-button'), - ).not.toHaveClass('btn extra btn-danger foo', {exact: true}) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: true}, + ) expect(queryByTestId('delete-button')).toHaveClass('btn extra btn-danger', { exact: false, @@ -112,9 +139,10 @@ test('.toHaveClass with exact mode option', () => { expect(queryByTestId('delete-button')).toHaveClass('btn extra', { exact: false, }) - expect( - queryByTestId('delete-button'), - ).not.toHaveClass('btn extra btn-danger foo', {exact: false}) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: false}, + ) expect(queryByTestId('delete-button')).toHaveClass( 'btn', @@ -178,3 +206,26 @@ test('.toHaveClass with exact mode option', () => { }), ).toThrowError(/Expected the element to have EXACTLY defined classes/) }) + +test('.toHaveClass combining {exact:true} and regular expressions throws an error', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass(/btn/, { + exact: true, + }), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass( + /-danger$/, + 'extra', + /\bbtn/, + {exact: true}, + ), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(/danger/, {exact: true}), + ).toThrowError() +}) diff --git a/src/__tests__/to-have-role.js b/src/__tests__/to-have-role.js new file mode 100644 index 00000000..c9e039ca --- /dev/null +++ b/src/__tests__/to-have-role.js @@ -0,0 +1,107 @@ +import {render} from './helpers/test-utils' + +describe('.toHaveRole', () => { + it('matches implicit role', () => { + const {queryByTestId} = render(` +
+ +
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + }) + + it('matches explicit role', () => { + const {queryByTestId} = render(` +
+
Continue
+
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + }) + + it('matches multiple explicit roles', () => { + const {queryByTestId} = render(` +
+
Continue
+
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + expect(continueButton).toHaveRole('switch') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('switch') + }).toThrow(/expected element not to have role/i) + }) + + // At this point, we might be testing the details of getImplicitAriaRoles, but + // it's good to have a gut check + it('handles implicit roles with multiple conditions', () => { + const {queryByTestId} = render(` + + `) + + const validLink = queryByTestId('link-valid') + const invalidLink = queryByTestId('link-invalid') + + // valid link has role 'link' + expect(validLink).not.toHaveRole('listitem') + expect(validLink).toHaveRole('link') + + expect(() => { + expect(validLink).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(validLink).not.toHaveRole('link') + }).toThrow(/expected element not to have role/i) + + // invalid link has role 'generic' + expect(invalidLink).not.toHaveRole('listitem') + expect(invalidLink).not.toHaveRole('link') + expect(invalidLink).toHaveRole('generic') + + expect(() => { + expect(invalidLink).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(invalidLink).toHaveRole('link') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(invalidLink).not.toHaveRole('generic') + }).toThrow(/expected element not to have role/i) + }) +}) diff --git a/src/__tests__/to-have-selection.js b/src/__tests__/to-have-selection.js new file mode 100644 index 00000000..9ddcc2c8 --- /dev/null +++ b/src/__tests__/to-have-selection.js @@ -0,0 +1,189 @@ +import {render} from './helpers/test-utils' + +describe('.toHaveSelection', () => { + test.each(['text', 'password', 'textarea'])( + 'handles selection within form elements', + testId => { + const {queryByTestId} = render(` + + + + `) + + queryByTestId(testId).setSelectionRange(5, 13) + expect(queryByTestId(testId)).toHaveSelection('selected') + + queryByTestId(testId).select() + expect(queryByTestId(testId)).toHaveSelection('text selected text') + }, + ) + + test.each(['checkbox', 'radio'])( + 'returns empty string for form elements without text', + testId => { + const {queryByTestId} = render(` + + + `) + + queryByTestId(testId).select() + expect(queryByTestId(testId)).toHaveSelection('') + }, + ) + + test('does not match subset string', () => { + const {queryByTestId} = render(` + + `) + + queryByTestId('text').setSelectionRange(5, 13) + expect(queryByTestId('text')).not.toHaveSelection('select') + expect(queryByTestId('text')).toHaveSelection('selected') + }) + + test('accepts any selection when expected selection is missing', () => { + const {queryByTestId} = render(` + + `) + + expect(queryByTestId('text')).not.toHaveSelection() + + queryByTestId('text').setSelectionRange(5, 13) + + expect(queryByTestId('text')).toHaveSelection() + }) + + test('throws when form element is not selected', () => { + const {queryByTestId} = render(` + + `) + + expect(() => + expect(queryByTestId('text')).toHaveSelection(), + ).toThrowErrorMatchingInlineSnapshot( + ` + expect(element).toHaveSelection(expected) + + Expected the element to have selection: + (any) + Received: + + `, + ) + }) + + test('throws when form element is selected', () => { + const {queryByTestId} = render(` + + `) + queryByTestId('text').setSelectionRange(5, 13) + + expect(() => + expect(queryByTestId('text')).not.toHaveSelection(), + ).toThrowErrorMatchingInlineSnapshot( + ` + expect(element).not.toHaveSelection(expected) + + Expected the element not to have selection: + (any) + Received: + selected + `, + ) + }) + + test('throws when element is not selected', () => { + const {queryByTestId} = render(` +
text
+ `) + + expect(() => + expect(queryByTestId('text')).toHaveSelection(), + ).toThrowErrorMatchingInlineSnapshot( + ` + expect(element).toHaveSelection(expected) + + Expected the element to have selection: + (any) + Received: + + `, + ) + }) + + test('throws when element selection does not match', () => { + const {queryByTestId} = render(` + + `) + queryByTestId('text').setSelectionRange(0, 4) + + expect(() => + expect(queryByTestId('text')).toHaveSelection('no match'), + ).toThrowErrorMatchingInlineSnapshot( + ` + expect(element).toHaveSelection(no match) + + Expected the element to have selection: + no match + Received: + text + `, + ) + }) + + test('handles selection within text nodes', () => { + const {queryByTestId} = render(` +
+
prev
+
text selected text
+
next
+
+ `) + + const selection = queryByTestId('child').ownerDocument.getSelection() + const range = queryByTestId('child').ownerDocument.createRange() + selection.removeAllRanges() + selection.empty() + selection.addRange(range) + + range.selectNodeContents(queryByTestId('child')) + + expect(queryByTestId('child')).toHaveSelection('selected') + expect(queryByTestId('parent')).toHaveSelection('selected') + + range.selectNodeContents(queryByTestId('parent')) + + expect(queryByTestId('child')).toHaveSelection('selected') + expect(queryByTestId('parent')).toHaveSelection('text selected text') + + range.setStart(queryByTestId('prev'), 0) + range.setEnd(queryByTestId('child').childNodes[0], 3) + + expect(queryByTestId('prev')).toHaveSelection('prev') + expect(queryByTestId('child')).toHaveSelection('sel') + expect(queryByTestId('parent')).toHaveSelection('text sel') + expect(queryByTestId('next')).not.toHaveSelection() + + range.setStart(queryByTestId('child').childNodes[0], 3) + range.setEnd(queryByTestId('next').childNodes[0], 2) + + expect(queryByTestId('child')).toHaveSelection('ected') + expect(queryByTestId('parent')).toHaveSelection('ected text') + expect(queryByTestId('prev')).not.toHaveSelection() + expect(queryByTestId('next')).toHaveSelection('ne') + }) + + test('throws with information when the expected selection is not string', () => { + const {container} = render(`
1
`) + const element = container.firstChild + const range = element.ownerDocument.createRange() + range.selectNodeContents(element) + element.ownerDocument.getSelection().addRange(range) + + expect(() => + expect(element).toHaveSelection(1), + ).toThrowErrorMatchingInlineSnapshot( + `expected selection must be a string or undefined`, + ) + }) +}) diff --git a/src/__tests__/to-have-style.js b/src/__tests__/to-have-style.js index 0d94efaa..5991a7e9 100644 --- a/src/__tests__/to-have-style.js +++ b/src/__tests__/to-have-style.js @@ -70,6 +70,7 @@ describe('.toHaveStyle', () => { background-color: black; color: white; float: left; + --var-name: 0px; transition: opacity 0.2s ease-out, top 0.3s cubic-bezier(1.175, 0.885, 0.32, 1.275); } ` @@ -92,6 +93,11 @@ describe('.toHaveStyle', () => { ), ).toThrowError() + // Custom property names are case sensitive + expect(() => + expect(container.querySelector('.label')).toHaveStyle('--VAR-NAME: 0px;'), + ).toThrowError() + // Make sure the test fails if the css syntax is not valid expect(() => expect(container.querySelector('.label')).not.toHaveStyle( @@ -119,11 +125,11 @@ describe('.toHaveStyle', () => { ) }) - test('handles inline custom properties', () => { + test('handles inline custom properties (with uppercase letters)', () => { const {queryByTestId} = render(` - Hello World + Hello World `) - expect(queryByTestId('color-example')).toHaveStyle('--color: blue') + expect(queryByTestId('color-example')).toHaveStyle('--accentColor: blue') }) test('handles global custom properties', () => { @@ -205,7 +211,7 @@ describe('.toHaveStyle', () => { Hello World `) expect(queryByTestId('color-example')).toHaveStyle({ - fontSize: 12 + fontSize: 12, }) }) @@ -214,7 +220,7 @@ describe('.toHaveStyle', () => { Hello World `) expect(() => { - expect(queryByTestId('color-example')).toHaveStyle({ fontSize: '12px' }) + expect(queryByTestId('color-example')).toHaveStyle({fontSize: '12px'}) }).toThrowError() }) diff --git a/src/__tests__/to-have-value.js b/src/__tests__/to-have-value.js index 80cc7828..1b276fb5 100644 --- a/src/__tests__/to-have-value.js +++ b/src/__tests__/to-have-value.js @@ -203,4 +203,18 @@ Received: foo `) }) + + test('handles value of aria-valuenow', () => { + const valueToCheck = 70 + const {queryByTestId} = render(` +
+
+ `) + + expect(queryByTestId('meter')).toHaveValue(valueToCheck) + expect(queryByTestId('meter')).not.toHaveValue(valueToCheck + 1) + + // Role that does not support aria-valuenow + expect(queryByTestId('textbox')).not.toHaveValue(70) + }) }) diff --git a/src/index.js b/src/index.js index 8cecbe35..3801a1d5 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,3 @@ -import './extend-expect' +import * as extensions from './matchers' + +expect.extend(extensions) diff --git a/src/jest-globals.js b/src/jest-globals.js new file mode 100644 index 00000000..ac36d474 --- /dev/null +++ b/src/jest-globals.js @@ -0,0 +1,6 @@ +/* istanbul ignore file */ + +import {expect} from '@jest/globals' +import * as extensions from './matchers' + +expect.extend(extensions) diff --git a/src/matchers.js b/src/matchers.js index c90945d5..ed534e28 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -1,53 +1,27 @@ -import {toBeInTheDOM} from './to-be-in-the-dom' -import {toBeInTheDocument} from './to-be-in-the-document' -import {toBeEmpty} from './to-be-empty' -import {toBeEmptyDOMElement} from './to-be-empty-dom-element' -import {toContainElement} from './to-contain-element' -import {toContainHTML} from './to-contain-html' -import {toHaveTextContent} from './to-have-text-content' -import {toHaveAccessibleDescription} from './to-have-accessible-description' -import {toHaveAccessibleName} from './to-have-accessible-name' -import {toHaveAttribute} from './to-have-attribute' -import {toHaveClass} from './to-have-class' -import {toHaveStyle} from './to-have-style' -import {toHaveFocus} from './to-have-focus' -import {toHaveFormValues} from './to-have-form-values' -import {toBeVisible} from './to-be-visible' -import {toBeDisabled, toBeEnabled} from './to-be-disabled' -import {toBeRequired} from './to-be-required' -import {toBeInvalid, toBeValid} from './to-be-invalid' -import {toHaveValue} from './to-have-value' -import {toHaveDisplayValue} from './to-have-display-value' -import {toBeChecked} from './to-be-checked' -import {toBePartiallyChecked} from './to-be-partially-checked' -import {toHaveDescription} from './to-have-description' -import {toHaveErrorMessage} from './to-have-errormessage' - -export { - toBeInTheDOM, - toBeInTheDocument, - toBeEmpty, - toBeEmptyDOMElement, - toContainElement, - toContainHTML, - toHaveTextContent, - toHaveAccessibleDescription, - toHaveAccessibleName, - toHaveAttribute, - toHaveClass, - toHaveStyle, - toHaveFocus, - toHaveFormValues, - toBeVisible, - toBeDisabled, - toBeEnabled, - toBeRequired, - toBeInvalid, - toBeValid, - toHaveValue, - toHaveDisplayValue, - toBeChecked, - toBePartiallyChecked, - toHaveDescription, - toHaveErrorMessage, -} +export {toBeInTheDOM} from './to-be-in-the-dom' +export {toBeInTheDocument} from './to-be-in-the-document' +export {toBeEmpty} from './to-be-empty' +export {toBeEmptyDOMElement} from './to-be-empty-dom-element' +export {toContainElement} from './to-contain-element' +export {toContainHTML} from './to-contain-html' +export {toHaveTextContent} from './to-have-text-content' +export {toHaveAccessibleDescription} from './to-have-accessible-description' +export {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage' +export {toHaveRole} from './to-have-role' +export {toHaveAccessibleName} from './to-have-accessible-name' +export {toHaveAttribute} from './to-have-attribute' +export {toHaveClass} from './to-have-class' +export {toHaveStyle} from './to-have-style' +export {toHaveFocus} from './to-have-focus' +export {toHaveFormValues} from './to-have-form-values' +export {toBeVisible} from './to-be-visible' +export {toBeDisabled, toBeEnabled} from './to-be-disabled' +export {toBeRequired} from './to-be-required' +export {toBeInvalid, toBeValid} from './to-be-invalid' +export {toHaveValue} from './to-have-value' +export {toHaveDisplayValue} from './to-have-display-value' +export {toBeChecked} from './to-be-checked' +export {toBePartiallyChecked} from './to-be-partially-checked' +export {toHaveDescription} from './to-have-description' +export {toHaveErrorMessage} from './to-have-errormessage' +export {toHaveSelection} from './to-have-selection' diff --git a/src/to-be-in-the-document.js b/src/to-be-in-the-document.js index 8ccc451a..a7eda78e 100644 --- a/src/to-be-in-the-document.js +++ b/src/to-be-in-the-document.js @@ -29,7 +29,7 @@ export function toBeInTheDocument(element) { '', ), '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap this.utils.RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()), ].join('\n') }, diff --git a/src/to-be-required.js b/src/to-be-required.js index d674a62c..39188e63 100644 --- a/src/to-be-required.js +++ b/src/to-be-required.js @@ -15,10 +15,13 @@ const UNSUPPORTED_INPUT_TYPES = [ ] const SUPPORTED_ARIA_ROLES = [ + 'checkbox', 'combobox', 'gridcell', + 'listbox', 'radiogroup', 'spinbutton', + 'textbox', 'tree', ] diff --git a/src/to-contain-element.js b/src/to-contain-element.js index c94ddbf9..445a6120 100644 --- a/src/to-contain-element.js +++ b/src/to-contain-element.js @@ -17,7 +17,7 @@ export function toContainElement(container, element) { 'element', ), '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap this.utils.RECEIVED_COLOR(`${this.utils.stringify( container.cloneNode(false), )} ${ diff --git a/src/to-contain-html.js b/src/to-contain-html.js index ccbff5f5..30158ee1 100644 --- a/src/to-contain-html.js +++ b/src/to-contain-html.js @@ -23,7 +23,7 @@ export function toContainHTML(container, htmlText) { '', ), 'Expected:', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap ` ${this.utils.EXPECTED_COLOR(htmlText)}`, 'Received:', ` ${this.utils.printReceived(container.cloneNode(true))}`, diff --git a/src/to-have-accessible-errormessage.js b/src/to-have-accessible-errormessage.js new file mode 100644 index 00000000..d34d310f --- /dev/null +++ b/src/to-have-accessible-errormessage.js @@ -0,0 +1,85 @@ +import {checkHtmlElement, getMessage, normalize} from './utils' + +const ariaInvalidName = 'aria-invalid' +const validStates = ['false'] + +// See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage +export function toHaveAccessibleErrorMessage( + htmlElement, + expectedAccessibleErrorMessage, +) { + checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this) + const to = this.isNot ? 'not to' : 'to' + const method = this.isNot + ? '.not.toHaveAccessibleErrorMessage' + : '.toHaveAccessibleErrorMessage' + + // Enforce Valid Id + const errormessageId = htmlElement.getAttribute('aria-errormessage') + const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId) + + if (errormessageIdInvalid) { + return { + pass: false, + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + "Expected element's `aria-errormessage` attribute to be empty or a single, valid ID", + '', + 'Received', + `aria-errormessage="${errormessageId}"`, + ) + }, + } + } + + // See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid + const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName) + const fieldValid = + !htmlElement.hasAttribute(ariaInvalidName) || + validStates.includes(ariaInvalidVal) + + // Enforce Valid `aria-invalid` Attribute + if (fieldValid) { + return { + pass: false, + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + 'Expected element to be marked as invalid with attribute', + `${ariaInvalidName}="${String(true)}"`, + 'Received', + htmlElement.hasAttribute('aria-invalid') + ? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}` + : null, + ) + }, + } + } + + const error = normalize( + htmlElement.ownerDocument.getElementById(errormessageId)?.textContent ?? '', + ) + + return { + pass: + expectedAccessibleErrorMessage === undefined + ? Boolean(error) + : expectedAccessibleErrorMessage instanceof RegExp + ? expectedAccessibleErrorMessage.test(error) + : this.equals(error, expectedAccessibleErrorMessage), + + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + `Expected element ${to} have accessible error message`, + expectedAccessibleErrorMessage ?? '', + 'Received', + error, + ) + }, + } +} diff --git a/src/to-have-class.js b/src/to-have-class.js index be59be3a..f59f4dd9 100644 --- a/src/to-have-class.js +++ b/src/to-have-class.js @@ -4,7 +4,7 @@ function getExpectedClassNamesAndOptions(params) { const lastParam = params.pop() let expectedClassNames, options - if (typeof lastParam === 'object') { + if (typeof lastParam === 'object' && !(lastParam instanceof RegExp)) { expectedClassNames = params options = lastParam } else { @@ -15,14 +15,16 @@ function getExpectedClassNamesAndOptions(params) { } function splitClassNames(str) { - if (!str) { - return [] - } + if (!str) return [] return str.split(/\s+/).filter(s => s.length > 0) } function isSubset(subset, superset) { - return subset.every(item => superset.includes(item)) + return subset.every(strOrRegexp => + typeof strOrRegexp === 'string' + ? superset.includes(strOrRegexp) + : superset.some(className => strOrRegexp.test(className)), + ) } export function toHaveClass(htmlElement, ...params) { @@ -31,10 +33,20 @@ export function toHaveClass(htmlElement, ...params) { const received = splitClassNames(htmlElement.getAttribute('class')) const expected = expectedClassNames.reduce( - (acc, className) => acc.concat(splitClassNames(className)), + (acc, className) => + acc.concat( + typeof className === 'string' || !className + ? splitClassNames(className) + : className, + ), [], ) + const hasRegExp = expected.some(className => className instanceof RegExp) + if (options.exact && hasRegExp) { + throw new Error('Exact option does not support RegExp expected class names') + } + if (options.exact) { return { pass: isSubset(expected, received) && expected.length === received.length, diff --git a/src/to-have-errormessage.js b/src/to-have-errormessage.js index a253b390..5b12e4e2 100644 --- a/src/to-have-errormessage.js +++ b/src/to-have-errormessage.js @@ -1,7 +1,8 @@ -import {checkHtmlElement, getMessage, normalize} from './utils' +import {checkHtmlElement, getMessage, normalize, deprecate} from './utils' // See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage export function toHaveErrorMessage(htmlElement, checkWith) { + deprecate('toHaveErrorMessage', 'Please use toHaveAccessibleErrorMessage.') checkHtmlElement(htmlElement, toHaveErrorMessage, this) if ( diff --git a/src/to-have-form-values.js b/src/to-have-form-values.js index c3fddcc0..cd479d68 100644 --- a/src/to-have-form-values.js +++ b/src/to-have-form-values.js @@ -1,16 +1,15 @@ -import isEqualWith from 'lodash/isEqualWith' -import uniq from 'lodash/uniq' +import isEqualWith from 'lodash/isEqualWith.js' import escape from 'css.escape' import { checkHtmlElement, - compareArraysAsSet, getSingleElementValue, + compareArraysAsSet, } from './utils' // Returns the combined value of several elements that have the same name // e.g. radio buttons or groups of checkboxes function getMultiElementValue(elements) { - const types = uniq(elements.map(element => element.type)) + const types = [...new Set(elements.map(element => element.type))] if (types.length !== 1) { throw new Error( 'Multiple form elements with the same name must be of the same type', diff --git a/src/to-have-role.js b/src/to-have-role.js new file mode 100644 index 00000000..04b85ed8 --- /dev/null +++ b/src/to-have-role.js @@ -0,0 +1,147 @@ +import {elementRoles} from 'aria-query' +import {checkHtmlElement, getMessage} from './utils' + +const elementRoleList = buildElementRoleList(elementRoles) + +export function toHaveRole(htmlElement, expectedRole) { + checkHtmlElement(htmlElement, toHaveRole, this) + + const actualRoles = getExplicitOrImplicitRoles(htmlElement) + const pass = actualRoles.some(el => el === expectedRole) + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.${toHaveRole.name}`, + 'element', + '', + ), + `Expected element ${to} have role`, + expectedRole, + 'Received', + actualRoles.join(', '), + ) + }, + } +} + +function getExplicitOrImplicitRoles(htmlElement) { + const hasExplicitRole = htmlElement.hasAttribute('role') + + if (hasExplicitRole) { + const roleValue = htmlElement.getAttribute('role') + + // Handle fallback roles, such as role="switch button" + // testing-library gates this behind the `queryFallbacks` flag; it is + // unclear why, but it makes sense to support this pattern out of the box + // https://testing-library.com/docs/queries/byrole/#queryfallbacks + return roleValue.split(' ').filter(Boolean) + } + + const implicitRoles = getImplicitAriaRoles(htmlElement) + + return implicitRoles +} + +function getImplicitAriaRoles(currentNode) { + for (const {match, roles} of elementRoleList) { + if (match(currentNode)) { + return [...roles] + } + } + + /* istanbul ignore next */ + return [] // this does not get reached in practice, since elements have at least a 'generic' role +} + +/** + * Transform the roles map (with required attributes and constraints) to a list + * of roles. Each item in the list has functions to match an element against it. + * + * Essentially copied over from [dom-testing-library's + * helpers](https://github.com/testing-library/dom-testing-library/blob/bd04cf95a1ed85a2238f7dfc1a77d5d16b4f59dc/src/role-helpers.js#L80) + * + * TODO: If we are truly just copying over stuff, would it make sense to move + * this to a separate package? + * + * TODO: This technique relies on CSS selectors; are those consistently + * available in all jest-dom environments? Why do other matchers in this package + * not use them like this? + */ +function buildElementRoleList(elementRolesMap) { + function makeElementSelector({name, attributes}) { + return `${name}${attributes + .map(({name: attributeName, value, constraints = []}) => { + const shouldNotExist = constraints.indexOf('undefined') !== -1 + if (shouldNotExist) { + return `:not([${attributeName}])` + } else if (value) { + return `[${attributeName}="${value}"]` + } else { + return `[${attributeName}]` + } + }) + .join('')}` + } + + function getSelectorSpecificity({attributes = []}) { + return attributes.length + } + + function bySelectorSpecificity( + {specificity: leftSpecificity}, + {specificity: rightSpecificity}, + ) { + return rightSpecificity - leftSpecificity + } + + function match(element) { + let {attributes = []} = element + + // https://github.com/testing-library/dom-testing-library/issues/814 + const typeTextIndex = attributes.findIndex( + attribute => + attribute.value && + attribute.name === 'type' && + attribute.value === 'text', + ) + + if (typeTextIndex >= 0) { + // not using splice to not mutate the attributes array + attributes = [ + ...attributes.slice(0, typeTextIndex), + ...attributes.slice(typeTextIndex + 1), + ] + } + + const selector = makeElementSelector({...element, attributes}) + + return node => { + if (typeTextIndex >= 0 && node.type !== 'text') { + return false + } + + return node.matches(selector) + } + } + + let result = [] + + for (const [element, roles] of elementRolesMap.entries()) { + result = [ + ...result, + { + match: match(element), + roles: Array.from(roles), + specificity: getSelectorSpecificity(element), + }, + ] + } + + return result.sort(bySelectorSpecificity) +} diff --git a/src/to-have-selection.js b/src/to-have-selection.js new file mode 100644 index 00000000..4eb74783 --- /dev/null +++ b/src/to-have-selection.js @@ -0,0 +1,114 @@ +import isEqualWith from 'lodash/isEqualWith.js' +import {checkHtmlElement, compareArraysAsSet, getMessage} from './utils' + +/** + * Returns the selection from the element. + * + * @param element {HTMLElement} The element to get the selection from. + * @returns {String} The selection. + */ +function getSelection(element) { + const selection = element.ownerDocument.getSelection() + + if (['input', 'textarea'].includes(element.tagName.toLowerCase())) { + if (['radio', 'checkbox'].includes(element.type)) return '' + return element.value + .toString() + .substring(element.selectionStart, element.selectionEnd) + } + + if (selection.anchorNode === null || selection.focusNode === null) { + // No selection + return '' + } + + const originalRange = selection.getRangeAt(0) + const temporaryRange = element.ownerDocument.createRange() + + if (selection.containsNode(element, false)) { + // Whole element is inside selection + temporaryRange.selectNodeContents(element) + selection.removeAllRanges() + selection.addRange(temporaryRange) + } else if ( + element.contains(selection.anchorNode) && + element.contains(selection.focusNode) + ) { + // Element contains selection, nothing to do + } else { + // Element is partially selected + const selectionStartsWithinElement = + element === originalRange.startContainer || + element.contains(originalRange.startContainer) + const selectionEndsWithinElement = + element === originalRange.endContainer || + element.contains(originalRange.endContainer) + selection.removeAllRanges() + + if (selectionStartsWithinElement || selectionEndsWithinElement) { + temporaryRange.selectNodeContents(element) + + if (selectionStartsWithinElement) { + temporaryRange.setStart( + originalRange.startContainer, + originalRange.startOffset, + ) + } + if (selectionEndsWithinElement) { + temporaryRange.setEnd( + originalRange.endContainer, + originalRange.endOffset, + ) + } + + selection.addRange(temporaryRange) + } + } + + const result = selection.toString() + + selection.removeAllRanges() + selection.addRange(originalRange) + + return result +} + +/** + * Checks if the element has the string selected. + * + * @param htmlElement {HTMLElement} The html element to check the selection for. + * @param expectedSelection {String} The selection as a string. + */ +export function toHaveSelection(htmlElement, expectedSelection) { + checkHtmlElement(htmlElement, toHaveSelection, this) + + const expectsSelection = expectedSelection !== undefined + + if (expectsSelection && typeof expectedSelection !== 'string') { + throw new Error(`expected selection must be a string or undefined`) + } + + const receivedSelection = getSelection(htmlElement) + + return { + pass: expectsSelection + ? isEqualWith(receivedSelection, expectedSelection, compareArraysAsSet) + : Boolean(receivedSelection), + message: () => { + const to = this.isNot ? 'not to' : 'to' + const matcher = this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveSelection`, + 'element', + expectedSelection, + ) + return getMessage( + this, + matcher, + `Expected the element ${to} have selection`, + expectsSelection ? expectedSelection : '(any)', + 'Received', + receivedSelection, + ) + }, + } +} diff --git a/src/to-have-style.js b/src/to-have-style.js index f125450c..010e2a5d 100644 --- a/src/to-have-style.js +++ b/src/to-have-style.js @@ -17,11 +17,17 @@ function getStyleDeclaration(document, css) { function isSubset(styles, computedStyle) { return ( !!Object.keys(styles).length && - Object.entries(styles).every( - ([prop, value]) => - computedStyle[prop] === value || - computedStyle.getPropertyValue(prop.toLowerCase()) === value, - ) + Object.entries(styles).every(([prop, value]) => { + const isCustomProperty = prop.startsWith('--') + const spellingVariants = [prop] + if (!isCustomProperty) spellingVariants.push(prop.toLowerCase()) + + return spellingVariants.some( + name => + computedStyle[name] === value || + computedStyle.getPropertyValue(name) === value, + ) + }) ) } diff --git a/src/to-have-value.js b/src/to-have-value.js index 0b24e165..37cca52c 100644 --- a/src/to-have-value.js +++ b/src/to-have-value.js @@ -1,9 +1,9 @@ -import isEqualWith from 'lodash/isEqualWith' +import isEqualWith from 'lodash/isEqualWith.js' import { checkHtmlElement, - compareArraysAsSet, getMessage, getSingleElementValue, + compareArraysAsSet, } from './utils' export function toHaveValue(htmlElement, expectedValue) { diff --git a/src/utils.js b/src/utils.js index 2f07fb8e..2699d954 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,6 +1,5 @@ import redent from 'redent' -import cssParse from 'css/lib/parse' -import isEqual from 'lodash/isEqual' +import {parse} from '@adobe/css-tools' class GenericTypeError extends Error { constructor(expectedString, received, matcherFn, context) { @@ -28,7 +27,7 @@ class GenericTypeError extends Error { '', ), '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap `${context.utils.RECEIVED_COLOR( 'received', )} value must ${expectedString}.`, @@ -91,16 +90,16 @@ class InvalidCSSError extends Error { this.message = [ received.message, '', - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap context.utils.RECEIVED_COLOR(`Failing css:`), - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap context.utils.RECEIVED_COLOR(`${received.css}`), ].join('\n') } } function parseCSS(css, ...args) { - const ast = cssParse(`selector { ${css} }`, {silent: true}).stylesheet + const ast = parse(`selector { ${css} }`, {silent: true}).stylesheet if (ast.parsingErrors && ast.parsingErrors.length > 0) { const {reason, line} = ast.parsingErrors[0] @@ -137,11 +136,11 @@ function getMessage( ) { return [ `${matcher}\n`, - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap `${expectedLabel}:\n${context.utils.EXPECTED_COLOR( redent(display(context, expectedValue), 2), )}`, - // eslint-disable-next-line @babel/new-cap + // eslint-disable-next-line new-cap `${receivedLabel}:\n${context.utils.RECEIVED_COLOR( redent(display(context, receivedValue), 2), )}`, @@ -197,26 +196,29 @@ function getInputValue(inputElement) { } } +const rolesSupportingValues = ['meter', 'progressbar', 'slider', 'spinbutton'] +function getAccessibleValue(element) { + if (!rolesSupportingValues.includes(element.getAttribute('role'))) { + return undefined + } + return Number(element.getAttribute('aria-valuenow')) +} + function getSingleElementValue(element) { /* istanbul ignore if */ if (!element) { return undefined } + switch (element.tagName.toLowerCase()) { case 'input': return getInputValue(element) case 'select': return getSelectValue(element) - default: - return element.value - } -} - -function compareArraysAsSet(a, b) { - if (Array.isArray(a) && Array.isArray(b)) { - return isEqual(new Set(a), new Set(b)) + default: { + return element.value ?? getAccessibleValue(element) + } } - return undefined } function toSentence( @@ -228,6 +230,13 @@ function toSentence( ) } +function compareArraysAsSet(arr1, arr2) { + if (Array.isArray(arr1) && Array.isArray(arr2)) { + return [...new Set(arr1)].every(v => new Set(arr2).has(v)) + } + return undefined +} + export { HtmlElementTypeError, NodeTypeError, @@ -240,6 +249,6 @@ export { normalize, getTag, getSingleElementValue, - compareArraysAsSet, toSentence, + compareArraysAsSet, } diff --git a/src/extend-expect.js b/src/vitest.js similarity index 53% rename from src/extend-expect.js rename to src/vitest.js index 3801a1d5..a6b56cef 100644 --- a/src/extend-expect.js +++ b/src/vitest.js @@ -1,3 +1,6 @@ +/* istanbul ignore file */ + +import {expect} from 'vitest' import * as extensions from './matchers' expect.extend(extensions) diff --git a/tests/setup-env.js b/tests/setup-env.js index a9325d25..151f6e7b 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1,4 +1,4 @@ import {plugins} from 'pretty-format' -import '../src/extend-expect' +import '../src/index' expect.addSnapshotSerializer(plugins.ConvertAnsi) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..dec023f5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["*.d.ts", "types"], + "exclude": ["types/__tests__"] +} diff --git a/types/__tests__/bun/bun-custom-expect-types.test.ts b/types/__tests__/bun/bun-custom-expect-types.test.ts new file mode 100644 index 00000000..1467e913 --- /dev/null +++ b/types/__tests__/bun/bun-custom-expect-types.test.ts @@ -0,0 +1,100 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import {expect} from 'bun:test' +import * as matchersStandalone from '../../matchers-standalone' +import * as originalMatchers from '../../matchers' + +expect.extend(matchersStandalone) + +const element: HTMLElement = document.body + +function customExpect( + _actual: HTMLElement, +): + | originalMatchers.TestingLibraryMatchers + | originalMatchers.TestingLibraryMatchers> { + throw new Error('Method not implemented.') +} + +customExpect(element).toBeInTheDOM() +customExpect(element).toBeInTheDOM(document.body) +customExpect(element).toBeInTheDocument() +customExpect(element).toBeVisible() +customExpect(element).toBeEmpty() +customExpect(element).toBeDisabled() +customExpect(element).toBeEnabled() +customExpect(element).toBeInvalid() +customExpect(element).toBeRequired() +customExpect(element).toBeValid() +customExpect(element).toContainElement(document.body) +customExpect(element).toContainElement(null) +customExpect(element).toContainHTML('body') +customExpect(element).toHaveAttribute('attr') +customExpect(element).toHaveAttribute('attr', true) +customExpect(element).toHaveAttribute('attr', 'yes') +customExpect(element).toHaveClass() +customExpect(element).toHaveClass('cls1') +customExpect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +customExpect(element).toHaveClass('cls1', {exact: true}) +customExpect(element).toHaveDisplayValue('str') +customExpect(element).toHaveDisplayValue(['str1', 'str2']) +customExpect(element).toHaveDisplayValue(/str/) +customExpect(element).toHaveDisplayValue([/str1/, 'str2']) +customExpect(element).toHaveFocus() +customExpect(element).toHaveFormValues({foo: 'bar', baz: 1}) +customExpect(element).toHaveStyle('display: block') +customExpect(element).toHaveStyle({display: 'block', width: 100}) +customExpect(element).toHaveTextContent('Text') +customExpect(element).toHaveTextContent(/Text/) +customExpect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +customExpect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +customExpect(element).toHaveValue() +customExpect(element).toHaveValue('str') +customExpect(element).toHaveValue(['str1', 'str2']) +customExpect(element).toHaveValue(1) +customExpect(element).toHaveValue(null) +customExpect(element).toBeChecked() +customExpect(element).toHaveDescription('some description') +customExpect(element).toHaveDescription(/some description/) +customExpect(element).toHaveDescription(expect.stringContaining('partial')) +customExpect(element).toHaveDescription() +customExpect(element).toHaveAccessibleDescription('some description') +customExpect(element).toHaveAccessibleDescription(/some description/) +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +) +customExpect(element).toHaveAccessibleDescription() + +customExpect(element).toHaveAccessibleErrorMessage() +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i) +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +) + +customExpect(element).toHaveAccessibleName('a label') +customExpect(element).toHaveAccessibleName(/a label/) +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +) +customExpect(element).toHaveAccessibleName() +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveErrorMessage(/invalid time/i) +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +) + +customExpect(element).toHaveRole('button') + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty() diff --git a/types/__tests__/bun/bun-types.test.ts b/types/__tests__/bun/bun-types.test.ts new file mode 100644 index 00000000..432dd867 --- /dev/null +++ b/types/__tests__/bun/bun-types.test.ts @@ -0,0 +1,120 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import {expect} from 'bun:test' +import '../../bun' + +const element: HTMLElement = document.body + +expect(element).toBeInTheDOM() +expect(element).toBeInTheDOM(document.body) +expect(element).toBeInTheDocument() +expect(element).toBeVisible() +expect(element).toBeEmpty() +expect(element).toBeDisabled() +expect(element).toBeEnabled() +expect(element).toBeInvalid() +expect(element).toBeRequired() +expect(element).toBeValid() +expect(element).toContainElement(document.body) +expect(element).toContainElement(null) +expect(element).toContainHTML('body') +expect(element).toHaveAttribute('attr') +expect(element).toHaveAttribute('attr', true) +expect(element).toHaveAttribute('attr', 'yes') +expect(element).toHaveClass() +expect(element).toHaveClass('cls1') +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).toHaveClass('cls1', {exact: true}) +expect(element).toHaveDisplayValue('str') +expect(element).toHaveDisplayValue(['str1', 'str2']) +expect(element).toHaveDisplayValue(/str/) +expect(element).toHaveDisplayValue([/str1/, 'str2']) +expect(element).toHaveFocus() +expect(element).toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).toHaveStyle('display: block') +expect(element).toHaveStyle({display: 'block', width: 100}) +expect(element).toHaveTextContent('Text') +expect(element).toHaveTextContent(/Text/) +expect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).toHaveValue() +expect(element).toHaveValue('str') +expect(element).toHaveValue(['str1', 'str2']) +expect(element).toHaveValue(1) +expect(element).toHaveValue(null) +expect(element).toBeChecked() +expect(element).toHaveDescription('some description') +expect(element).toHaveDescription(/some description/) +expect(element).toHaveDescription(expect.stringContaining('partial')) +expect(element).toHaveDescription() +expect(element).toHaveAccessibleDescription('some description') +expect(element).toHaveAccessibleDescription(/some description/) +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')) +expect(element).toHaveAccessibleDescription() +expect(element).toHaveAccessibleName('a label') +expect(element).toHaveAccessibleName(/a label/) +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')) +expect(element).toHaveAccessibleName() +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +expect(element).toHaveErrorMessage(/invalid time/i) +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') + +expect(element).not.toBeInTheDOM() +expect(element).not.toBeInTheDOM(document.body) +expect(element).not.toBeInTheDocument() +expect(element).not.toBeVisible() +expect(element).not.toBeEmpty() +expect(element).not.toBeEmptyDOMElement() +expect(element).not.toBeDisabled() +expect(element).not.toBeEnabled() +expect(element).not.toBeInvalid() +expect(element).not.toBeRequired() +expect(element).not.toBeValid() +expect(element).not.toContainElement(document.body) +expect(element).not.toContainElement(null) +expect(element).not.toContainHTML('body') +expect(element).not.toHaveAttribute('attr') +expect(element).not.toHaveAttribute('attr', true) +expect(element).not.toHaveAttribute('attr', 'yes') +expect(element).not.toHaveClass() +expect(element).not.toHaveClass('cls1') +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).not.toHaveClass('cls1', {exact: true}) +expect(element).not.toHaveDisplayValue('str') +expect(element).not.toHaveDisplayValue(['str1', 'str2']) +expect(element).not.toHaveDisplayValue(/str/) +expect(element).not.toHaveDisplayValue([/str1/, 'str2']) +expect(element).not.toHaveFocus() +expect(element).not.toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).not.toHaveStyle('display: block') +expect(element).not.toHaveTextContent('Text') +expect(element).not.toHaveTextContent(/Text/) +expect(element).not.toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).not.toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).not.toHaveValue() +expect(element).not.toHaveValue('str') +expect(element).not.toHaveValue(['str1', 'str2']) +expect(element).not.toHaveValue(1) +expect(element).not.toBeChecked() +expect(element).not.toHaveDescription('some description') +expect(element).not.toHaveDescription() +expect(element).not.toHaveAccessibleDescription('some description') +expect(element).not.toHaveAccessibleDescription() +expect(element).not.toHaveAccessibleName('a label') +expect(element).not.toHaveAccessibleName() +expect(element).not.toBePartiallyChecked() +expect(element).not.toHaveErrorMessage() +expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty() diff --git a/types/__tests__/bun/tsconfig.json b/types/__tests__/bun/tsconfig.json new file mode 100644 index 00000000..2a7ced01 --- /dev/null +++ b/types/__tests__/bun/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun", "web"] + }, + "include": ["*.ts"] +} diff --git a/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts b/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts new file mode 100644 index 00000000..96034dc1 --- /dev/null +++ b/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts @@ -0,0 +1,97 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import {expect} from '@jest/globals' +import * as matchers from '../../matchers' + +expect.extend(matchers) + +const element: HTMLElement = document.body + +function customExpect( + _actual: HTMLElement, +): + | matchers.TestingLibraryMatchers + | matchers.TestingLibraryMatchers> { + throw new Error('Method not implemented.') +} + +customExpect(element).toBeInTheDOM() +customExpect(element).toBeInTheDOM(document.body) +customExpect(element).toBeInTheDocument() +customExpect(element).toBeVisible() +customExpect(element).toBeEmpty() +customExpect(element).toBeDisabled() +customExpect(element).toBeEnabled() +customExpect(element).toBeInvalid() +customExpect(element).toBeRequired() +customExpect(element).toBeValid() +customExpect(element).toContainElement(document.body) +customExpect(element).toContainElement(null) +customExpect(element).toContainHTML('body') +customExpect(element).toHaveAttribute('attr') +customExpect(element).toHaveAttribute('attr', true) +customExpect(element).toHaveAttribute('attr', 'yes') +customExpect(element).toHaveClass() +customExpect(element).toHaveClass('cls1') +customExpect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +customExpect(element).toHaveClass('cls1', {exact: true}) +customExpect(element).toHaveDisplayValue('str') +customExpect(element).toHaveDisplayValue(['str1', 'str2']) +customExpect(element).toHaveDisplayValue(/str/) +customExpect(element).toHaveDisplayValue([/str1/, 'str2']) +customExpect(element).toHaveFocus() +customExpect(element).toHaveFormValues({foo: 'bar', baz: 1}) +customExpect(element).toHaveStyle('display: block') +customExpect(element).toHaveStyle({display: 'block', width: 100}) +customExpect(element).toHaveTextContent('Text') +customExpect(element).toHaveTextContent(/Text/) +customExpect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +customExpect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +customExpect(element).toHaveValue() +customExpect(element).toHaveValue('str') +customExpect(element).toHaveValue(['str1', 'str2']) +customExpect(element).toHaveValue(1) +customExpect(element).toHaveValue(null) +customExpect(element).toBeChecked() +customExpect(element).toHaveDescription('some description') +customExpect(element).toHaveDescription(/some description/) +customExpect(element).toHaveDescription(expect.stringContaining('partial')) +customExpect(element).toHaveDescription() +customExpect(element).toHaveAccessibleDescription('some description') +customExpect(element).toHaveAccessibleDescription(/some description/) +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +) +customExpect(element).toHaveAccessibleDescription() + +customExpect(element).toHaveAccessibleErrorMessage() +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i) +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +) + +customExpect(element).toHaveAccessibleName('a label') +customExpect(element).toHaveAccessibleName(/a label/) +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +) +customExpect(element).toHaveAccessibleName() +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveErrorMessage(/invalid time/i) +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +) + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty() diff --git a/types/__tests__/jest-globals/jest-globals-types.test.ts b/types/__tests__/jest-globals/jest-globals-types.test.ts new file mode 100644 index 00000000..150f8250 --- /dev/null +++ b/types/__tests__/jest-globals/jest-globals-types.test.ts @@ -0,0 +1,120 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import {expect} from '@jest/globals' +import '../../jest-globals' + +const element: HTMLElement = document.body + +expect(element).toBeInTheDOM() +expect(element).toBeInTheDOM(document.body) +expect(element).toBeInTheDocument() +expect(element).toBeVisible() +expect(element).toBeEmpty() +expect(element).toBeDisabled() +expect(element).toBeEnabled() +expect(element).toBeInvalid() +expect(element).toBeRequired() +expect(element).toBeValid() +expect(element).toContainElement(document.body) +expect(element).toContainElement(null) +expect(element).toContainHTML('body') +expect(element).toHaveAttribute('attr') +expect(element).toHaveAttribute('attr', true) +expect(element).toHaveAttribute('attr', 'yes') +expect(element).toHaveClass() +expect(element).toHaveClass('cls1') +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).toHaveClass('cls1', {exact: true}) +expect(element).toHaveDisplayValue('str') +expect(element).toHaveDisplayValue(['str1', 'str2']) +expect(element).toHaveDisplayValue(/str/) +expect(element).toHaveDisplayValue([/str1/, 'str2']) +expect(element).toHaveFocus() +expect(element).toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).toHaveStyle('display: block') +expect(element).toHaveStyle({display: 'block', width: 100}) +expect(element).toHaveTextContent('Text') +expect(element).toHaveTextContent(/Text/) +expect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).toHaveValue() +expect(element).toHaveValue('str') +expect(element).toHaveValue(['str1', 'str2']) +expect(element).toHaveValue(1) +expect(element).toHaveValue(null) +expect(element).toBeChecked() +expect(element).toHaveDescription('some description') +expect(element).toHaveDescription(/some description/) +expect(element).toHaveDescription(expect.stringContaining('partial')) +expect(element).toHaveDescription() +expect(element).toHaveAccessibleDescription('some description') +expect(element).toHaveAccessibleDescription(/some description/) +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')) +expect(element).toHaveAccessibleDescription() +expect(element).toHaveAccessibleName('a label') +expect(element).toHaveAccessibleName(/a label/) +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')) +expect(element).toHaveAccessibleName() +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +expect(element).toHaveErrorMessage(/invalid time/i) +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') + +expect(element).not.toBeInTheDOM() +expect(element).not.toBeInTheDOM(document.body) +expect(element).not.toBeInTheDocument() +expect(element).not.toBeVisible() +expect(element).not.toBeEmpty() +expect(element).not.toBeEmptyDOMElement() +expect(element).not.toBeDisabled() +expect(element).not.toBeEnabled() +expect(element).not.toBeInvalid() +expect(element).not.toBeRequired() +expect(element).not.toBeValid() +expect(element).not.toContainElement(document.body) +expect(element).not.toContainElement(null) +expect(element).not.toContainHTML('body') +expect(element).not.toHaveAttribute('attr') +expect(element).not.toHaveAttribute('attr', true) +expect(element).not.toHaveAttribute('attr', 'yes') +expect(element).not.toHaveClass() +expect(element).not.toHaveClass('cls1') +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).not.toHaveClass('cls1', {exact: true}) +expect(element).not.toHaveDisplayValue('str') +expect(element).not.toHaveDisplayValue(['str1', 'str2']) +expect(element).not.toHaveDisplayValue(/str/) +expect(element).not.toHaveDisplayValue([/str1/, 'str2']) +expect(element).not.toHaveFocus() +expect(element).not.toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).not.toHaveStyle('display: block') +expect(element).not.toHaveTextContent('Text') +expect(element).not.toHaveTextContent(/Text/) +expect(element).not.toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).not.toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).not.toHaveValue() +expect(element).not.toHaveValue('str') +expect(element).not.toHaveValue(['str1', 'str2']) +expect(element).not.toHaveValue(1) +expect(element).not.toBeChecked() +expect(element).not.toHaveDescription('some description') +expect(element).not.toHaveDescription() +expect(element).not.toHaveAccessibleDescription('some description') +expect(element).not.toHaveAccessibleDescription() +expect(element).not.toHaveAccessibleName('a label') +expect(element).not.toHaveAccessibleName() +expect(element).not.toBePartiallyChecked() +expect(element).not.toHaveErrorMessage() +expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty() diff --git a/types/__tests__/jest-globals/tsconfig.json b/types/__tests__/jest-globals/tsconfig.json new file mode 100644 index 00000000..25a9cf27 --- /dev/null +++ b/types/__tests__/jest-globals/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["*.ts"] +} diff --git a/types/__tests__/jest/jest-custom-expect-types.test.ts b/types/__tests__/jest/jest-custom-expect-types.test.ts new file mode 100644 index 00000000..6b64b8e5 --- /dev/null +++ b/types/__tests__/jest/jest-custom-expect-types.test.ts @@ -0,0 +1,100 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import * as matchers from '../../matchers' + +expect.extend(matchers) + +const element: HTMLElement = document.body + +function customExpect( + _actual: HTMLElement, +): + | matchers.TestingLibraryMatchers + | matchers.TestingLibraryMatchers> { + throw new Error('Method not implemented.') +} + +customExpect(element).toBeInTheDOM() +customExpect(element).toBeInTheDOM(document.body) +customExpect(element).toBeInTheDocument() +customExpect(element).toBeVisible() +customExpect(element).toBeEmpty() +customExpect(element).toBeDisabled() +customExpect(element).toBeEnabled() +customExpect(element).toBeInvalid() +customExpect(element).toBeRequired() +customExpect(element).toBeValid() +customExpect(element).toContainElement(document.body) +customExpect(element).toContainElement(null) +customExpect(element).toContainHTML('body') +customExpect(element).toHaveAttribute('attr') +customExpect(element).toHaveAttribute('attr', true) +customExpect(element).toHaveAttribute('attr', 'yes') +customExpect(element).toHaveClass() +customExpect(element).toHaveClass('cls1') +customExpect(element).toHaveClass(/cls/) +customExpect(element).toHaveClass('cls1', 'cls2', /cls(3|4)/) +customExpect(element).toHaveClass('cls1', {exact: true}) +customExpect(element).toHaveDisplayValue('str') +customExpect(element).toHaveDisplayValue(['str1', 'str2']) +customExpect(element).toHaveDisplayValue(/str/) +customExpect(element).toHaveDisplayValue([/str1/, 'str2']) +customExpect(element).toHaveFocus() +customExpect(element).toHaveFormValues({foo: 'bar', baz: 1}) +customExpect(element).toHaveStyle('display: block') +customExpect(element).toHaveStyle({display: 'block', width: 100}) +customExpect(element).toHaveTextContent('Text') +customExpect(element).toHaveTextContent(/Text/) +customExpect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +customExpect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +customExpect(element).toHaveValue() +customExpect(element).toHaveValue('str') +customExpect(element).toHaveValue(['str1', 'str2']) +customExpect(element).toHaveValue(1) +customExpect(element).toHaveValue(null) +customExpect(element).toBeChecked() +customExpect(element).toHaveDescription('some description') +customExpect(element).toHaveDescription(/some description/) +customExpect(element).toHaveDescription(expect.stringContaining('partial')) +customExpect(element).toHaveDescription() +customExpect(element).toHaveAccessibleDescription('some description') +customExpect(element).toHaveAccessibleDescription(/some description/) +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +) +customExpect(element).toHaveAccessibleDescription() + +customExpect(element).toHaveAccessibleErrorMessage() +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i) +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +) + +customExpect(element).toHaveAccessibleName('a label') +customExpect(element).toHaveAccessibleName(/a label/) +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +) +customExpect(element).toHaveAccessibleName() +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveErrorMessage(/invalid time/i) +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +) + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty() + +// @ts-expect-error +customExpect(element).toHaveClass(/cls/, {exact: true}) diff --git a/types/__tests__/jest/jest-types.test.ts b/types/__tests__/jest/jest-types.test.ts new file mode 100644 index 00000000..d9596c67 --- /dev/null +++ b/types/__tests__/jest/jest-types.test.ts @@ -0,0 +1,119 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import '../../jest' + +const element: HTMLElement = document.body + +expect(element).toBeInTheDOM() +expect(element).toBeInTheDOM(document.body) +expect(element).toBeInTheDocument() +expect(element).toBeVisible() +expect(element).toBeEmpty() +expect(element).toBeDisabled() +expect(element).toBeEnabled() +expect(element).toBeInvalid() +expect(element).toBeRequired() +expect(element).toBeValid() +expect(element).toContainElement(document.body) +expect(element).toContainElement(null) +expect(element).toContainHTML('body') +expect(element).toHaveAttribute('attr') +expect(element).toHaveAttribute('attr', true) +expect(element).toHaveAttribute('attr', 'yes') +expect(element).toHaveClass() +expect(element).toHaveClass('cls1') +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).toHaveClass('cls1', {exact: true}) +expect(element).toHaveDisplayValue('str') +expect(element).toHaveDisplayValue(['str1', 'str2']) +expect(element).toHaveDisplayValue(/str/) +expect(element).toHaveDisplayValue([/str1/, 'str2']) +expect(element).toHaveFocus() +expect(element).toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).toHaveStyle('display: block') +expect(element).toHaveStyle({display: 'block', width: 100}) +expect(element).toHaveTextContent('Text') +expect(element).toHaveTextContent(/Text/) +expect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).toHaveValue() +expect(element).toHaveValue('str') +expect(element).toHaveValue(['str1', 'str2']) +expect(element).toHaveValue(1) +expect(element).toHaveValue(null) +expect(element).toBeChecked() +expect(element).toHaveDescription('some description') +expect(element).toHaveDescription(/some description/) +expect(element).toHaveDescription(expect.stringContaining('partial')) +expect(element).toHaveDescription() +expect(element).toHaveAccessibleDescription('some description') +expect(element).toHaveAccessibleDescription(/some description/) +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')) +expect(element).toHaveAccessibleDescription() +expect(element).toHaveAccessibleName('a label') +expect(element).toHaveAccessibleName(/a label/) +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')) +expect(element).toHaveAccessibleName() +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +expect(element).toHaveErrorMessage(/invalid time/i) +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') + +expect(element).not.toBeInTheDOM() +expect(element).not.toBeInTheDOM(document.body) +expect(element).not.toBeInTheDocument() +expect(element).not.toBeVisible() +expect(element).not.toBeEmpty() +expect(element).not.toBeEmptyDOMElement() +expect(element).not.toBeDisabled() +expect(element).not.toBeEnabled() +expect(element).not.toBeInvalid() +expect(element).not.toBeRequired() +expect(element).not.toBeValid() +expect(element).not.toContainElement(document.body) +expect(element).not.toContainElement(null) +expect(element).not.toContainHTML('body') +expect(element).not.toHaveAttribute('attr') +expect(element).not.toHaveAttribute('attr', true) +expect(element).not.toHaveAttribute('attr', 'yes') +expect(element).not.toHaveClass() +expect(element).not.toHaveClass('cls1') +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).not.toHaveClass('cls1', {exact: true}) +expect(element).not.toHaveDisplayValue('str') +expect(element).not.toHaveDisplayValue(['str1', 'str2']) +expect(element).not.toHaveDisplayValue(/str/) +expect(element).not.toHaveDisplayValue([/str1/, 'str2']) +expect(element).not.toHaveFocus() +expect(element).not.toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).not.toHaveStyle('display: block') +expect(element).not.toHaveTextContent('Text') +expect(element).not.toHaveTextContent(/Text/) +expect(element).not.toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).not.toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).not.toHaveValue() +expect(element).not.toHaveValue('str') +expect(element).not.toHaveValue(['str1', 'str2']) +expect(element).not.toHaveValue(1) +expect(element).not.toBeChecked() +expect(element).not.toHaveDescription('some description') +expect(element).not.toHaveDescription() +expect(element).not.toHaveAccessibleDescription('some description') +expect(element).not.toHaveAccessibleDescription() +expect(element).not.toHaveAccessibleName('a label') +expect(element).not.toHaveAccessibleName() +expect(element).not.toBePartiallyChecked() +expect(element).not.toHaveErrorMessage() +expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty() diff --git a/types/__tests__/jest/tsconfig.json b/types/__tests__/jest/tsconfig.json new file mode 100644 index 00000000..52ecd5d4 --- /dev/null +++ b/types/__tests__/jest/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["jest"] + }, + "include": ["*.ts"] +} diff --git a/types/__tests__/vitest/tsconfig.json b/types/__tests__/vitest/tsconfig.json new file mode 100644 index 00000000..25a9cf27 --- /dev/null +++ b/types/__tests__/vitest/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["*.ts"] +} diff --git a/types/__tests__/vitest/vitest-custom-expect-types.test.ts b/types/__tests__/vitest/vitest-custom-expect-types.test.ts new file mode 100644 index 00000000..e9008966 --- /dev/null +++ b/types/__tests__/vitest/vitest-custom-expect-types.test.ts @@ -0,0 +1,97 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import {expect} from 'vitest' +import * as matchers from '../../matchers' + +expect.extend(matchers) + +const element: HTMLElement = document.body + +function customExpect( + _actual: HTMLElement, +): + | matchers.TestingLibraryMatchers + | matchers.TestingLibraryMatchers> { + throw new Error('Method not implemented.') +} + +customExpect(element).toBeInTheDOM() +customExpect(element).toBeInTheDOM(document.body) +customExpect(element).toBeInTheDocument() +customExpect(element).toBeVisible() +customExpect(element).toBeEmpty() +customExpect(element).toBeDisabled() +customExpect(element).toBeEnabled() +customExpect(element).toBeInvalid() +customExpect(element).toBeRequired() +customExpect(element).toBeValid() +customExpect(element).toContainElement(document.body) +customExpect(element).toContainElement(null) +customExpect(element).toContainHTML('body') +customExpect(element).toHaveAttribute('attr') +customExpect(element).toHaveAttribute('attr', true) +customExpect(element).toHaveAttribute('attr', 'yes') +customExpect(element).toHaveClass() +customExpect(element).toHaveClass('cls1') +customExpect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +customExpect(element).toHaveClass('cls1', {exact: true}) +customExpect(element).toHaveDisplayValue('str') +customExpect(element).toHaveDisplayValue(['str1', 'str2']) +customExpect(element).toHaveDisplayValue(/str/) +customExpect(element).toHaveDisplayValue([/str1/, 'str2']) +customExpect(element).toHaveFocus() +customExpect(element).toHaveFormValues({foo: 'bar', baz: 1}) +customExpect(element).toHaveStyle('display: block') +customExpect(element).toHaveStyle({display: 'block', width: 100}) +customExpect(element).toHaveTextContent('Text') +customExpect(element).toHaveTextContent(/Text/) +customExpect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +customExpect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +customExpect(element).toHaveValue() +customExpect(element).toHaveValue('str') +customExpect(element).toHaveValue(['str1', 'str2']) +customExpect(element).toHaveValue(1) +customExpect(element).toHaveValue(null) +customExpect(element).toBeChecked() +customExpect(element).toHaveDescription('some description') +customExpect(element).toHaveDescription(/some description/) +customExpect(element).toHaveDescription(expect.stringContaining('partial')) +customExpect(element).toHaveDescription() +customExpect(element).toHaveAccessibleDescription('some description') +customExpect(element).toHaveAccessibleDescription(/some description/) +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +) +customExpect(element).toHaveAccessibleDescription() + +customExpect(element).toHaveAccessibleErrorMessage() +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i) +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +) + +customExpect(element).toHaveAccessibleName('a label') +customExpect(element).toHaveAccessibleName(/a label/) +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +) +customExpect(element).toHaveAccessibleName() +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +customExpect(element).toHaveErrorMessage(/invalid time/i) +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +) + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty() diff --git a/types/__tests__/vitest/vitest-types.test.ts b/types/__tests__/vitest/vitest-types.test.ts new file mode 100644 index 00000000..7a90be05 --- /dev/null +++ b/types/__tests__/vitest/vitest-types.test.ts @@ -0,0 +1,120 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import {expect} from 'vitest' +import '../../vitest' + +const element: HTMLElement = document.body + +expect(element).toBeInTheDOM() +expect(element).toBeInTheDOM(document.body) +expect(element).toBeInTheDocument() +expect(element).toBeVisible() +expect(element).toBeEmpty() +expect(element).toBeDisabled() +expect(element).toBeEnabled() +expect(element).toBeInvalid() +expect(element).toBeRequired() +expect(element).toBeValid() +expect(element).toContainElement(document.body) +expect(element).toContainElement(null) +expect(element).toContainHTML('body') +expect(element).toHaveAttribute('attr') +expect(element).toHaveAttribute('attr', true) +expect(element).toHaveAttribute('attr', 'yes') +expect(element).toHaveClass() +expect(element).toHaveClass('cls1') +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).toHaveClass('cls1', {exact: true}) +expect(element).toHaveDisplayValue('str') +expect(element).toHaveDisplayValue(['str1', 'str2']) +expect(element).toHaveDisplayValue(/str/) +expect(element).toHaveDisplayValue([/str1/, 'str2']) +expect(element).toHaveFocus() +expect(element).toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).toHaveStyle('display: block') +expect(element).toHaveStyle({display: 'block', width: 100}) +expect(element).toHaveTextContent('Text') +expect(element).toHaveTextContent(/Text/) +expect(element).toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).toHaveValue() +expect(element).toHaveValue('str') +expect(element).toHaveValue(['str1', 'str2']) +expect(element).toHaveValue(1) +expect(element).toHaveValue(null) +expect(element).toBeChecked() +expect(element).toHaveDescription('some description') +expect(element).toHaveDescription(/some description/) +expect(element).toHaveDescription(expect.stringContaining('partial')) +expect(element).toHaveDescription() +expect(element).toHaveAccessibleDescription('some description') +expect(element).toHaveAccessibleDescription(/some description/) +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')) +expect(element).toHaveAccessibleDescription() +expect(element).toHaveAccessibleName('a label') +expect(element).toHaveAccessibleName(/a label/) +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')) +expect(element).toHaveAccessibleName() +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +) +expect(element).toHaveErrorMessage(/invalid time/i) +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') + +expect(element).not.toBeInTheDOM() +expect(element).not.toBeInTheDOM(document.body) +expect(element).not.toBeInTheDocument() +expect(element).not.toBeVisible() +expect(element).not.toBeEmpty() +expect(element).not.toBeEmptyDOMElement() +expect(element).not.toBeDisabled() +expect(element).not.toBeEnabled() +expect(element).not.toBeInvalid() +expect(element).not.toBeRequired() +expect(element).not.toBeValid() +expect(element).not.toContainElement(document.body) +expect(element).not.toContainElement(null) +expect(element).not.toContainHTML('body') +expect(element).not.toHaveAttribute('attr') +expect(element).not.toHaveAttribute('attr', true) +expect(element).not.toHaveAttribute('attr', 'yes') +expect(element).not.toHaveClass() +expect(element).not.toHaveClass('cls1') +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4') +expect(element).not.toHaveClass('cls1', {exact: true}) +expect(element).not.toHaveDisplayValue('str') +expect(element).not.toHaveDisplayValue(['str1', 'str2']) +expect(element).not.toHaveDisplayValue(/str/) +expect(element).not.toHaveDisplayValue([/str1/, 'str2']) +expect(element).not.toHaveFocus() +expect(element).not.toHaveFormValues({foo: 'bar', baz: 1}) +expect(element).not.toHaveStyle('display: block') +expect(element).not.toHaveTextContent('Text') +expect(element).not.toHaveTextContent(/Text/) +expect(element).not.toHaveTextContent('Text', {normalizeWhitespace: true}) +expect(element).not.toHaveTextContent(/Text/, {normalizeWhitespace: true}) +expect(element).not.toHaveValue() +expect(element).not.toHaveValue('str') +expect(element).not.toHaveValue(['str1', 'str2']) +expect(element).not.toHaveValue(1) +expect(element).not.toBeChecked() +expect(element).not.toHaveDescription('some description') +expect(element).not.toHaveDescription() +expect(element).not.toHaveAccessibleDescription('some description') +expect(element).not.toHaveAccessibleDescription() +expect(element).not.toHaveAccessibleName('a label') +expect(element).not.toHaveAccessibleName() +expect(element).not.toBePartiallyChecked() +expect(element).not.toHaveErrorMessage() +expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty() diff --git a/types/bun.d.ts b/types/bun.d.ts new file mode 100644 index 00000000..676a0ac3 --- /dev/null +++ b/types/bun.d.ts @@ -0,0 +1,11 @@ +import {type expect} from 'bun:test' +import {type TestingLibraryMatchers} from './matchers' + +export {} +declare module 'bun:test' { + interface Matchers + extends TestingLibraryMatchers< + ReturnType, + T + > {} +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 00000000..cebdd1af --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1 @@ +/// diff --git a/types/jest-globals.d.ts b/types/jest-globals.d.ts new file mode 100644 index 00000000..a6819079 --- /dev/null +++ b/types/jest-globals.d.ts @@ -0,0 +1,11 @@ +import {type expect} from '@jest/globals' +import {type TestingLibraryMatchers} from './matchers' + +export {} +declare module '@jest/expect' { + export interface Matchers> + extends TestingLibraryMatchers< + ReturnType, + R + > {} +} diff --git a/types/jest.d.ts b/types/jest.d.ts new file mode 100644 index 00000000..1daed629 --- /dev/null +++ b/types/jest.d.ts @@ -0,0 +1,13 @@ +/// + +import {type TestingLibraryMatchers} from './matchers' + +declare global { + namespace jest { + interface Matchers + extends TestingLibraryMatchers< + ReturnType, + R + > {} + } +} diff --git a/types/matchers-standalone.d.ts b/types/matchers-standalone.d.ts new file mode 100644 index 00000000..175e9157 --- /dev/null +++ b/types/matchers-standalone.d.ts @@ -0,0 +1,30 @@ +import {type TestingLibraryMatchers as _TLM} from './matchers' + +interface MatcherReturnType { + pass: boolean + message: () => string +} + +interface OverloadedMatchers { + toHaveClass(expected: any, ...rest: string[]) : MatcherReturnType + toHaveClass( + expected: any, + className: string, + options?: {exact: boolean}, + ) : MatcherReturnType +} + +declare namespace matchersStandalone { + type MatchersStandalone = { + [T in keyof _TLM]: ( + expected: any, + ...rest: Parameters<_TLM[T]> + ) => MatcherReturnType + } & OverloadedMatchers + + type TestingLibraryMatchers = _TLM +} + +declare const matchersStandalone: matchersStandalone.MatchersStandalone & + Record +export = matchersStandalone diff --git a/types/matchers.d.ts b/types/matchers.d.ts new file mode 100755 index 00000000..8bf2ad58 --- /dev/null +++ b/types/matchers.d.ts @@ -0,0 +1,771 @@ +import {type ARIARole} from 'aria-query' + +declare namespace matchers { + interface TestingLibraryMatchers { + /** + * @deprecated + * since v1.9.0 + * @description + * Assert whether a value is a DOM element, or not. Contrary to what its name implies, this matcher only checks + * that you passed to it a valid DOM element. + * + * It does not have a clear definition of what "the DOM" is. Therefore, it does not check whether that element + * is contained anywhere. + * @see + * [testing-library/jest-dom#toBeInTheDom](https://github.com/testing-library/jest-dom#toBeInTheDom) + */ + toBeInTheDOM(container?: HTMLElement | SVGElement): R + /** + * @description + * Assert whether an element is present in the document or not. + * @example + * + * + * expect(queryByTestId('svg-element')).toBeInTheDocument() + * expect(queryByTestId('does-not-exist')).not.toBeInTheDocument() + * @see + * [testing-library/jest-dom#tobeinthedocument](https://github.com/testing-library/jest-dom#tobeinthedocument) + */ + toBeInTheDocument(): R + /** + * @description + * This allows you to check if an element is currently visible to the user. + * + * An element is visible if **all** the following conditions are met: + * * it does not have its css property display set to none + * * it does not have its css property visibility set to either hidden or collapse + * * it does not have its css property opacity set to 0 + * * its parent element is also visible (and so on up to the top of the DOM tree) + * * it does not have the hidden attribute + * * if `
` it has the open attribute + * @example + *
+ * Zero Opacity + *
+ * + *
Visible Example
+ * + * expect(getByTestId('zero-opacity')).not.toBeVisible() + * expect(getByTestId('visible')).toBeVisible() + * @see + * [testing-library/jest-dom#tobevisible](https://github.com/testing-library/jest-dom#tobevisible) + */ + toBeVisible(): R + /** + * @deprecated + * since v5.9.0 + * @description + * Assert whether an element has content or not. + * @example + * + * + * + * + * expect(getByTestId('empty')).toBeEmpty() + * expect(getByTestId('not-empty')).not.toBeEmpty() + * @see + * [testing-library/jest-dom#tobeempty](https://github.com/testing-library/jest-dom#tobeempty) + */ + toBeEmpty(): R + /** + * @description + * Assert whether an element has content or not. + * @example + * + * + * + * + * expect(getByTestId('empty')).toBeEmptyDOMElement() + * expect(getByTestId('not-empty')).not.toBeEmptyDOMElement() + * @see + * [testing-library/jest-dom#tobeemptydomelement](https://github.com/testing-library/jest-dom#tobeemptydomelement) + */ + toBeEmptyDOMElement(): R + /** + * @description + * Allows you to check whether an element is disabled from the user's perspective. + * + * Matches if the element is a form control and the `disabled` attribute is specified on this element or the + * element is a descendant of a form element with a `disabled` attribute. + * @example + * + * + * expect(getByTestId('button')).toBeDisabled() + * @see + * [testing-library/jest-dom#tobedisabled](https://github.com/testing-library/jest-dom#tobedisabled) + */ + toBeDisabled(): R + /** + * @description + * Allows you to check whether an element is not disabled from the user's perspective. + * + * Works like `not.toBeDisabled()`. + * + * Use this matcher to avoid double negation in your tests. + * @example + * + * + * expect(getByTestId('button')).toBeEnabled() + * @see + * [testing-library/jest-dom#tobeenabled](https://github.com/testing-library/jest-dom#tobeenabled) + */ + toBeEnabled(): R + /** + * @description + * Check if a form element, or the entire `form`, is currently invalid. + * + * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no + * value or a value of "true", or if the result of `checkValidity()` is false. + * @example + * + * + *
+ * + *
+ * + * expect(getByTestId('no-aria-invalid')).not.toBeInvalid() + * expect(getByTestId('invalid-form')).toBeInvalid() + * @see + * [testing-library/jest-dom#tobeinvalid](https://github.com/testing-library/jest-dom#tobeinvalid) + */ + toBeInvalid(): R + /** + * @description + * This allows you to check if a form element is currently required. + * + * An element is required if it is having a `required` or `aria-required="true"` attribute. + * @example + * + *
+ * + * expect(getByTestId('required-input')).toBeRequired() + * expect(getByTestId('supported-role')).not.toBeRequired() + * @see + * [testing-library/jest-dom#toberequired](https://github.com/testing-library/jest-dom#toberequired) + */ + toBeRequired(): R + /** + * @description + * Allows you to check if a form element is currently required. + * + * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no + * value or a value of "false", or if the result of `checkValidity()` is true. + * @example + * + * + *
+ * + *
+ * + * expect(getByTestId('no-aria-invalid')).not.toBeValid() + * expect(getByTestId('invalid-form')).toBeInvalid() + * @see + * [testing-library/jest-dom#tobevalid](https://github.com/testing-library/jest-dom#tobevalid) + */ + toBeValid(): R + /** + * @description + * Allows you to assert whether an element contains another element as a descendant or not. + * @example + * + * + * + * + * const ancestor = getByTestId('ancestor') + * const descendant = getByTestId('descendant') + * const nonExistantElement = getByTestId('does-not-exist') + * expect(ancestor).toContainElement(descendant) + * expect(descendant).not.toContainElement(ancestor) + * expect(ancestor).not.toContainElement(nonExistantElement) + * @see + * [testing-library/jest-dom#tocontainelement](https://github.com/testing-library/jest-dom#tocontainelement) + */ + toContainElement(element: HTMLElement | SVGElement | null): R + /** + * @description + * Assert whether a string representing a HTML element is contained in another element. + * @example + * + * + * expect(getByTestId('parent')).toContainHTML('') + * @see + * [testing-library/jest-dom#tocontainhtml](https://github.com/testing-library/jest-dom#tocontainhtml) + */ + toContainHTML(htmlText: string): R + /** + * @description + * Allows you to check if a given element has an attribute or not. + * + * You can also optionally check that the attribute has a specific expected value or partial match using + * [expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring) or + * [expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). + * @example + * + * + * expect(button).toHaveAttribute('disabled') + * expect(button).toHaveAttribute('type', 'submit') + * expect(button).not.toHaveAttribute('type', 'button') + * @see + * [testing-library/jest-dom#tohaveattribute](https://github.com/testing-library/jest-dom#tohaveattribute) + */ + toHaveAttribute(attr: string, value?: unknown): R + /** + * @description + * Check whether the given element has certain classes within its `class` attribute. + * + * You must provide at least one class, unless you are asserting that an element does not have any classes. + * @example + * + * + *
no classes
+ * + * const deleteButton = getByTestId('delete-button') + * const noClasses = getByTestId('no-classes') + * expect(deleteButton).toHaveClass('btn') + * expect(deleteButton).toHaveClass('btn-danger xs') + * expect(deleteButton).toHaveClass(/danger/, 'xs') + * expect(deleteButton).toHaveClass('btn xs btn-danger', {exact: true}) + * expect(deleteButton).not.toHaveClass('btn xs btn-danger', {exact: true}) + * expect(noClasses).not.toHaveClass() + * @see + * [testing-library/jest-dom#tohaveclass](https://github.com/testing-library/jest-dom#tohaveclass) + */ + toHaveClass(...classNames: Array): R + toHaveClass(classNames: string, options?: {exact: boolean}): R + /** + * @description + * This allows you to check whether the given form element has the specified displayed value (the one the + * end user will see). It accepts , + * + * + * + * + * + * + * + * const input = screen.getByLabelText('First name') + * const textarea = screen.getByLabelText('Description') + * const selectSingle = screen.getByLabelText('Fruit') + * const selectMultiple = screen.getByLabelText('Fruits') + * + * expect(input).toHaveDisplayValue('Luca') + * expect(textarea).toHaveDisplayValue('An example description here.') + * expect(selectSingle).toHaveDisplayValue('Select a fruit...') + * expect(selectMultiple).toHaveDisplayValue(['Banana', 'Avocado']) + * + * @see + * [testing-library/jest-dom#tohavedisplayvalue](https://github.com/testing-library/jest-dom#tohavedisplayvalue) + */ + toHaveDisplayValue(value: string | RegExp | Array): R + /** + * @description + * Assert whether an element has focus or not. + * @example + *
+ * + *
+ * + * const input = getByTestId('element-to-focus') + * input.focus() + * expect(input).toHaveFocus() + * input.blur() + * expect(input).not.toHaveFocus() + * @see + * [testing-library/jest-dom#tohavefocus](https://github.com/testing-library/jest-dom#tohavefocus) + */ + toHaveFocus(): R + /** + * @description + * Check if a form or fieldset contains form controls for each given name, and having the specified value. + * + * Can only be invoked on a form or fieldset element. + * @example + *
+ * + * + * + * + *
+ * + * expect(getByTestId('login-form')).toHaveFormValues({ + * username: 'jane.doe', + * rememberMe: true, + * }) + * @see + * [testing-library/jest-dom#tohaveformvalues](https://github.com/testing-library/jest-dom#tohaveformvalues) + */ + toHaveFormValues(expectedValues: Record): R + /** + * @description + * Check if an element has specific css properties with specific values applied. + * + * Only matches if the element has *all* the expected properties applied, not just some of them. + * @example + * + * + * const button = getByTestId('submit-button') + * expect(button).toHaveStyle('background-color: green') + * expect(button).toHaveStyle({ + * 'background-color': 'green', + * display: 'none' + * }) + * @see + * [testing-library/jest-dom#tohavestyle](https://github.com/testing-library/jest-dom#tohavestyle) + */ + toHaveStyle(css: string | Record): R + /** + * @description + * Check whether the given element has a text content or not. + * + * When a string argument is passed through, it will perform a partial case-sensitive match to the element + * content. + * + * To perform a case-insensitive match, you can use a RegExp with the `/i` modifier. + * + * If you want to match the whole content, you can use a RegExp to do it. + * @example + * Text Content + * + * const element = getByTestId('text-content') + * expect(element).toHaveTextContent('Content') + * // to match the whole content + * expect(element).toHaveTextContent(/^Text Content$/) + * // to use case-insentive match + * expect(element).toHaveTextContent(/content$/i) + * expect(element).not.toHaveTextContent('content') + * @see + * [testing-library/jest-dom#tohavetextcontent](https://github.com/testing-library/jest-dom#tohavetextcontent) + */ + toHaveTextContent( + text: string | RegExp, + options?: {normalizeWhitespace: boolean}, + ): R + /** + * @description + * Check whether the given form element has the specified value. + * + * Accepts ``, ` + *

prev

+ *

text selected text

+ *

next

+ *
+ * + * getByTestId('text').setSelectionRange(5, 13) + * expect(getByTestId('text')).toHaveSelection('selected') + * + * getByTestId('textarea').setSelectionRange(0, 5) + * expect('textarea').toHaveSelection('text ') + * + * const selection = document.getSelection() + * const range = document.createRange() + * selection.removeAllRanges() + * selection.empty() + * selection.addRange(range) + * + * // selection of child applies to the parent as well + * range.selectNodeContents(getByTestId('child')) + * expect(getByTestId('child')).toHaveSelection('selected') + * expect(getByTestId('parent')).toHaveSelection('selected') + * + * // selection that applies from prev all, parent text before child, and part child. + * range.setStart(getByTestId('prev'), 0) + * range.setEnd(getByTestId('child').childNodes[0], 3) + * expect(queryByTestId('prev')).toHaveSelection('prev') + * expect(queryByTestId('child')).toHaveSelection('sel') + * expect(queryByTestId('parent')).toHaveSelection('text sel') + * expect(queryByTestId('next')).not.toHaveSelection() + * + * // selection that applies from part child, parent text after child and part next. + * range.setStart(getByTestId('child').childNodes[0], 3) + * range.setEnd(getByTestId('next').childNodes[0], 2) + * expect(queryByTestId('child')).toHaveSelection('ected') + * expect(queryByTestId('parent')).toHaveSelection('ected text') + * expect(queryByTestId('prev')).not.toHaveSelection() + * expect(queryByTestId('next')).toHaveSelection('ne') + * + * @see + * [testing-library/jest-dom#tohaveselection](https://github.com/testing-library/jest-dom#tohaveselection) + */ + toHaveSelection(selection?: string): R + } +} + +// Needs to extend Record to be accepted by expect.extend() +// as it requires a string index signature. +declare const matchers: matchers.TestingLibraryMatchers & + Record +export = matchers diff --git a/types/vitest.d.ts b/types/vitest.d.ts new file mode 100644 index 00000000..3ee9eb2f --- /dev/null +++ b/types/vitest.d.ts @@ -0,0 +1,15 @@ +import 'vitest' +import {type TestingLibraryMatchers} from './matchers' + +declare module 'vitest' { + interface Assertion + extends TestingLibraryMatchers< + any, + T + > {} + interface AsymmetricMatchersContaining + extends TestingLibraryMatchers< + any, + any + > {} +} diff --git a/vitest.d.ts b/vitest.d.ts new file mode 100644 index 00000000..1b17a0d4 --- /dev/null +++ b/vitest.d.ts @@ -0,0 +1 @@ +/// diff --git a/vitest.js b/vitest.js new file mode 100644 index 00000000..e1f318c5 --- /dev/null +++ b/vitest.js @@ -0,0 +1,4 @@ +import {expect} from 'vitest' +import * as extensions from './dist/matchers' + +expect.extend(extensions)