Skip to content

Commit

Permalink
Merge pull request #10 from mbland/separate-implementation-modules
Browse files Browse the repository at this point in the history
Move {Browser,Jsdom}PageOpener to separate modules
  • Loading branch information
mbland authored Jan 4, 2024
2 parents 05645d4 + 9fbe668 commit 5ff250c
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 212 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,15 @@ My [mbland/tomcat-servlet-testing-example][] project

### Page Object pattern

### Using a fake backend
### Mocking backend calls

TODO: Look into <https://mswjs.io>

### Using a separate backend

- Injecting a backend address
- CORS

### Stubbing fetch()?

## Development

Uses [pnpm][] and [Vitest][] for building and testing.
Expand Down
217 changes: 8 additions & 209 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

import libCoverage from 'istanbul-lib-coverage'

/**
* Return value from PageOpener.open()
* @typedef OpenedPage
* @property {Window} window - Window object for the opened page
* @property {Document} document - Document object parsed from the opened page
* @property {Function} close - closes the page
*/
import BrowserPageOpener from './lib/browser'
import JsdomPageOpener from './lib/jsdom'
import { OpenedPage } from './lib/types'

/**
* Enables tests to open an application's own page URLs both in the browser and
Expand Down Expand Up @@ -41,6 +35,11 @@ export class PageOpener {
return new PageOpener(basePath, impl)
}

/**
* Opens a page using the current environment's implementation.
* @param {string} pagePath - path to the HTML file relative to basePath
* @returns {Promise<OpenedPage>} - object representing the opened page
*/
async open(pagePath) {
if (pagePath.startsWith('/')) {
const msg = 'page path should not start with \'/\''
Expand All @@ -57,203 +56,3 @@ export class PageOpener {
this.#opened = []
}
}

class BrowserPageOpener {
#window
#coverageKey

constructor(window) {
this.#window = window
this.#coverageKey = BrowserPageOpener.getCoverageKey(window)
}

static getCoverageKey(globalObj) {
const foundKey = Object.getOwnPropertyNames(globalObj)
.find(n => /_+.*coverage_+/i.test(n))
return foundKey || '__coverage__'
}

// open a page and returns {window, document, close()} using the browser.
async open(basePath, pagePath) {
const w = this.#window.open(`${basePath}${pagePath}`)
const close = () => {
this.#mergeCoverageStore(w)
w.close()
}
return new Promise(resolve => {
const listener = () => {
resolve({window: w, document: w.document, close})
}
w.addEventListener('load', listener, {once: true})
})
}

// This is very specific to the Istanbul coverage provider.
#mergeCoverageStore(openedWindow) {
const covKey = this.#coverageKey
const thisCov = this.#window[covKey]
const combinedCov = libCoverage.createCoverageMap(thisCov)

combinedCov.merge(openedWindow[covKey])
this.#window[covKey] = combinedCov.toJSON()
}
}

/**
* Returns window and document objects from a jsdom-parsed HTML file.
*
* Based on hints from:
* - <https://oliverjam.es/articles/frontend-testing-node-jsdom>
*
* It will import modules from `<script type="module">` elements with a `src`
* attribute, but not those with inline code. It does this by calling dynamic
* `import()` on the `src` paths:
*
* - <https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/import>
*
* This is because jsdom currently parses, but doesn't execute,
* `<script type="module">` elements:
*
* - <https://github.com/jsdom/jsdom/issues/2475>
*
* Once that issue is resolved, the jsdom module loading implementation will
* supplant this class's current module loading implementation, described below.
*
* ### Timing of `<script type="module">` execution
*
* Technically, imported modules should execute similarly to `<script defer>`
* and execute before the `DOMContentLoaded` event.
*
* - <https://developer.mozilla.org/docs/Web/HTML/Element/script#module>
*
* However, this implementation registers a `load` event handler that collects
* `src` paths and waits for the dynamic `import()` of each path to resolve. It
* then fires the `DOMContentLoaded` and `load` events again, enabling modules
* that register listeners for those events to behave as expected.
*
* ### More detail...
*
* `DOMContentLoaded` and `load` events from `JSDOM.fromFile()` always fire
* before dynamic module imports finish resolving. In some cases,
* `DOMContentLoaded` fires even before `JSDOM.fromFile()` resolves.
*
* If, immediately after JSDOM.fromFile() returns, `document.readyState` is
* `loading`, `DOMContentLoaded` has yet to fire. If it's `interactive`,
* `DOMContentLoaded` has already fired, and `load` is about to fire.
*
* - <https://developer.mozilla.org/docs/Web/API/Document/readyState>
*
* The `test/event-ordering-demo/main.js` demo script from this package shows
* this behavior in action. See that file's comments for details.
*/
class JsdomPageOpener {
#JSDOM

constructor({ JSDOM }) {
this.#JSDOM = JSDOM
}

/**
* Opens a page using jsdom.
* @param {string} _ - ignored
* @param {string} pagePath - path to the HTML file to load
* @returns {Promise<OpenedPage>} - object representing the opened page
*/
async open(_, pagePath) {
const { window } = await this.#JSDOM.fromFile(
pagePath, {resources: 'usable', runScripts: 'dangerously'}
)
const document = window.document

try {
await this.#importModules(window, document)
} catch (err) {
throw new Error(`error importing modules from ${pagePath}: ${err}`)
}
return { window, document, close() { window.close() } }
}

