diff --git a/.eslintrc b/.eslintrc index aa7d52c..e55fc9c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,7 +1,7 @@ { - "env" : { + "env": { "node": true, - "es2023" : true + "es2023": true }, "parserOptions": { "ecmaVersion": "latest", @@ -25,7 +25,7 @@ ] } ], - "rules" : { + "rules": { "@stylistic/js/comma-dangle": [ "error", "never" ], @@ -50,5 +50,12 @@ "no-console": [ "error", { "allow": [ "warn", "error" ]} ] + }, + "settings": { + "jsdoc": { + "preferredTypes": { + "Object": "object" + } + } } } diff --git a/README.md b/README.md index 253cf77..176d858 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,39 @@ describe('TestPageOpener', () => { }) ``` +### Using with a bundler (e.g., with Rollup, Vite, and Vitest) + +If your project uses any bundler plugins that perform source transforms, you +_may_ need to configure your project to include `test-page-loader` in the test +bundle. Specifically, if it transforms files without a `.js` extension into importable JavaScript, `test-page-opener` may fail with an error resembling: + +```text +Caused by: TypeError: Unknown file extension ".hbs" for +/.../mbland/tomcat-servlet-testing-example/strcalc/src/main/frontend/components/calculator.hbs +———————————————————————————————————————————————————————— +Serialized Error: { code: 'ERR_UNKNOWN_FILE_EXTENSION' } +———————————————————————————————————————————————————————— +``` + +For example, using [Vite][] and [Vitest][], which use [Rollup][] under the hood, +you will need to add this `server:` setting to the `test` config object: + +```js +test: { + server: { + deps: { + // Without this, jsdom tests will fail to import '.hbs' files + // transformed by rollup-plugin-handlebars-precompiler. + inline: ['test-page-opener'] + } + } +} +``` + +For a concrete example with more details, see: + +- + ### Reporting code coverage `TestPageOpener` makes it possible to collect code coverage from opened browser @@ -229,11 +262,12 @@ level explanation. [coveralls-tpo]: https://coveralls.io/github/mbland/test-page-opener?branch=main [npm-tpo]: https://www.npmjs.com/package/test-page-opener [pnpm]: https://pnpm.io/ +[Vite]: https://vitejs.dev/ +[Vitest]: https://vitest.dev/ +[Rollup]: https://rollupjs.org/ [DOMContentLoaded]: https://developer.mozilla.org/docs/Web/API/Document/DOMContentLoaded_event [window.load]: https://developer.mozilla.org/docs/Web/API/Window/load_event [DOM]: https://developer.mozilla.org/docs/Web/API/Document_Object_Model -[Vite]: https://vitejs.dev/ -[Vitest]: https://vitest.dev/ [ECMAScript Modules]: https://nodejs.org/docs/latest-v18.x/api/esm.html [ESM resolution and loading algorithm]: https://nodejs.org/docs/latest-v18.x/api/esm.html#resolution-and-loading-algorithm [jsdom-2475]: https://github.com/jsdom/jsdom/issues/2475 diff --git a/ci/vitest.config.browser.js b/ci/vitest.config.browser.js index 72ff124..70773ca 100644 --- a/ci/vitest.config.browser.js +++ b/ci/vitest.config.browser.js @@ -6,6 +6,7 @@ export default mergeConfig(baseConfig, defineConfig({ test: { outputFile: 'TESTS-TestSuites-browser.xml', coverage: { + provider: 'istanbul', reportsDirectory: 'coverage-browser' }, browser: { diff --git a/ci/vitest.config.js b/ci/vitest.config.js index b7a186a..1ba6c02 100644 --- a/ci/vitest.config.js +++ b/ci/vitest.config.js @@ -8,7 +8,6 @@ export default mergeConfig(viteConfig, defineConfig({ reporters: [ 'junit', 'default' ], coverage: { enabled: true, - provider: 'istanbul', reporter: [ 'text', 'lcovonly' ], reportsDirectory: 'coverage-jsdom' } diff --git a/index.js b/index.js index 880e530..b121a7a 100644 --- a/index.js +++ b/index.js @@ -102,6 +102,7 @@ export default class TestPageOpener { * @param {string} pagePath - path to the HTML file relative to the basePath * specified during `TestPageOpener.create()` * @returns {Promise} - object representing the opened page + * @throws {Error} if pagePath is malformed or opening page failed */ async open(pagePath) { if (pagePath.startsWith('/')) { diff --git a/jsconfig.json b/jsconfig.json index 5367b8a..50d2464 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,10 +1,16 @@ { "compilerOptions": { "checkJs": true, - "module": "nodenext", - "strict": true, - "strictNullChecks": false, - "noImplicitAny": false + "lib": [ + "ES2022" + ], + "module": "node16", + "target": "es2020", + "strict": true }, - "exclude": ["node_modules"] + "exclude": [ + "node_modules/**", + "coverage*/**", + "jsdoc/**" + ] } diff --git a/jsdoc.json b/jsdoc.json index 0361ec2..d8329a3 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -1,11 +1,12 @@ { "plugins": [ - "plugins/markdown" + "plugins/markdown", + "node_modules/jsdoc-plugin-intersection" ], "recurseDepth": 10, "source": { "includePattern": ".+\\.js$", - "exclude": ["node_modules"] + "exclude": ["node_modules", "test"] }, "opts": { "destination": "jsdoc", diff --git a/lib/browser.js b/lib/browser.js index 1dba54e..9510fe2 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -8,6 +8,11 @@ import libCoverage from 'istanbul-lib-coverage' import { OpenedPage } from './types.js' +/** + * Type for accessing the Istanbul coverage object in Window + * @typedef {Window & Object.} CovWindow + */ + /** * Returns the window and document from a browser-opened HTML file. * @@ -20,6 +25,7 @@ import { OpenedPage } from './types.js' export default class BrowserPageOpener { #window #coverageKey + #coverageMap /** * @param {Window} window - the global (browser) window object @@ -34,7 +40,9 @@ export default class BrowserPageOpener { // coverage. There's no harm in this, and it avoids a coverage gap for a // condition that, by definition, would never execute when collecting // coverage. We could use a directive to ignore that gap, but why bother. - window[covKey] = libCoverage.createCoverageMap(window[covKey]) + const covWindow = /** @type {CovWindow} */ (window) + this.#coverageMap = libCoverage.createCoverageMap(covWindow[covKey]) + covWindow[covKey] = this.#coverageMap } /** @@ -42,11 +50,16 @@ export default class BrowserPageOpener { * @param {string} basePath - base path of the application under test * @param {string} pagePath - path to the HTML file relative to basePath * @returns {Promise} - object representing the opened page + * @throws {Error} if opening page failed */ async open(basePath, pagePath) { - const w = this.#window.open(`${basePath}${pagePath}`) + const fullPath = `${basePath}${pagePath}` + const w = this.#window.open(fullPath) + if (w === null) throw new Error(`failed to open: ${fullPath}`) + const close = () => { - this.#window[this.#coverageKey].merge(w[this.#coverageKey]) + const testWindow = /** @type {CovWindow} */ (w) + this.#coverageMap.merge(testWindow[this.#coverageKey]) w.close() } diff --git a/lib/jsdom.js b/lib/jsdom.js index 827e7e1..b11a366 100644 --- a/lib/jsdom.js +++ b/lib/jsdom.js @@ -7,6 +7,11 @@ import { OpenedPage } from './types.js' +/** + * @typedef {object} JSDOM - simulated jsdom.JSDOM + * @property {Function} fromFile - simulated JSDOM.fromFile + */ + /** * Returns window and document objects from a jsdom-parsed HTML file. * @@ -61,7 +66,7 @@ export default class JsdomPageOpener { /** * Creates a JsdomPageOpener from a dynamically imported jsdom module * @param {object} jsdom - dynamically imported jsdom module - * @param {object} jsdom.JSDOM - JSDOM class from the jsdom module + * @param {JSDOM} jsdom.JSDOM - JSDOM class from the jsdom module */ constructor({ JSDOM }) { this.#JSDOM = JSDOM @@ -72,6 +77,7 @@ export default class JsdomPageOpener { * @param {string} _ - ignored * @param {string} pagePath - path to the HTML file to load * @returns {Promise} - object representing the opened page + * @throws {Error} if opening page failed */ async open(_, pagePath) { const { window } = await this.#JSDOM.fromFile( @@ -125,14 +131,21 @@ export default class JsdomPageOpener { // register closures over window and document, or specific document // elements. That would ensure they remain defined even after we remove // window and document from globalThis. - // + const { window: origWindow, document: origDocument } = globalThis + + /** @param {Function} done - called after restoring original globals */ + const resetGlobals = done => { + globalThis.document = origDocument + globalThis.window = origWindow + done() + } + // @ts-expect-error globalThis.window = window globalThis.document = document - const Event = globalThis.window.Event try { await importModules(document) } - catch (err) { reject(err) } + catch (err) { return resetGlobals(() => {reject(err)}) } // Manually firing DOMContentLoaded again after loading modules // approximates the requirement that modules execute before @@ -140,20 +153,18 @@ export default class JsdomPageOpener { // DOMContentLoaded event listeners and have them fire here. // // We eventually fire the 'load' event again too for the same reason. - document.dispatchEvent(new Event( - 'DOMContentLoaded', {bubbles: true, cancelable: false} - )) + const Event = globalThis.window.Event + document.dispatchEvent( + new Event('DOMContentLoaded', {bubbles: true, cancelable: false}) + ) // Register a 'load' listener that deletes the global window and // document variables. Because it's registered after any // DOMContentLoaded listeners have fired, it should execute after any // other 'load' listeners registered by any module code. - const resetGlobals = () => { - delete globalThis.document - delete globalThis.window - resolve() - } - window.addEventListener('load', resetGlobals, {once: true}) + window.addEventListener( + 'load', () => {resetGlobals(resolve)}, {once: true} + ) window.dispatchEvent( new Event('load', {bubbles: false, cancelable: false}) ) diff --git a/package.json b/package.json index 8c13a52..1bce2c8 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,17 @@ { "name": "test-page-opener", - "version": "1.0.4", + "version": "1.0.5", "description": "Enables an application's tests to open its own page URLs both in the browser and in Node.js using jsdom", "main": "index.js", "types": "types/index.d.ts", "scripts": { "lint": "eslint --color --max-warnings 0 .", "test": "vitest", - "test:ci": "eslint --color --max-warnings 0 . && vitest run -c ci/vitest.config.js && vitest run -c ci/vitest.config.browser.js", + "test:ci": "pnpm lint && pnpm typecheck && pnpm jsdoc && pnpm test:ci:jsdom && pnpm test:ci:browser", + "test:ci:jsdom": "vitest run -c ci/vitest.config.js", + "test:ci:browser": "vitest run -c ci/vitest.config.browser.js", "jsdoc": "jsdoc-cli-wrapper -c jsdoc.json .", + "typecheck": "npx -p typescript tsc -p jsconfig.json --noEmit --pretty", "prepack": "npx -p typescript tsc ./index.js --allowJs --declaration --declarationMap --emitDeclarationOnly --outDir types" }, "files": [ @@ -32,6 +35,9 @@ "bugs": "https://github.com/mbland/test-page-opener/issues", "devDependencies": { "@stylistic/eslint-plugin-js": "^1.5.3", + "@types/chai": "^4.3.11", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/jsdom": "^21.1.6", "@vitest/browser": "^1.1.3", "@vitest/coverage-istanbul": "^1.1.3", "@vitest/coverage-v8": "^1.1.3", @@ -40,6 +46,7 @@ "eslint-plugin-jsdoc": "^46.10.1", "eslint-plugin-vitest": "^0.3.20", "jsdoc-cli-wrapper": "^1.0.4", + "jsdoc-plugin-intersection": "^1.0.4", "jsdom": "^23.1.0", "typescript": "^5.3.3", "vite": "^5.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b61597c..a0def6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,15 @@ devDependencies: '@stylistic/eslint-plugin-js': specifier: ^1.5.3 version: 1.5.3(eslint@8.56.0) + '@types/chai': + specifier: ^4.3.11 + version: 4.3.11 + '@types/istanbul-lib-coverage': + specifier: ^2.0.6 + version: 2.0.6 + '@types/jsdom': + specifier: ^21.1.6 + version: 21.1.6 '@vitest/browser': specifier: ^1.1.3 version: 1.1.3(vitest@1.1.3)(webdriverio@8.27.0) @@ -37,6 +46,9 @@ devDependencies: jsdoc-cli-wrapper: specifier: ^1.0.4 version: 1.0.4 + jsdoc-plugin-intersection: + specifier: ^1.0.4 + version: 1.0.4 jsdom: specifier: ^23.1.0 version: 23.1.0 @@ -797,6 +809,10 @@ packages: resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} dev: true + /@types/chai@4.3.11: + resolution: {integrity: sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==} + dev: true + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true @@ -809,6 +825,14 @@ packages: resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} dev: true + /@types/jsdom@21.1.6: + resolution: {integrity: sha512-/7kkMsC+/kMs7gAYmmBR9P0vGTnOoLhQhyhQJSlXGI5bzTHp6xdo0TtKWQAsz6pmSAeVqKSbqeyP6hytqr9FDw==} + dependencies: + '@types/node': 20.10.6 + '@types/tough-cookie': 4.0.5 + parse5: 7.1.2 + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -823,6 +847,10 @@ packages: resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} dev: true + /@types/tough-cookie@4.0.5: + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + dev: true + /@types/which@2.0.2: resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} dev: true @@ -2476,6 +2504,10 @@ packages: hasBin: true dev: true + /jsdoc-plugin-intersection@1.0.4: + resolution: {integrity: sha512-5OGv+aWpp0pYRMnhaUeqijesIi/xD2z8IIwIlK+JsQ0sXzMjiWT2YgNSvxcd476pRdh70Xmby/l7GD3NkWcKcQ==} + dev: true + /jsdoc-type-pratt-parser@4.0.0: resolution: {integrity: sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==} engines: {node: '>=12.0.0'} diff --git a/test-modules/app.js b/test-modules/app.js index 86b3322..3cc14e0 100644 --- a/test-modules/app.js +++ b/test-modules/app.js @@ -8,6 +8,10 @@ const HELLO_URL = 'https://en.wikipedia.org/wiki/%22Hello,_World!%22_program' export default class App { + /** + * @param {object} _ - initializaion parameters + * @param {HTMLElement} _.appElem - root element of application + */ init({ appElem }) { const t = document.createElement('template') t.innerHTML = `

Hello, World!

` diff --git a/test-modules/main.js b/test-modules/main.js index 7603e1a..d30cb55 100644 --- a/test-modules/main.js +++ b/test-modules/main.js @@ -6,9 +6,18 @@ */ import App from './app.js' +import * as types from './types.js' document.addEventListener( 'DOMContentLoaded', - () => new App().init({ appElem: document.querySelector('#app') }), + () => { + /** @type {(HTMLDivElement | null)} */ + const appElem = document.querySelector('#app') + if (appElem === null) return console.error('no #app element') + + /** @type {types.InitParams} */ + const initParams = { appElem } + new App().init(initParams) + }, { once: true } ) diff --git a/test-modules/missing.html b/test-modules/missing.html new file mode 100644 index 0000000..8dd6aed --- /dev/null +++ b/test-modules/missing.html @@ -0,0 +1,18 @@ + + + + + + + Test Page for JsdomPageOpener missing an app div + + + + +
+ + diff --git a/test-modules/types.js b/test-modules/types.js new file mode 100644 index 0000000..9f6291c --- /dev/null +++ b/test-modules/types.js @@ -0,0 +1,14 @@ +/* eslint-env browser */ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/** + * Parameters that main.js passes to all Init() functions + * @typedef {object} InitParams + * @property {HTMLElement} appElem - root element of application + */ +/** @type {InitParams} */ +export let InitParams diff --git a/test/browser.test.js b/test/browser.test.js index 9ffc54b..6c6e42d 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -5,7 +5,8 @@ */ import { DEFAULT_COVERAGE_KEY, getCoverageKey } from '../lib/browser.js' -import { describe, expect, test } from 'vitest' +import TestPageOpener from '../index.js' +import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest' describe('getCoverageKey', () => { test('returns existing coverage key', () => { @@ -18,3 +19,24 @@ describe('getCoverageKey', () => { expect(getCoverageKey({})).toBe(DEFAULT_COVERAGE_KEY) }) }) + +describe.skipIf(globalThis.window === undefined)('BrowserPageOpener', () => { + /** @type {TestPageOpener} */ + let opener + + beforeAll(async () => {opener = await TestPageOpener.create('/basedir/')}) + + afterEach(() => { + opener.closeAll() + vi.unstubAllGlobals() + }) + + test('open() throws if page fails to open', async () => { + const openStub = vi.fn() + openStub.mockReturnValueOnce(null) + vi.stubGlobal('open', openStub) + + await expect(opener.open('test-modules/index.html')).rejects + .toThrowError('failed to open: /basedir/test-modules/index.html') + }) +}) diff --git a/test/event-ordering-demo/main.js b/test/event-ordering-demo/main.js index de49011..59d112d 100755 --- a/test/event-ordering-demo/main.js +++ b/test/event-ordering-demo/main.js @@ -47,8 +47,12 @@ const { window } = await JSDOM.fromFile( pagePath, {resources: 'usable', runScripts: 'dangerously'} ) const document = window.document -/** @type {HTMLScriptElement} */ +/** @type {(HTMLScriptElement | null)} */ const moduleElem = document.querySelector('script[type="module"]') +if (moduleElem === null) { + throw new Error(`no