diff --git a/.eslintignore b/.eslintignore index ae409af0..44fa669a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,5 @@ /coverage /dist /node_modules +/testenv +/scripts diff --git a/.eslintrc.js b/.eslintrc.js index 923f1f31..c0ed5299 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,5 +23,11 @@ module.exports = { 'local-rules/explicit-globals': 'error', }, }, + { + files: ['**.{ts,tsx}'], + rules: { + '@typescript-eslint/no-unsafe-argument': 1, + }, + }, ], } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c21cbf8..f3fcbd04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,10 @@ jobs: run: npm run lint - name: ๐Ÿงช Test run: npm run test -- --coverage + - name: ๐Ÿšง Build test environments + run: npm run setup:env + - name: ๐Ÿ”ฌ Test with toolbox + run: npm run test:toolbox - name: ๐Ÿ— Build run: npm run build diff --git a/jest.config.js b/jest.config.js index 44cb3f12..077f1872 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,8 +11,7 @@ config.moduleNameMapper = { config.testEnvironment = 'jsdom' config.setupFilesAfterEnv = [ - '/tests/_setup-env.js', - '/tests/react/_env/setup-env.js', + '/testenv/jest.js', ] config.testMatch.push('/tests/**/*.+(js|jsx|ts|tsx)') @@ -24,4 +23,10 @@ config.testPathIgnorePatterns.push('/_.*(?=7.21.4" - } + }, + "dependencies": {} } diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 00000000..6990891f --- /dev/null +++ b/scripts/package.json @@ -0,0 +1 @@ +{"type": "module"} diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 00000000..39148d26 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,136 @@ +import fs from 'fs/promises' +import path from 'path' +import { createBundleBuilder } from '@ph.fritsche/toolbox/dist/builder/index.js' +import { spawn } from 'child_process' + +const dirname = path.dirname(new URL(import.meta.url).pathname) +const indexDirLib = path.join(dirname, '../testenv/libs') +const indexDirEnv = path.join(dirname, '../testenv') + +const ignoreEnv = ['node.js', 'jest.js'] + +const cmd = process.argv[2] +const names = process.argv.length > 3 ? process.argv.slice(3) : undefined + +if (cmd === 'install-lib') { + await Promise.all( + (await getLibDirs(names)) + .map(([name, dir]) => installLib(name, dir)) + ) +} else if (cmd === 'bundle-lib') { + await Promise.all( + (await getLibDirs(names)) + .map(([name, dir]) => buildLib(name, dir)) + ) +} else if (cmd === 'bundle-env') { + await Promise.all( + (await getEnvFiles(names)) + .map(([name, file]) => buildEnv(name, file)) + ) +} else if (!cmd) { + await Promise.all([ + ...(await getLibDirs()).map(([name, dir]) => installLib(name, dir).then(() => buildLib(name, dir))), + ...(await getEnvFiles()).map(([name, file]) => buildEnv(name, file)), + ]) +} + +async function getLibDirs(names) { + names ??= (await fs.readdir(indexDirLib)).filter(n => !n.startsWith('.')) + + return await Promise.all(names.map(name => { + const dir = `${indexDirLib}/${name}` + + return fs.stat(`${dir}/index.js`).then( + () => [name, dir], + () => {throw new Error(`${dir}/index.js could not be found.`)} + ) + })) +} + +async function getEnvFiles(names) { + names ??= (await fs.readdir(indexDirEnv)) + .filter(n => /^\w+\.js$/.test(n)) + .filter(n => !ignoreEnv.includes(n)) + .map(f => f.slice(0, f.length - 3)) + + return await Promise.all(names.map(async name => { + const file = `${indexDirEnv}/${name}.js` + + return fs.stat(file).then( + () => [name, file], + () => { throw new Error(`${file} could not be found.`)} + ) + })) +} + +async function installLib(name, dir) { + return new Promise((res, rej) => { + const child = spawn('npm', ['i'], {cwd: dir}) + + process.stdout.write(`Installing library "${name}" at ${dir}\n`) + + child.on('error', e => { + process.stdout.write(`${e.stack ?? String(e)}\n`) + }) + child.on('exit', (code, signal) => { + (code || signal ? rej(code) : res()) + }) + }) +} + +async function buildLib(name, dir) { + const { globals } = JSON.parse(await fs.readFile(`${dir}/package.json`)) + + process.stdout.write(`Bundling library "${name}" at ${dir}/index.js\n`) + + const builder = createBundleBuilder({ + basePath: `${dir}/`, + globals, + }) + builder.inputFiles.set(`${dir}/index.js`, undefined) + + builder.emitter.addListener('complete', e => { + const content = String(e.outputFiles.get('index.js')?.content) + fs.writeFile(`${dir}/index.bundle.js`, content) + .then(() => process.stdout.write([ + '<<<', + `Wrote ${dir}/index.bundle.js`, + `[${content.length} bytes]`, + ...((globals && Object.keys(globals).length) + ? [ + ` Depending on:`, + ...Object.entries(globals).map(([module, name]) => ` ${name} => ${module}`), + ] + : []), + '>>>', + '', + ].join('\n'))) + }) + + builder.build() +} + +async function buildEnv(name, file) { + process.stdout.write(`Bundling environment "${name}" at ${file}\n`) + + const builder = createBundleBuilder({ + basePath: `${indexDirEnv}/`, + }) + const basename = path.basename(file, '.js') + builder.inputFiles.set(file, undefined) + + builder.emitter.addListener('complete', e => { + const content = String(e.outputFiles.get(`${basename}.js`)?.content) + fs.writeFile(`${indexDirEnv}/${basename}.bundle.js`, content) + .then(() => process.stdout.write([ + '<<<', + `Wrote ${indexDirEnv}/${basename}.bundle.js`, + `[${content.length} bytes]`, + '>>>', + '', + ].join('\n'))) + }) + + builder.build() +} + diff --git a/scripts/test.js b/scripts/test.js new file mode 100644 index 00000000..7052ffd6 --- /dev/null +++ b/scripts/test.js @@ -0,0 +1,126 @@ +import { createProjectBuildProvider, serveDir, serveToolboxRunner } from '@ph.fritsche/toolbox' +import { NodeTestConductor } from '@ph.fritsche/toolbox/dist/conductor/NodeTestConductor.js' +import { ChromeTestConductor } from '@ph.fritsche/toolbox/dist/conductor/ChromeTestConductor.js' +import { ConsoleReporter } from '@ph.fritsche/toolbox/dist/reporter/ConsoleReporter.js' +import { ReporterServer } from '@ph.fritsche/toolbox/dist/reporter/ReporterServer.js' +import { TestRunStack } from '@ph.fritsche/toolbox/dist/reporter/TestRunStack.js' + +import IstanbulLibCoverage from 'istanbul-lib-coverage' +import IstanbulLibReport from 'istanbul-lib-report' +import IstanbulLibSourceMaps from 'istanbul-lib-source-maps' +import IstanbulReports from 'istanbul-reports' + +const tsConfigFile = './tests/tsconfig.json' + +const toolbox = await serveToolboxRunner() +const env = await serveDir('testenv') + +const { buildProvider, fileProvider, fileServer, onBuildDone } = createProjectBuildProvider([ + 'src', + 'tests', +], { + tsConfigFile, + globals: { + '@testing-library/dom': 'DomTestingLibrary', + '@testing-library/react': 'ReactTestingLibrary', + 'react': 'React', + 'react-dom': 'ReactDom', + } +}) + +for (const { builder } of buildProvider.builders) { + builder.emitter.addListener('start', ({ type, buildId, inputFiles }) => console.log(builder.id, { type, buildId, inputFiles: inputFiles.size })) + builder.emitter.addListener('complete', ({ type, buildId, inputFiles, outputFiles }) => console.log(builder.id, { type, buildId, inputFiles: inputFiles.size, outputFiles: outputFiles.size })) + builder.emitter.addListener('error', ({ type, buildId, error }) => console.log(builder.id, { type, buildId, error })) + builder.emitter.addListener('done', ({ type, buildId }) => console.log(builder.id, { type, buildId })) +} +buildProvider.getBuilder('dependencies').builder.emitter.addListener('start', ({inputFiles}) => console.log('dependencies', inputFiles.keys())) + +const filter = (f) => f.startsWith('tests') + && /(? { + const files = { + server: await fileServer.url, + paths: Array.from(fileProvider.files.keys()).filter(filter), + } + const runs = conductors.map(c => c.createTestRun(files)) + const stack = new TestRunStack(runs.map(r => r.run)) + + for (const r of runs) { + await r.exec() + } + + await stack.then() + + const coverageMap = IstanbulLibCoverage.createCoverageMap() + for (const run of stack.runs) { + for (const coverage of run.coverage.values()) { + coverageMap.merge(coverage) + } + } + + const sourceStore = IstanbulLibSourceMaps.createSourceMapStore() + const reportContext = IstanbulLibReport.createContext({ + coverageMap: await sourceStore.transformCoverage(coverageMap), + dir: fileProvider.origin, + sourceFinder: sourceStore.sourceFinder, + defaultSummarizer: 'nested', + watermarks: { + branches: [80, 100], + functions: [80, 100], + lines: [80, 100], + statements: [80, 100], + }, + }) + + IstanbulReports.create('text').execute(reportContext) + + if (process.env.CI) { + toolbox.server.close() + env.server.close() + fileServer.close() + buildProvider.close() + reporterServer.close() + conductors.forEach(c => c.close()) + } +}) diff --git a/src/event/dom-events.d.ts b/src/_interop/dom-events.d.ts similarity index 100% rename from src/event/dom-events.d.ts rename to src/_interop/dom-events.d.ts diff --git a/src/utils/misc/dom-helpers.d.ts b/src/_interop/dom-helpers.d.ts similarity index 100% rename from src/utils/misc/dom-helpers.d.ts rename to src/_interop/dom-helpers.d.ts diff --git a/src/_interop/dtl.ts b/src/_interop/dtl.ts new file mode 100644 index 00000000..4011553d --- /dev/null +++ b/src/_interop/dtl.ts @@ -0,0 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ + +import def, * as named from '@testing-library/dom' + +export default def ?? named diff --git a/src/_interop/dtlEventMap.ts b/src/_interop/dtlEventMap.ts new file mode 100644 index 00000000..6474d356 --- /dev/null +++ b/src/_interop/dtlEventMap.ts @@ -0,0 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ + +import def, * as named from '@testing-library/dom/dist/event-map.js' + +export default def ?? named diff --git a/src/_interop/dtlHelpers.ts b/src/_interop/dtlHelpers.ts new file mode 100644 index 00000000..dcc60101 --- /dev/null +++ b/src/_interop/dtlHelpers.ts @@ -0,0 +1,5 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ + +import def, * as named from '@testing-library/dom/dist/helpers.js' + +export default def ?? named diff --git a/src/event/eventMap.ts b/src/event/eventMap.ts index ee628b44..629ce939 100644 --- a/src/event/eventMap.ts +++ b/src/event/eventMap.ts @@ -1,8 +1,8 @@ -import {eventMap as baseEventMap} from '@testing-library/dom/dist/event-map.js' +import dtlEvents from '../_interop/dtlEventMap' import {EventType} from './types' export const eventMap = { - ...baseEventMap, + ...dtlEvents.eventMap, click: { EventType: 'PointerEvent', diff --git a/src/event/wrapEvent.ts b/src/event/wrapEvent.ts index 3f436ccf..15254b56 100644 --- a/src/event/wrapEvent.ts +++ b/src/event/wrapEvent.ts @@ -1,4 +1,6 @@ -import {getConfig} from '@testing-library/dom' +import dtl from '../_interop/dtl' + +const { getConfig } = dtl export function wrapEvent(cb: () => R, _element: Element) { return getConfig().eventWrapper(cb) as unknown as R diff --git a/src/setup/api.ts b/src/setup/api.ts index 15a7e695..4180ce64 100644 --- a/src/setup/api.ts +++ b/src/setup/api.ts @@ -1,5 +1,24 @@ -export {click, dblClick, tripleClick, hover, unhover, tab} from '../convenience' -export {keyboard} from '../keyboard' -export {copy, cut, paste} from '../clipboard' -export {pointer} from '../pointer' -export {clear, deselectOptions, selectOptions, type, upload} from '../utility' +import {click, dblClick, tripleClick, hover, unhover, tab} from '../convenience' +import {keyboard} from '../keyboard' +import {copy, cut, paste} from '../clipboard' +import {pointer} from '../pointer' +import {clear, deselectOptions, selectOptions, type, upload} from '../utility' + +export const userEventApi = { + click, + dblClick, + tripleClick, + hover, + unhover, + tab, + keyboard, + copy, + cut, + paste, + pointer, + clear, + deselectOptions, + selectOptions, + type, + upload, +} diff --git a/src/setup/setup.ts b/src/setup/setup.ts index afef6165..f841e9f6 100644 --- a/src/setup/setup.ts +++ b/src/setup/setup.ts @@ -11,7 +11,7 @@ import { wait, } from '../utils' import {System} from '../system' -import * as userEventApi from './api' +import {userEventApi} from './api' import {wrapAsync} from './wrapAsync' import {DirectOptions} from './directApi' diff --git a/src/setup/wrapAsync.ts b/src/setup/wrapAsync.ts index 83cda376..e18efcf8 100644 --- a/src/setup/wrapAsync.ts +++ b/src/setup/wrapAsync.ts @@ -1,4 +1,6 @@ -import {getConfig} from '@testing-library/dom' +import dtl from '../_interop/dtl' + +const { getConfig } = dtl /** * Wrap an internal Promise diff --git a/src/utility/selectOptions.ts b/src/utility/selectOptions.ts index f76fb42e..03766a62 100644 --- a/src/utility/selectOptions.ts +++ b/src/utility/selectOptions.ts @@ -1,8 +1,10 @@ -import {getConfig} from '@testing-library/dom' +import dtl from '../_interop/dtl' import {hasPointerEvents, isDisabled, isElementType, wait} from '../utils' import type {Instance} from '../setup' import {focusElement} from '../event' +const { getConfig } = dtl + export async function selectOptions( this: Instance, select: Element, diff --git a/src/utils/dataTransfer/Clipboard.ts b/src/utils/dataTransfer/Clipboard.ts index edb32d3b..a26be281 100644 --- a/src/utils/dataTransfer/Clipboard.ts +++ b/src/utils/dataTransfer/Clipboard.ts @@ -210,12 +210,16 @@ export async function writeDataTransferToClipboard( } } +const g = globalThis as { + afterEach?: (cb?: () => void) => void + afterAll?: (cb?: () => void) => void +} /* istanbul ignore else */ -if (typeof globalThis.afterEach === 'function') { - globalThis.afterEach(() => resetClipboardStubOnView(globalThis.window)) +if (typeof g.afterEach === 'function') { + g.afterEach(() => resetClipboardStubOnView(globalThis.window)) } /* istanbul ignore else */ -if (typeof globalThis.afterAll === 'function') { - globalThis.afterAll(() => detachClipboardStubFromView(globalThis.window)) +if (typeof g.afterAll === 'function') { + g.afterAll(() => detachClipboardStubFromView(globalThis.window)) } diff --git a/src/utils/misc/getWindow.ts b/src/utils/misc/getWindow.ts index 4dcde0b0..a723667f 100644 --- a/src/utils/misc/getWindow.ts +++ b/src/utils/misc/getWindow.ts @@ -1,4 +1,6 @@ -import {getWindowFromNode} from '@testing-library/dom/dist/helpers.js' +import dtlHelpers from '../../_interop/dtlHelpers' + +const {getWindowFromNode} = dtlHelpers export function getWindow(node: Node) { return getWindowFromNode(node) as Window & typeof globalThis diff --git a/src/utils/misc/isElementType.ts b/src/utils/misc/isElementType.ts index 91f423f9..8106a6ab 100644 --- a/src/utils/misc/isElementType.ts +++ b/src/utils/misc/isElementType.ts @@ -25,7 +25,7 @@ export function isElementType< } if (props) { - return Object.entries(props as NonNullable

).every( + return Object.entries(props).every( ([k, v]) => element[k as keyof Element] === v, ) } diff --git a/testenv/.gitignore b/testenv/.gitignore new file mode 100644 index 00000000..ac2de2b8 --- /dev/null +++ b/testenv/.gitignore @@ -0,0 +1 @@ +*.bundle.js diff --git a/testenv/browser.js b/testenv/browser.js new file mode 100644 index 00000000..334fa6ff --- /dev/null +++ b/testenv/browser.js @@ -0,0 +1,8 @@ +import './modules/global.js' +import './modules/process.js' +import './modules/expect.js' +import './modules/mocks.js' +import './modules/timers.js' +import './modules/testinglibrary.js' +import './modules/inlineSnapshot.js' +import './modules/console.js' diff --git a/testenv/jest.js b/testenv/jest.js new file mode 100644 index 00000000..49d18467 --- /dev/null +++ b/testenv/jest.js @@ -0,0 +1,12 @@ +import './modules/mocks.js' +import './modules/timers.js' +import './modules/testinglibrary.js' +import './modules/console.js' + +import { toMatchInlineSnapshot } from 'jest-snapshot' + +expect.extend({ + toMatchInlineSnapshot: function(actual, ...args) { + return toMatchInlineSnapshot.call(this, actual?.snapshot ?? actual, ...args) + } +}) diff --git a/testenv/libs/dom8/index.js b/testenv/libs/dom8/index.js new file mode 100644 index 00000000..8c83a310 --- /dev/null +++ b/testenv/libs/dom8/index.js @@ -0,0 +1,3 @@ +import * as DomTestingLibrary from '@testing-library/dom' + +globalThis.DomTestingLibrary = DomTestingLibrary diff --git a/testenv/libs/dom8/package.json b/testenv/libs/dom8/package.json new file mode 100644 index 00000000..1e7f7c83 --- /dev/null +++ b/testenv/libs/dom8/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "dependencies": { + "@testing-library/dom": "^8" + } +} \ No newline at end of file diff --git a/testenv/libs/react17/index.js b/testenv/libs/react17/index.js new file mode 100644 index 00000000..85cf326f --- /dev/null +++ b/testenv/libs/react17/index.js @@ -0,0 +1,7 @@ +import * as React from 'react' +import * as ReactDom from 'react-dom' +import * as ReactTestingLibrary from '@testing-library/react' + +globalThis.React = React +globalThis.ReactDOM = ReactDom +globalThis.ReactTestingLibrary = ReactTestingLibrary diff --git a/testenv/libs/react17/package.json b/testenv/libs/react17/package.json new file mode 100644 index 00000000..ee7a1398 --- /dev/null +++ b/testenv/libs/react17/package.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "dependencies": { + "@testing-library/react": "^12", + "react": "^17", + "react-dom": "^17" + }, + "globals": { + "@testing-library/dom": "DomTestingLibrary" + } +} diff --git a/testenv/libs/react18/index.js b/testenv/libs/react18/index.js new file mode 100644 index 00000000..85cf326f --- /dev/null +++ b/testenv/libs/react18/index.js @@ -0,0 +1,7 @@ +import * as React from 'react' +import * as ReactDom from 'react-dom' +import * as ReactTestingLibrary from '@testing-library/react' + +globalThis.React = React +globalThis.ReactDOM = ReactDom +globalThis.ReactTestingLibrary = ReactTestingLibrary diff --git a/testenv/libs/react18/package.json b/testenv/libs/react18/package.json new file mode 100644 index 00000000..85e226f6 --- /dev/null +++ b/testenv/libs/react18/package.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "dependencies": { + "@testing-library/react": "^13", + "react": "^18", + "react-dom": "^18" + }, + "globals": { + "@testing-library/dom": "DomTestingLibrary" + } +} diff --git a/testenv/modules/console.js b/testenv/modules/console.js new file mode 100644 index 00000000..d8468502 --- /dev/null +++ b/testenv/modules/console.js @@ -0,0 +1,30 @@ +const isCI = !!process.env.CI + +beforeEach(() => { + mocks.spyOn(console, 'error') + mocks.spyOn(console, 'log') + mocks.spyOn(console, 'warn') + mocks.spyOn(console, 'info') +}) + +afterEach(() => { + if (isCI && console.error.mock.calls.length) { + throw new Error(`console.error should not be called in tests`) + } + console.error.mockRestore() + + if (isCI && console.log.mock.calls.length) { + throw new Error(`console.log should not be called in tests`) + } + console.log.mockRestore() + + if (isCI && console.warn.mock.calls.length) { + throw new Error(`console.warn should not be called in tests`) + } + console.warn.mockRestore() + + if (isCI && console.info.mock.calls.length) { + throw new Error(`console.info should not be called in tests`) + } + console.info.mockRestore() +}) diff --git a/testenv/modules/expect.js b/testenv/modules/expect.js new file mode 100644 index 00000000..3c0ddf04 --- /dev/null +++ b/testenv/modules/expect.js @@ -0,0 +1,3 @@ +import { expect } from 'expect' + +globalThis.expect = expect diff --git a/testenv/modules/fakeTimers.js b/testenv/modules/fakeTimers.js new file mode 100644 index 00000000..91502abc --- /dev/null +++ b/testenv/modules/fakeTimers.js @@ -0,0 +1,104 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { withGlobal, } from '@sinonjs/fake-timers'; +var FakeTimers = /** @class */ (function () { + function FakeTimers(context) { + if (context === void 0) { context = globalThis; } + this.fakeTimers = withGlobal(context); + } + FakeTimers.prototype.clearAllTimers = function () { + var _a; + (_a = this.clock) === null || _a === void 0 ? void 0 : _a.reset(); + }; + FakeTimers.prototype.dispose = function () { + this.useRealTimers(); + }; + FakeTimers.prototype.runAllTimers = function () { + var _a; + (_a = this.clock) === null || _a === void 0 ? void 0 : _a.runAll(); + }; + FakeTimers.prototype.runOnlyPendingTimers = function () { + var _a; + (_a = this.clock) === null || _a === void 0 ? void 0 : _a.runToLast(); + }; + FakeTimers.prototype.advanceTimersToNextTimer = function (steps) { + if (steps === void 0) { steps = 1; } + this.assertFakeTimers(); + for (var i = steps; i > 0; i--) { + this.clock.next(); + // Fire all timers at this point: https://github.com/sinonjs/fake-timers/issues/250 + this.clock.tick(0); + if (this.clock.countTimers() === 0) { + break; + } + } + }; + FakeTimers.prototype.advanceTimersByTime = function (msToRun) { + this.assertFakeTimers(); + this.clock.tick(msToRun); + }; + FakeTimers.prototype.runAllTicks = function () { + this.assertFakeTimers(); + // @ts-expect-error - doesn't exist? + this.clock.runMicrotasks(); + }; + FakeTimers.prototype.useRealTimers = function () { + var _a; + (_a = this.clock) === null || _a === void 0 ? void 0 : _a.uninstall(); + delete this.clock; + }; + FakeTimers.prototype.useFakeTimers = function (fakeTimersConfig) { + if (this.clock) { + this.clock.uninstall(); + delete this.clock; + } + this.clock = this.fakeTimers.install(fakeTimersConfig); + }; + FakeTimers.prototype.reset = function () { + this.assertFakeTimers(); + var now = this.clock.now; + this.clock.reset(); + this.clock.setSystemTime(now); + }; + FakeTimers.prototype.setSystemTime = function (now) { + this.assertFakeTimers(); + this.clock.setSystemTime(now); + }; + FakeTimers.prototype.getRealSystemTime = function () { + return Date.now(); + }; + FakeTimers.prototype.now = function () { + var _a, _b; + return (_b = (_a = this.clock) === null || _a === void 0 ? void 0 : _a.now) !== null && _b !== void 0 ? _b : Date.now(); + }; + FakeTimers.prototype.getTimerCount = function () { + this.assertFakeTimers(); + return this.clock.countTimers(); + }; + FakeTimers.prototype.assertFakeTimers = function () { + if (!this.clock) { + throw new Error('A function that relies on fake timers was called, but the timers APIs are not replaced with fake timers.'); + } + }; + return FakeTimers; +}()); +export { FakeTimers }; diff --git a/testenv/modules/fakeTimers.ts b/testenv/modules/fakeTimers.ts new file mode 100644 index 00000000..0f2cf961 --- /dev/null +++ b/testenv/modules/fakeTimers.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + FakeTimerWithContext, + InstalledClock, + FakeTimerInstallOpts, + withGlobal, +} from '@sinonjs/fake-timers' + +export class FakeTimers { + protected readonly fakeTimers: FakeTimerWithContext + protected clock?: InstalledClock + + constructor( + context: typeof globalThis = globalThis, + ) { + this.fakeTimers = withGlobal(context) + } + + clearAllTimers(): void { + this.clock?.reset() + } + + dispose(): void { + this.useRealTimers() + } + + runAllTimers(): void { + this.clock?.runAll() + } + + runOnlyPendingTimers(): void { + this.clock?.runToLast() + } + + advanceTimersToNextTimer(steps = 1): void { + this.assertFakeTimers() + for (let i = steps; i > 0; i--) { + this.clock!.next() + // Fire all timers at this point: https://github.com/sinonjs/fake-timers/issues/250 + this.clock!.tick(0) + + if (this.clock!.countTimers() === 0) { + break + } + } + } + + advanceTimersByTime(msToRun: number): void { + this.assertFakeTimers() + this.clock!.tick(msToRun) + } + + runAllTicks(): void { + this.assertFakeTimers() + // @ts-expect-error - doesn't exist? + this.clock!.runMicrotasks() + } + + useRealTimers(): void { + this.clock?.uninstall() + delete this.clock + } + + useFakeTimers(fakeTimersConfig?: FakeTimerInstallOpts): void { + if (this.clock) { + this.clock.uninstall() + delete this.clock + } + + this.clock = this.fakeTimers.install(fakeTimersConfig) + } + + reset(): void { + this.assertFakeTimers() + const now = this.clock!.now + this.clock!.reset() + this.clock!.setSystemTime(now) + } + + setSystemTime(now?: number | Date): void { + this.assertFakeTimers() + this.clock!.setSystemTime(now) + } + + getRealSystemTime(): number { + return Date.now() + } + + now(): number { + return this.clock?.now ?? Date.now() + } + + getTimerCount(): number { + this.assertFakeTimers() + return this.clock!.countTimers() + } + + protected assertFakeTimers() { + if (!this.clock) { + throw new Error('A function that relies on fake timers was called, but the timers APIs are not replaced with fake timers.') + } + } +} diff --git a/testenv/modules/global.js b/testenv/modules/global.js new file mode 100644 index 00000000..ef104a5f --- /dev/null +++ b/testenv/modules/global.js @@ -0,0 +1 @@ +globalThis.global = globalThis diff --git a/testenv/modules/inlineSnapshot.js b/testenv/modules/inlineSnapshot.js new file mode 100644 index 00000000..23bb09d3 --- /dev/null +++ b/testenv/modules/inlineSnapshot.js @@ -0,0 +1,110 @@ +expect.extend({ + toMatchInlineSnapshot, + toThrowErrorMatchingInlineSnapshot, +}) + +export function toMatchInlineSnapshot( + actual, + expected, +) { + const normalizedActual = stripAddedLinebreaks(stripAddedIndentation(actual.snapshot ?? actual)) + const normalizedExpected = stripAddedLinebreaks(stripAddedIndentation(expected)) + + return { + pass: normalizedActual === normalizedExpected, + message: () => [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toMatchInlineSnapshot`, + 'element', + '', + ), + '', + `Expected: ${this.isNot ? 'not ' : ''}${this.promise}`, + ` ${this.utils.printExpected(normalizedExpected)}`, + 'Received:', + ` ${this.utils.printReceived(normalizedActual)}`, + ].join('\n'), + } +} + +export function toThrowErrorMatchingInlineSnapshot( + callback, + expected, +) { + let didThrow = false, actual = undefined + try { + callback() + } catch (e) { + didThrow = true + actual = e + } + + const normalizedActual = didThrow && stripAddedLinebreaks(stripAddedIndentation(typeof actual === 'object' && 'message' in actual ? actual.message : String(actual))) + const normalizedExpected = stripAddedLinebreaks(stripAddedIndentation(expected)) + + return { + pass: this.isNot === !didThrow && normalizedActual === normalizedExpected, + message: () => [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toThrowErrorMatchingInlineSnapshot`, + 'callback', + '', + ), + '', + `Expected: ${this.isNot ? 'not ' : ''}${this.promise}`, + ` ${this.utils.printExpected(normalizedExpected)}`, + 'Received:', + ` ${didThrow ? this.utils.printReceived(normalizedActual) : '[Did not throw]'}`, + ].join('\n'), + } +} + +function stripAddedLinebreaks(normalizedSnapshot) { + return normalizedSnapshot.startsWith('\n') && normalizedSnapshot.endsWith('\n') + ? normalizedSnapshot.substring(1, normalizedSnapshot.length - 1) + : normalizedSnapshot +} + +const INDENTATION_REGEX = /^([^\S\n]*)\S/m; + +function stripAddedIndentation(inlineSnapshot) { + // Find indentation if exists. + const match = inlineSnapshot.match(INDENTATION_REGEX) + if (!match || !match[1]) { + // No indentation. + return inlineSnapshot + } + + const indentation = match[1] + const lines = inlineSnapshot.split('\n') + if (lines.length <= 2) { + // Must be at least 3 lines. + return inlineSnapshot + } + + if (lines[0].trim() !== '' || lines[lines.length - 1].trim() !== '') { + // If not blank first and last lines, abort. + return inlineSnapshot + } + + for (let i = 1; i < lines.length - 1; i++) { + if (lines[i] !== '') { + if (lines[i].indexOf(indentation) !== 0) { + // All lines except first and last should either be blank or have the same + // indent as the first line (or more). If this isn't the case we don't + // want to touch the snapshot at all. + return inlineSnapshot + } + + lines[i] = lines[i].substring(indentation.length) + } + } + + // Last line is a special case because it won't have the same indent as others + // but may still have been given some indent to line up. + lines[lines.length - 1] = '' + + // Return inline snapshot, now at indent 0. + inlineSnapshot = lines.join('\n') + return inlineSnapshot +} diff --git a/testenv/modules/mocks.js b/testenv/modules/mocks.js new file mode 100644 index 00000000..5449ce55 --- /dev/null +++ b/testenv/modules/mocks.js @@ -0,0 +1,4 @@ +import { ModuleMocker } from 'jest-mock' + +const mocks = new ModuleMocker(globalThis) +globalThis.mocks = mocks diff --git a/testenv/modules/process.js b/testenv/modules/process.js new file mode 100644 index 00000000..ef264f8f --- /dev/null +++ b/testenv/modules/process.js @@ -0,0 +1,6 @@ +import process from 'process' + +globalThis.process = process +process.stdout = {} +process.stdin = {} +process.stderr = {} diff --git a/testenv/modules/testinglibrary.js b/testenv/modules/testinglibrary.js new file mode 100644 index 00000000..de45f073 --- /dev/null +++ b/testenv/modules/testinglibrary.js @@ -0,0 +1,3 @@ +import TestingLibraryMatchersDefault, * as TestingLibraryMatchersNamed from '@testing-library/jest-dom/matchers.js' + +expect.extend(TestingLibraryMatchersDefault ?? TestingLibraryMatchersNamed) diff --git a/testenv/modules/timers.js b/testenv/modules/timers.js new file mode 100644 index 00000000..808da59b --- /dev/null +++ b/testenv/modules/timers.js @@ -0,0 +1,4 @@ +import { FakeTimers } from './fakeTimers.js' + +const timers = new FakeTimers() +globalThis.timers = timers diff --git a/testenv/node.js b/testenv/node.js new file mode 100644 index 00000000..2b0eb766 --- /dev/null +++ b/testenv/node.js @@ -0,0 +1,38 @@ +import './modules/expect.js' +import './modules/mocks.js' +import './modules/timers.js' +import './modules/testinglibrary.js' +import './modules/console.js' + +import 'css.escape' +import jestSnapshot from 'jest-snapshot' +import {JSDOM} from 'jsdom' + +expect.setState({ + snapshotState: new jestSnapshot.SnapshotState('tests/__snapshot__/', { + updateSnapshot: 'none', + }) +}) +expect.extend({ + toMatchInlineSnapshot: jestSnapshot.toMatchInlineSnapshot, + toMatchSnapshot: jestSnapshot.toMatchSnapshot, + toThrowErrorMatchingInlineSnapshot: jestSnapshot.toThrowErrorMatchingInlineSnapshot, + toThrowErrorMatchingSnapshot: jestSnapshot.toThrowErrorMatchingSnapshot, +}) + +const jsdom = new JSDOM() +globalThis.navigator = jsdom.window.navigator +globalThis.window = jsdom.window +globalThis.document = jsdom.window.document + +globalThis.window.CSS = { + escape: global.CSS.escape, +} + +globalThis.XPathResult = jsdom.window.XPathResult +globalThis.File = jsdom.window.File +globalThis.DOMParser = jsdom.window.DOMParser +globalThis.Blob = jsdom.window.Blob +globalThis.FileReader = jsdom.window.FileReader +globalThis.FileList = jsdom.window.FileList +globalThis.customElements = jsdom.window.customElements diff --git a/testenv/package.json b/testenv/package.json new file mode 100644 index 00000000..6990891f --- /dev/null +++ b/testenv/package.json @@ -0,0 +1 @@ +{"type": "module"} diff --git a/testenv/types/index.d.ts b/testenv/types/index.d.ts new file mode 100644 index 00000000..ac4e3942 --- /dev/null +++ b/testenv/types/index.d.ts @@ -0,0 +1,29 @@ +import type { Expect } from 'expect' +import type { ModuleMocker } from 'jest-mock' +import type { SnapshotMatchers } from 'jest-snapshot' +import type { FakeTimers } from '../modules/fakeTimers' +import type { TestContext } from '@ph.fritsche/toolbox' + +type M = import('@testing-library/jest-dom/matchers').TestingLibraryMatchers + +declare module 'expect' { + export interface Matchers extends + M, + SnapshotMatchers + {} +} + +declare global { + declare var expect: Expect + + declare var mocks: ModuleMocker + + declare var timers: FakeTimers + + declare var describe: TestContext['describe'] + declare var test: TestContext['test'] + declare var beforeAll: TestContext['beforeAll'] + declare var beforeEach: TestContext['beforeEach'] + declare var afterAll: TestContext['afterAll'] + declare var afterEach: TestContext['afterEach'] +} diff --git a/tests/_helpers/index.ts b/tests/_helpers/index.ts index a4c933c1..13c9b71c 100644 --- a/tests/_helpers/index.ts +++ b/tests/_helpers/index.ts @@ -1,15 +1,5 @@ // this is pretty helpful: // https://codesandbox.io/s/quizzical-worker-eo909 -expect.addSnapshotSerializer({ - test: (val: unknown) => - Boolean( - typeof val === 'object' - ? Object.prototype.hasOwnProperty.call(val, 'snapshot') - : false, - ), - print: val => String((val)?.snapshot), -}) - export {render, setup} from './setup' export {addEventListener, addListeners} from './listeners' diff --git a/tests/_helpers/listeners.ts b/tests/_helpers/listeners.ts index 96715e8b..8b530139 100644 --- a/tests/_helpers/listeners.ts +++ b/tests/_helpers/listeners.ts @@ -1,4 +1,3 @@ -import {TestData, TestDataProps} from './trackProps' import {eventMapKeys} from '#src/event/eventMap' import {isElementType} from '#src/utils' import {MouseButton, MouseButtonFlip} from '#src/system/pointer/buttons' @@ -36,7 +35,7 @@ export type EventHandlers = {[k in keyof DocumentEventMap]?: EventListener} * Add listeners for logging events. */ export function addListeners( - element: Element & {testData?: TestData}, + element: Element, { eventHandlers = {}, }: { @@ -46,11 +45,10 @@ export function addListeners( type CallData = { event: Event elementDisplayName: string - testData?: TestData } let eventHandlerCalls: CallData[] = [] - const generalListener = jest.fn(eventHandler).mockName('eventListener') + const generalListener = mocks.fn(eventHandler).mockName('eventListener') for (const eventType of Object.keys(eventMapKeys) as Array< keyof typeof eventMapKeys @@ -78,14 +76,6 @@ export function addListeners( elementDisplayName: target && isElement(target) ? getElementDisplayName(target) : '', } - if (element.testData && !element.testData.handled) { - callData.testData = element.testData - // sometimes firing a single event (like click on a checkbox) will - // automatically fire more events (line input and change). - // and we don't want the test data applied to those, so we'll store - // this and not add the testData to our call if that was already handled - element.testData.handled = true - } eventHandlerCalls.push(callData) } @@ -119,7 +109,7 @@ export function addListeners( function getEventSnapshot() { const eventCalls = eventHandlerCalls - .map(({event, testData, elementDisplayName}) => { + .map(({event, elementDisplayName}) => { const firstLine = [ `${elementDisplayName} - ${event.type}`, [getEventLabel(event), getEventModifiers(event)] @@ -129,7 +119,7 @@ export function addListeners( .filter(Boolean) .join(': ') - return [firstLine, getChanges(testData)].filter(Boolean).join('\n') + return firstLine }) .join('\n') .trim() @@ -187,10 +177,10 @@ function getElementDisplayName(element: Element) { displayName.push(`#${element.id}`) } if (hasProperty(element, 'name') && element.name) { - displayName.push(`[name="${element.name}"]`) + displayName.push(`[name="${String(element.name)}"]`) } if (hasProperty(element, 'htmlFor') && element.htmlFor) { - displayName.push(`[for="${element.htmlFor}"]`) + displayName.push(`[for="${String(element.htmlFor)}"]`) } if ( isElementType(element, 'input') && @@ -261,51 +251,3 @@ function getMouseButtonName(button: number) { k => MouseButton[k as keyof typeof MouseButton] === button, ) } - -function getChanges({before, after}: TestData = {}) { - const changes = new Set() - if (before && after) { - for (const key of Object.keys(before) as Array) { - if (after[key] !== before[key]) { - if (key === 'checked') { - changes.add( - [ - before.checked ? 'checked' : 'unchecked', - after.checked ? 'checked' : 'unchecked', - ].join(' -> '), - ) - } else { - changes.add( - [ - JSON.stringify(getValueWithSelection(before)), - JSON.stringify(getValueWithSelection(after)), - ].join(' -> '), - ) - } - } - } - } - - return Array.from(changes) - .filter(Boolean) - .map(line => ` ${line}`) - .join('\n') -} - -function getValueWithSelection({ - value, - selectionStart, - selectionEnd, -}: TestDataProps = {}) { - return [ - value?.slice(0, selectionStart ?? undefined), - ...(selectionStart === selectionEnd - ? ['{CURSOR}'] - : [ - '{SELECTION}', - value?.slice(selectionStart ?? 0, selectionEnd ?? undefined), - '{/SELECTION}', - ]), - value?.slice(selectionEnd ?? undefined), - ].join('') -} diff --git a/tests/_helpers/trackProps.ts b/tests/_helpers/trackProps.ts deleted file mode 100644 index 91e76566..00000000 --- a/tests/_helpers/trackProps.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {wrapEvent} from '#src/event/wrapEvent' - -declare global { - interface Element { - testData?: TestData - } -} - -export type TestDataProps = { - value?: string - checked?: boolean - selectionStart?: number | null - selectionEnd?: number | null -} - -export type TestData = { - handled?: boolean - - // Where is this assigned? - before?: TestDataProps - after?: TestDataProps -} - -jest.mock('#src/event/wrapEvent', () => ({ - wrapEvent(...[cb, element]: Parameters) { - const before = getTrackedElementValues(element as TestDataProps) - const testData = {before} - - // put it on the element so the event handler can grab it - element.testData = testData - - const result = jest - .requireActual<{ - wrapEvent: typeof wrapEvent - }>('#src/event/wrapEvent') - .wrapEvent(cb, element) - - const after = getTrackedElementValues(element as TestDataProps) - Object.assign(testData, {after}) - - // elete the testData for the next event - delete element.testData - return result - - function getTrackedElementValues(el: TestDataProps): TestDataProps { - return { - value: el.value, - checked: el.checked, - selectionStart: el.selectionStart, - selectionEnd: el.selectionEnd, - - // unfortunately, changing a select option doesn't happen within fireEvent - // but rather imperatively via `options.selected = newValue` - // because of this we don't (currently) have a way to track before/after - // in a given fireEvent call. - } - } - }, -})) diff --git a/tests/_setup-env.js b/tests/_setup-env.js deleted file mode 100644 index 48498e38..00000000 --- a/tests/_setup-env.js +++ /dev/null @@ -1,41 +0,0 @@ -import '@testing-library/jest-dom/extend-expect' -import isCI from 'is-ci' -import jestSerializerAnsi from 'jest-serializer-ansi' -import './_helpers/trackProps' - -expect.addSnapshotSerializer(jestSerializerAnsi) - -// prevent console calls from making it out into the wild -beforeEach(() => { - jest.spyOn(console, 'error') - jest.spyOn(console, 'log') - jest.spyOn(console, 'warn') - jest.spyOn(console, 'info') -}) - -// but we only assert in CI because it's annoying locally during development -afterEach(() => { - if (isCI && console.error.mock.calls.length) { - throw new Error(`console.error should not be called in tests`) - } - console.error.mockRestore() - - if (isCI && console.log.mock.calls.length) { - throw new Error(`console.log should not be called in tests`) - } - console.log.mockRestore() - - if (isCI && console.warn.mock.calls.length) { - throw new Error(`console.warn should not be called in tests`) - } - console.warn.mockRestore() - - if (isCI && console.info.mock.calls.length) { - throw new Error(`console.info should not be called in tests`) - } - console.info.mockRestore() -}) -/* -eslint - no-console: "off", -*/ diff --git a/tests/document/index.ts b/tests/document/index.ts index 00c5f2f5..2ca18b93 100644 --- a/tests/document/index.ts +++ b/tests/document/index.ts @@ -209,8 +209,8 @@ test('circumvent setters/methods for UI changes', () => { const prototypeDescr = Object.getOwnPropertyDescriptors( Object.getPrototypeOf(element) as HTMLInputElement, ) - const valueSpy = jest.fn(prototypeDescr.value.set) - const setSelectionRangeSpy = jest.fn(prototypeDescr.setSelectionRange.value) + const valueSpy = mocks.fn(prototypeDescr.value.set) + const setSelectionRangeSpy = mocks.fn(prototypeDescr.setSelectionRange.value) Object.defineProperties(element, { value: { diff --git a/tests/dom/customElement.ts b/tests/dom/customElement.ts index 37422449..beafce69 100644 --- a/tests/dom/customElement.ts +++ b/tests/dom/customElement.ts @@ -5,6 +5,7 @@ import {addListeners} from '#testHelpers' // Can this be removed? Is it sufficient? const observed = ['value'] +const HTMLElement = window.HTMLElement class CustomEl extends HTMLElement { private $input: HTMLInputElement diff --git a/tests/event/dispatchEvent.ts b/tests/event/dispatchEvent.ts index fd5bfa72..b76662ce 100644 --- a/tests/event/dispatchEvent.ts +++ b/tests/event/dispatchEvent.ts @@ -1,19 +1,11 @@ -import {behavior, BehaviorPlugin} from '#src/event/behavior' +import {behavior} from '#src/event/behavior' import {createConfig, createInstance} from '#src/setup/setup' import {render} from '#testHelpers' -jest.mock('#src/event/behavior', () => ({ - behavior: { - click: jest.fn(), - }, -})) - -const mockPlugin = behavior.click as jest.MockedFunction< - BehaviorPlugin<'click'> -> +const mockPlugin = mocks.spyOn(behavior as Required, 'click').mockImplementation(() => void 0) afterEach(() => { - jest.clearAllMocks() + mockPlugin.mockClear() }) function setupInstance() { @@ -32,7 +24,7 @@ test('keep default behavior', () => { test('replace default behavior', () => { const {element} = render(``) - const mockBehavior = jest.fn() + const mockBehavior = mocks.fn(() => void 0) mockPlugin.mockImplementationOnce(() => mockBehavior) setupInstance().dispatchUIEvent(element, 'click') @@ -50,7 +42,7 @@ test('prevent replaced default behavior', () => { expect(e).toHaveProperty('defaultPrevented', true) }) - const mockBehavior = jest.fn() + const mockBehavior = mocks.fn(() => void 0) mockPlugin.mockImplementationOnce(() => mockBehavior) setupInstance().dispatchUIEvent(element, 'click') diff --git a/tests/jest.d.ts b/tests/jest.d.ts deleted file mode 100644 index e0c35325..00000000 --- a/tests/jest.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare namespace jest { - // eslint-disable-next-line - export interface MockContext { - // mock.lastCall has been introduced in jest@27.5 but is still missing in @types/jest - lastCall: Y | undefined - } -} diff --git a/tests/keyboard/index.ts b/tests/keyboard/index.ts index af482eaf..32ec1133 100644 --- a/tests/keyboard/index.ts +++ b/tests/keyboard/index.ts @@ -1,7 +1,7 @@ import userEvent from '#src' import {addListeners, render, setup} from '#testHelpers' -it('type without focus', async () => { +test('type without focus', async () => { const {element, user} = setup('', {focus: false}) const {getEventSnapshot} = addListeners(document.body) @@ -23,7 +23,7 @@ it('type without focus', async () => { `) }) -it('type with focus', async () => { +test('type with focus', async () => { const {element, user, getEventSnapshot} = setup('') await user.keyboard('foo') @@ -50,7 +50,7 @@ it('type with focus', async () => { `) }) -it('type asynchronous', async () => { +test('type asynchronous', async () => { const {element, user, getEventSnapshot} = setup('', {delay: 1}) await user.keyboard('foo') @@ -77,14 +77,14 @@ it('type asynchronous', async () => { `) }) -it('error in async', async () => { +test('error in async', async () => { const {user} = setup('') await expect(user.keyboard('[!')).rejects.toThrowError( 'Expected key descriptor but found "!" in "[!"', ) }) -it('continue typing with state', async () => { +test('continue typing with state', async () => { const {getEventSnapshot, clearEventCalls} = render('') const state = await userEvent.keyboard('[ShiftRight>]') @@ -111,7 +111,7 @@ it('continue typing with state', async () => { }) describe('delay', () => { - const spy = jest.spyOn(global, 'setTimeout') + const spy = mocks.spyOn(global, 'setTimeout') beforeEach(() => { spy.mockClear() diff --git a/tests/keyboard/keyboardAction.ts b/tests/keyboard/keyboardAction.ts index 12dbd95c..3d4ccf55 100644 --- a/tests/keyboard/keyboardAction.ts +++ b/tests/keyboard/keyboardAction.ts @@ -47,11 +47,11 @@ test('do not leak repeatKey in state', async () => { render(``) const keyboardState = await userEvent.keyboard('{a>2}') - expect(keyboardState).toHaveProperty('repeatKey', undefined) + expect(keyboardState).not.toHaveProperty('repeatKey') }) describe('pressing and releasing keys', () => { - it('fires event with releasing key twice', async () => { + test('fires event with releasing key twice', async () => { const {getEventSnapshot, user} = setup(``) await user.keyboard('{ArrowLeft>}{ArrowLeft}') @@ -66,7 +66,7 @@ describe('pressing and releasing keys', () => { `) }) - it('fires event without releasing key', async () => { + test('fires event without releasing key', async () => { const {getEventSnapshot, user} = setup(``) await user.keyboard('{a>}') @@ -81,7 +81,7 @@ describe('pressing and releasing keys', () => { `) }) - it('fires event multiple times without releasing key', async () => { + test('fires event multiple times without releasing key', async () => { const {getEventSnapshot, user} = setup(``) await user.keyboard('{a>2}') @@ -100,7 +100,7 @@ describe('pressing and releasing keys', () => { `) }) - it('fires event multiple times and releases key', async () => { + test('fires event multiple times and releases key', async () => { const {getEventSnapshot, user} = setup(``) await user.keyboard('{a>2/}') @@ -120,7 +120,7 @@ describe('pressing and releasing keys', () => { `) }) - it('fires event multiple times for multiple keys', async () => { + test('fires event multiple times for multiple keys', async () => { const {getEventSnapshot, user} = setup(``) await user.keyboard('{a>2}{b>2/}{c>2}{/a}') @@ -182,7 +182,7 @@ describe('prevent default behavior', () => { test('do not call setTimeout with delay `null`', async () => { const {user} = setup(`

`) - const spy = jest.spyOn(global, 'setTimeout') + const spy = mocks.spyOn(global, 'setTimeout') await user.keyboard('ab') expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1) diff --git a/tests/pointer/click.ts b/tests/pointer/click.ts index 88dc8452..da3dab61 100644 --- a/tests/pointer/click.ts +++ b/tests/pointer/click.ts @@ -179,7 +179,7 @@ test('double click per touch device', async () => { expect(getEvents('click')[0]).toHaveProperty('pointerId', 2) expect(getEvents('click')[1]).toHaveProperty('pointerId', 3) expect(getEvents('dblclick')).toHaveLength(1) - expect(getEvents('dblclick')[0]).toHaveProperty('pointerId', undefined) + expect(getEvents('dblclick')[0]).not.toHaveProperty('pointerId') }) test('multi touch does not click', async () => { diff --git a/tests/pointer/index.ts b/tests/pointer/index.ts index 95d68ad5..de6afb6f 100644 --- a/tests/pointer/index.ts +++ b/tests/pointer/index.ts @@ -1,3 +1,4 @@ +import type { SpyInstance } from 'jest-mock' import {PointerEventsCheckLevel} from '#src' import {setup} from '#testHelpers' @@ -57,7 +58,7 @@ test('apply modifiers from keyboardstate', async () => { }) describe('delay', () => { - const spy = jest.spyOn(global, 'setTimeout') + const spy = mocks.spyOn(global, 'setTimeout') beforeEach(() => { spy.mockClear() @@ -123,13 +124,9 @@ test('no mousedown/mouseup on disabled elements', async () => { }) describe('check for pointer-events', () => { - let getComputedStyle: jest.SpyInstance< - ReturnType, - Parameters - > + let getComputedStyle: SpyInstance beforeAll(() => { - getComputedStyle = jest - .spyOn(window, 'getComputedStyle') + getComputedStyle = mocks.spyOn(window, 'getComputedStyle') .mockImplementation( () => ({ @@ -145,7 +142,7 @@ describe('check for pointer-events', () => { ) }) afterAll(() => { - jest.restoreAllMocks() + mocks.restoreAllMocks() }) test('skip check', async () => { @@ -240,7 +237,7 @@ test('reject if target has `pointer-events: none`', async () => { test('omit pointer events on previous target if it has `pointer-events: none`', async () => { const {element, user} = setup(``) - const onPointerLeave = jest.fn() + const onPointerLeave = mocks.fn() element.addEventListener('pointerleave', onPointerLeave) await user.pointer({target: element}) diff --git a/tests/react/17.tsx b/tests/react/17.tsx deleted file mode 100644 index 7ed0e4cd..00000000 --- a/tests/react/17.tsx +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @jest-environment ./tests/react/_env/react17.js - */ - -import './index' diff --git a/tests/react/_env/react17.js b/tests/react/_env/react17.js deleted file mode 100644 index ae933137..00000000 --- a/tests/react/_env/react17.js +++ /dev/null @@ -1,9 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -const jsdomEnv = require('jest-environment-jsdom') - -module.exports = class React17 extends jsdomEnv { - async setup() { - await super.setup() - this.global.REACT_VERSION = 17 - } -} diff --git a/tests/react/_env/setup-env.js b/tests/react/_env/setup-env.js deleted file mode 100644 index 308baa70..00000000 --- a/tests/react/_env/setup-env.js +++ /dev/null @@ -1,15 +0,0 @@ -if (global.REACT_VERSION) { - jest.mock('@testing-library/react', () => - jest.requireActual(`reactTesting${global.REACT_VERSION}`), - ) - jest.mock('react', () => jest.requireActual(`react${global.REACT_VERSION}`)) - jest.mock('react-dom', () => - jest.requireActual(`reactDom${global.REACT_VERSION}`), - ) - jest.mock('react-dom/test-utils', () => - jest.requireActual(`reactDom${global.REACT_VERSION}/test-utils`), - ) - jest.mock('react-is', () => - jest.requireActual(`reactIs${global.REACT_VERSION}`), - ) -} diff --git a/tests/react/index.tsx b/tests/react/index.tsx index 539773e6..0ba2e7b0 100644 --- a/tests/react/index.tsx +++ b/tests/react/index.tsx @@ -24,7 +24,7 @@ test.each([0, 1])('maintain cursor position on controlled input', async () => { }) test('trigger Synthetic `keypress` event for printable characters', async () => { - const onKeyPress = jest.fn() + const onKeyPress = mocks.fn() render() const user = userEvent.setup() screen.getByRole('textbox').focus() @@ -106,8 +106,8 @@ test('detect value and selection change', async () => { }) test('trigger onChange SyntheticEvent on input', async () => { - const inputHandler = jest.fn() - const changeHandler = jest.fn() + const inputHandler = mocks.fn() + const changeHandler = mocks.fn() render() const user = userEvent.setup() diff --git a/tests/setup/_mockApis.ts b/tests/setup/_mockApis.ts index 88791af1..a7c60428 100644 --- a/tests/setup/_mockApis.ts +++ b/tests/setup/_mockApis.ts @@ -1,84 +1,81 @@ +import type {MockedFunction, MockInstance} from 'jest-mock' import type {Instance, UserEventApi} from '#src/setup/setup' - -// The following hacky mocking allows us to spy on imported API functions. -// This way we can test assertions on the wiring of arguments without repeating tests of each API implementation. +import { userEventApi } from '#src/setup/api' // `const` are not initialized when mocking is executed, but `function` are when prefixed with `mock` -function mockApis() {} -// access the `function` as object -type mockApisRefHack = (() => void) & { +const mockApis = {} as { [name in keyof UserEventApi]: { - mock: APIMock + mock: APIMock real: UserEventApi[name] } } -// make the tests more readable by applying the typecast here -export function getSpy(k: keyof UserEventApi) { - return (mockApis as mockApisRefHack)[k].mock +export function getSpy(k: K) { + return mockApis[k].mock } -export function getReal(k: keyof UserEventApi) { - return (mockApis as mockApisRefHack)[k].real +export function getReal(k: K) { + return mockApis[k].real } -interface APIMock - extends Function, - jest.MockInstance< - ReturnType, - Parameters & { - this?: Instance - } - > { - ( - this: Instance, - ...args: Parameters - ): ReturnType +type APIMock = UserEventApi[name] & MockInstance & { originalMockImplementation: ( this: Instance, ...args: Parameters ) => ReturnType + mock: { + lastCall?: { + this: Instance + } + calls: {this: Instance}[] + } } -jest.mock('#src/setup/api', () => { - const real: UserEventApi & {__esModule: true} = - jest.requireActual('#src/setup/api') - const fake = {} as { - [K in keyof UserEventApi]: jest.MockedFunction +const real = { + ...userEventApi, +} +const fake = {} as { + [K in keyof UserEventApi]: MockedFunction +} +;(Object.keys(userEventApi) as Array).forEach(key => { + const mock = mocks.fn(mockImpl) + function mockImpl(this: Instance, ...args: unknown[]) { + Object.defineProperty(mock.mock.lastCall, 'this', { + get: () => this, + configurable: true, + }) + return (real[key] as Function).apply(this, args) } + Object.defineProperty(mock, 'originalMockImplementation', { + get: () => mockImpl, + configurable: true, + }) - ;(Object.keys(real) as Array).forEach(key => { - const mock = jest.fn(mockImpl) as unknown as APIMock - function mockImpl(this: Instance, ...args: unknown[]) { - Object.defineProperty(mock.mock.lastCall, 'this', { - get: () => this, - }) - return (real[key] as Function).apply(this, args) - } - mock.originalMockImplementation = mockImpl - - Object.defineProperty(mock, 'name', { - get: () => `mock-${key}`, - }) + Object.defineProperty(mock, 'name', { + get: () => `mock-${key}`, + configurable: true, + }) - Object.defineProperty(fake, key, { - get: () => mock, - enumerable: true, - }) + Object.defineProperty(fake, key, { + get: () => mock, + enumerable: true, + configurable: true, + }) - Object.defineProperty(mockApis, key, { - get: () => ({ - mock: fake[key], - real: real[key], - }), - }) + Object.defineProperty(mockApis, key, { + get: () => ({ + mock: fake[key], + real: real[key], + }), + configurable: true, }) - return { - __esmodule: true, - ...fake, - } + Object.defineProperty(userEventApi, key, { + get: () => fake[key], + enumerable: true, + configurable: true, + }) }) afterEach(async () => { - jest.clearAllMocks() + mocks.clearAllMocks() }) diff --git a/tests/setup/index.ts b/tests/setup/index.ts index 55263b73..f498e8a2 100644 --- a/tests/setup/index.ts +++ b/tests/setup/index.ts @@ -1,9 +1,11 @@ -import {getConfig} from '@testing-library/dom' import {getSpy} from './_mockApis' +import DOMTestingLibrary from '#src/_interop/dtl' import userEvent from '#src' import type {Instance, UserEventApi} from '#src/setup/setup' import {render} from '#testHelpers' +const { getConfig } = DOMTestingLibrary + type ApiDeclarations = { [api in keyof UserEventApi]: { args?: unknown[] @@ -65,9 +67,9 @@ const apiDeclarations: ApiDeclarations = { elementHtml: ``, }, } -const apiDeclarationsEntries = Object.entries(apiDeclarations) as Array< - [keyof ApiDeclarations, ApiDeclarations[keyof ApiDeclarations]] -> + +type ApiDeclarationsEntry = [k, ApiDeclarations[k]] +const apiDeclarationsEntries = Object.entries(apiDeclarations) as ApiDeclarationsEntry[] const opt = Symbol('testOption') declare module '#src/setup' { @@ -85,7 +87,7 @@ declare module '#src/options' { const realAsyncWrapper = getConfig().asyncWrapper afterEach(() => { getConfig().asyncWrapper = realAsyncWrapper - jest.restoreAllMocks() + mocks.restoreAllMocks() }) test.each(apiDeclarationsEntries)( @@ -104,7 +106,7 @@ test.each(apiDeclarationsEntries)( expect(apis[name]).toHaveProperty('name', `mock-${name}`) // Replace the asyncWrapper to make sure that a delayed state update happens inside of it - const stateUpdate = jest.fn() + const stateUpdate = mocks.fn() spy.mockImplementation(async function impl( this: Instance, ...a: Parameters @@ -112,8 +114,8 @@ test.each(apiDeclarationsEntries)( const ret = spy.originalMockImplementation.apply(this, a) void ret.then(() => setTimeout(stateUpdate)) return ret - } as typeof spy['originalMockImplementation']) - const asyncWrapper = jest.fn(async (cb: () => Promise) => { + } as UserEventApi[typeof name]) + const asyncWrapper = mocks.fn(async (cb: () => Promise) => { stateUpdate.mockClear() const ret = cb() expect(stateUpdate).not.toBeCalled() @@ -126,7 +128,7 @@ test.each(apiDeclarationsEntries)( await (apis[name] as Function)(...args) expect(spy).toBeCalledTimes(1) - expect(spy.mock.lastCall?.this?.config[opt]).toBe(true) + expect(spy.mock.lastCall?.this.config[opt]).toBe(true) // Make sure the asyncWrapper mock has been used in the API call expect(asyncWrapper).toBeCalled() @@ -136,9 +138,9 @@ test.each(apiDeclarationsEntries)( await (subApis[name] as Function)(...args) expect(spy).toBeCalledTimes(2) - expect(spy.mock.lastCall?.this?.config[opt]).toBe(true) - expect(spy.mock.calls[1]?.this?.system).toBe( - spy.mock.calls[0]?.this?.system, + expect(spy.mock.lastCall?.this.config[opt]).toBe(true) + expect(spy.mock.calls[1].this.system).toBe( + spy.mock.calls[0].this.system, ) }, ) @@ -168,13 +170,13 @@ test.each(apiDeclarationsEntries)( if (!['clear', 'tab'].includes(name)) { // TODO: add options param to these direct APIs - expect(spy.mock.lastCall?.this?.config[opt]).toBe(true) + expect(spy.mock.lastCall?.this.config[opt]).toBe(true) } // options arg can be omitted await (userEvent[name] as Function)(...args.slice(0, -1)) expect(spy).toBeCalledTimes(2) - expect(spy.mock.lastCall?.this?.config[opt]).toBe(undefined) + expect(spy.mock.lastCall?.this.config[opt]).toBe(undefined) }, ) diff --git a/tests/tsconfig.json b/tests/tsconfig.json index d5ce52b1..d9659fb1 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -6,7 +6,13 @@ "#src": ["./src/index"], "#src/*": ["./src/*"], "#testHelpers": ["./tests/_helpers/index"] - } + }, + "typeRoots": [ + "../testenv", + ], + "types": [ + "types", + ], }, - "include": ["."] + "include": [".", "../testenv"], } diff --git a/tests/utility/selectOptions/select.ts b/tests/utility/selectOptions/select.ts index ee1900a7..51b13cb2 100644 --- a/tests/utility/selectOptions/select.ts +++ b/tests/utility/selectOptions/select.ts @@ -192,10 +192,10 @@ test('does not select anything if options are disabled', async () => { test('should call onChange/input bubbling up the event when a new option is selected', async () => { const {select, form, user} = setupSelect({multiple: true}) - const onChangeSelect = jest.fn() - const onChangeForm = jest.fn() - const onInputSelect = jest.fn() - const onInputForm = jest.fn() + const onChangeSelect = mocks.fn() + const onChangeForm = mocks.fn() + const onInputSelect = mocks.fn() + const onInputForm = mocks.fn() addListeners(select, { eventHandlers: {change: onChangeSelect, input: onInputSelect}, }) diff --git a/tests/utils/dataTransfer/DataTransfer.ts b/tests/utils/dataTransfer/DataTransfer.ts index 4afdb9d7..7b63b03c 100644 --- a/tests/utils/dataTransfer/DataTransfer.ts +++ b/tests/utils/dataTransfer/DataTransfer.ts @@ -7,7 +7,7 @@ describe('create DataTransfer', () => { expect(dt.getData('text/plain')).toBe('foo') - const callback = jest.fn() + const callback = mocks.fn() dt.items[0].getAsString(callback) expect(callback).toBeCalledWith('foo') }) @@ -60,7 +60,7 @@ describe('create DataTransfer', () => { expect(dt.items[0].getAsFile()).toBe(null) expect(dt.items[1].getAsFile()).toBe(f0) - const callback = jest.fn() + const callback = mocks.fn() dt.items[1].getAsString(callback) expect(callback).not.toBeCalled() }) diff --git a/tests/utils/misc/isDisabled.ts b/tests/utils/misc/isDisabled.ts index c88512fa..deaa30f9 100644 --- a/tests/utils/misc/isDisabled.ts +++ b/tests/utils/misc/isDisabled.ts @@ -2,7 +2,9 @@ import cases from 'jest-in-case' import {isDisabled} from '#src/utils' import {render} from '#testHelpers' -customElements.define( +const HTMLElement = window.HTMLElement + +window.customElements.define( 'form-associated', class FormAssociated extends HTMLElement { static formAssociated = true @@ -12,7 +14,7 @@ customElements.define( }, ) -customElements.define( +window.customElements.define( 'custom-el', class CustomEl extends HTMLElement { get disabled() { diff --git a/tests/utils/misc/isVisible.ts b/tests/utils/misc/isVisible.ts index aa5e1b44..d603ae55 100644 --- a/tests/utils/misc/isVisible.ts +++ b/tests/utils/misc/isVisible.ts @@ -1,7 +1,9 @@ -import {screen} from '@testing-library/dom' +import DOMTestingLibrary from '#src/_interop/dtl' import {isVisible} from '#src/utils' import {setup} from '#testHelpers' +const { screen } = DOMTestingLibrary + test('check if element is visible', async () => { setup(` diff --git a/tests/utils/misc/wait.ts b/tests/utils/misc/wait.ts index 7b4f0ef3..36efb877 100644 --- a/tests/utils/misc/wait.ts +++ b/tests/utils/misc/wait.ts @@ -3,16 +3,16 @@ import {wait} from '#src/utils/misc/wait' test('advances timers when set', async () => { const beforeReal = performance.now() - jest.useFakeTimers() + timers.useFakeTimers() const beforeFake = performance.now() const config = createConfig({ delay: 1000, - advanceTimers: jest.advanceTimersByTime, + advanceTimers: t => timers.advanceTimersByTime(t), }) await wait(config) expect(performance.now() - beforeFake).toBe(1000) - jest.useRealTimers() + timers.useRealTimers() expect(performance.now() - beforeReal).toBeLessThan(1000) }, 10) diff --git a/tsconfig.json b/tsconfig.json index 5ea41274..ccfd108b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,10 +2,16 @@ "extends": "./node_modules/kcd-scripts/shared-tsconfig.json", "compilerOptions": { "esModuleInterop": true, + "lib": [ + "ESNext", + "DOM", + ], "target": "ES5", "baseUrl": "/dev/null", "paths": {}, - "noEmit": true + "noEmit": true, + "typeRoots": [], + "types": [], }, "include": ["./src"] }