/**
* Dynamically imports ECMAScript modules.
* @param {Window} window - the jsdom window object
* @param {Document} document - the jsdom window.document object
* @returns {Promise} - resolves after importing all ECMAScript modules
* @throws if importing any ECMAScript modules fails
*/
#importModules(window, document) {
return new Promise(resolve => {
const importModulesOnEvent = async () => {
// The jsdom docs advise against setting global properties, but we don't
// really have another option given any module may access window and/or
// document.
//
// (I tried to explore invoking ES modules properly inside the jsdom,
// and realized that way lies madness. At least, I couldn't yet figure
// out how to access the Vite/Vitest module path resolver or Rollup
// plugins. Then there's the matter of importmaps. I may still pick at
// it, but staring directly at it right now isn't productive.)
//
// Also, unless the module takes care to close over window or document,
// they may still reference the global.window and global.document
// attributes. This isn't a common cause for concern in a browser, but
// resetting these global properties before a jsdom listener fires can
// cause it to error. This, in turn, can potentially cause a test to
// hang or fail.
//
// This is why we keep global.window and global.document set until
// the load event handler below fires, after the manually dispatched
// load event. This is best-effort, of course, as we can't know if any
// async ops dispatched by those listeners will register a 'load' event
// later. In that case, window and document may be undefined for those
// listeners.
//
// The best defense against this problem would be to design the app to
// 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.
globalThis.window = window
globalThis.document = document
await importModules(document)

// Manually firing DOMContentLoaded again after loading modules
// approximates the requirement that modules execute before
// DOMContentLoaded. This means that the modules can register
// DOMContentLoaded event listeners and have them fire here.
//
// We eventually fire the 'load' event again too for the same reason.
document.dispatchEvent(new window.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 = () => resolve(
delete globalThis.document, delete globalThis.window
)
window.addEventListener('load', resetGlobals, {once: true})
window.dispatchEvent(
new window.Event('load', {bubbles: false, cancelable: false})
)
}
window.addEventListener('load', importModulesOnEvent, {once: true})
})
}
}

/**
* Imports modules from `<script type="module">` elements parsed by jsdom.
*
* Only works with the `src` attribute; it will not execute inline code.
* @param {Document} doc - the jsdom window.document object
* @returns {Promise} - resolves after importing all ECMAScript modules in doc
* @throws if importing any ECMAScript modules fails
*/
function importModules(doc) {
const modules = Array.from(doc.querySelectorAll('script[type="module"]'))
return Promise.all(modules.filter(m => m.src).map(async m => {
try { await import(m.src) }
catch (err) { throw Error(`error importing ${m.src}: ${err}`) }
}))
}
50 changes: 50 additions & 0 deletions lib/browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* eslint-env browser */

import libCoverage from 'istanbul-lib-coverage'
import { OpenedPage } from './types'

export default class BrowserPageOpener {
#window
#coverageKey

constructor(window) {
this.#window = window
this.#coverageKey = BrowserPageOpener.getCoverageKey(window)
}

static getCoverageKey(globalObj) {
const foundKey = Object.getOwnPropertyNames(globalObj)
.find(n => /_+.*coverage_+/i.test(n))
return foundKey || '__coverage__'
}

/**
* Opens another page within a web browser.
* @param {string} basePath - base path of the application under test
* @param {string} pagePath - path to the HTML file relative to basePath
* @returns {Promise<OpenedPage>} - object representing the opened page
*/
async open(basePath, pagePath) {
const w = this.#window.open(`${basePath}${pagePath}`)
const close = () => {
this.#mergeCoverageStore(w)
w.close()
}
return new Promise(resolve => {
const listener = () => {
resolve({window: w, document: w.document, close})
}
w.addEventListener('load', listener, {once: true})
})
}

// This is very specific to the Istanbul coverage provider.
#mergeCoverageStore(openedWindow) {
const covKey = this.#coverageKey
const thisCov = this.#window[covKey]
const combinedCov = libCoverage.createCoverageMap(thisCov)

combinedCov.merge(openedWindow[covKey])
this.#window[covKey] = combinedCov.toJSON()
}
}
Loading

0 comments on commit 5ff250c

Please sign in to comment.