brut-js 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.projections.json +10 -0
- package/CODE_OF_CONDUCT.txt +99 -0
- package/LICENSE.txt +370 -0
- package/README.md +118 -0
- package/package.json +30 -0
- package/specs/AjaxSubmit.spec.js +241 -0
- package/specs/Autosubmit.spec.js +127 -0
- package/specs/ConfirmSubmit.spec.js +193 -0
- package/specs/ConstraintViolationMessage.spec.js +33 -0
- package/specs/ConstraintViolationMessages.spec.js +27 -0
- package/specs/Form.spec.js +136 -0
- package/specs/I18nTranslation.spec.js +19 -0
- package/specs/LocaleDetection.spec.js +22 -0
- package/specs/Message.spec.js +15 -0
- package/specs/Tabs.spec.js +41 -0
- package/specs/config/asset_metadata.json +7 -0
- package/specs/public/js/bundle.js +1284 -0
- package/specs/public/js/bundle.js.map +7 -0
- package/src/AjaxSubmit.js +364 -0
- package/src/Autosubmit.js +61 -0
- package/src/BaseCustomElement.js +261 -0
- package/src/ConfirmSubmit.js +114 -0
- package/src/ConfirmationDialog.js +141 -0
- package/src/ConstraintViolationMessage.js +101 -0
- package/src/ConstraintViolationMessages.js +90 -0
- package/src/Form.js +117 -0
- package/src/I18nTranslation.js +59 -0
- package/src/LocaleDetection.js +93 -0
- package/src/Logger.js +90 -0
- package/src/Message.js +55 -0
- package/src/RichString.js +113 -0
- package/src/Tabs.js +167 -0
- package/src/appForTestingOnly.js +15 -0
- package/src/index.js +119 -0
- package/src/testing/index.js +348 -0
package/src/Tabs.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import BaseCustomElement from "./BaseCustomElement"
|
|
2
|
+
|
|
3
|
+
/** Implements an in-page tab selector. It's intended to wrap a set of `<a>` or `<button>` elements
|
|
4
|
+
* that represent the tabs of a tabbed UI, as defined by ARIA roles.
|
|
5
|
+
*
|
|
6
|
+
* Each direct child must be an `<a>` or a `<button>`, though `<a>` is recommended.
|
|
7
|
+
* Any other elements are ignored. Each `<a>` or `<button>`
|
|
8
|
+
* (herafter referred to as "tab") must have the correct ARIA attributes:
|
|
9
|
+
*
|
|
10
|
+
* * `role="tab"`
|
|
11
|
+
* * `aria-selected` as true or false, depending on what tab is selected when the page is first rendered. This
|
|
12
|
+
* custom element will ensure this value is updated as different tabs are selected.
|
|
13
|
+
* * `tabindex` should be 0 if selected, -1 otherwise. This custom element will ensure this value is updated as
|
|
14
|
+
* different tabs are selected.
|
|
15
|
+
* * `aria-controls` to the ID or list of IDs of panels that should be shown when this tab is selected.
|
|
16
|
+
* * `id` to allow the `tab-panel` to refer back to this tab.
|
|
17
|
+
*
|
|
18
|
+
* This custom element will set click listeners on all tabs and, when clicked, hide all panels referred to by
|
|
19
|
+
* every tab (by setting the `hidden` attribute), then show only those panels referred to by the clicked
|
|
20
|
+
* tab. You can use CSS to style everything the way you like it.
|
|
21
|
+
*
|
|
22
|
+
* @property {boolean} tab-selection-pushes-and-restores-state if set, this custom element will use the
|
|
23
|
+
* history API to manage state. When a tab
|
|
24
|
+
* implemented by an `<a>` with an `href` is
|
|
25
|
+
* clicked, that `href` will be pushed into
|
|
26
|
+
* the state. When the back button is hit,
|
|
27
|
+
* this will select the previous tab as selected.
|
|
28
|
+
* Note that this will conflict with anything else
|
|
29
|
+
* on the page that manipulates state, so only
|
|
30
|
+
* set this if your UI is a "full page tab"
|
|
31
|
+
* style UI.
|
|
32
|
+
*
|
|
33
|
+
* @fires Tabs#brut:tabselected whenever the tab selection has changed
|
|
34
|
+
* @example
|
|
35
|
+
* <brut-tabs>
|
|
36
|
+
* <a role="tab" aria-selected="true" tabindex="0" aria-controls="inbox-panel" id="inbox-tab"
|
|
37
|
+
* href="?tab=inbox">Inbox</a>
|
|
38
|
+
* <a role="tab" aria-selected="false" tabindex="-1" aria-controls="drafts-panel" id="drafts-tab"
|
|
39
|
+
* href="?tab=drafts">Drafts</a>
|
|
40
|
+
* <a role="tab" aria-selected="false" tabindex="-1" aria-controls="spam-panel" id="spam-tab"
|
|
41
|
+
* href="?tab=spam">Spam</a>
|
|
42
|
+
* </brut-tabs>
|
|
43
|
+
* <section role="tabpanel" tabindex="0" id="inbox-panel">
|
|
44
|
+
* <h3>Inbox</h3>
|
|
45
|
+
* </section>
|
|
46
|
+
* <section role="tabpanel" tabindex="0" id="drafts-panel" hidden>
|
|
47
|
+
* <h3>Drafts</h3>
|
|
48
|
+
* </section>
|
|
49
|
+
* <section role="tabpanel" tabindex="0" id="spam-panel" hidden>
|
|
50
|
+
* <h3>Spam</h3>
|
|
51
|
+
* </section>
|
|
52
|
+
* <!-- if a user clicks on 'Drafts', the DOM will be updated to look
|
|
53
|
+
* effectively like so: -->
|
|
54
|
+
* <brut-tabs>
|
|
55
|
+
* <a role="tab" aria-selected="false" tabindex="-1" aria-controls="inbox-panel" id="inbox-tab"
|
|
56
|
+
* href="?tab=inbox">Inbox</a>
|
|
57
|
+
* <a role="tab" aria-selected="true" tabindex="0" aria-controls="drafts-panel" id="drafts-tab"
|
|
58
|
+
* href="?tab=drafts">Drafts</a>
|
|
59
|
+
* <a role="tab" aria-selected="false" tabindex="-1" aria-controls="spam-panel" id="spam-tab"
|
|
60
|
+
* href="?tab=spam">Spam</a>
|
|
61
|
+
* </brut-tabs>
|
|
62
|
+
* <section role="tabpanel" tabindex="0" id="inbox-panel" hidden>
|
|
63
|
+
* <h3>Inbox</h3>
|
|
64
|
+
* </section>
|
|
65
|
+
* <section role="tabpanel" tabindex="-1" id="drafts-panel">
|
|
66
|
+
* <h3>Drafts</h3>
|
|
67
|
+
* </section>
|
|
68
|
+
* <section role="tabpanel" tabindex="-1" id="spam-panel" hidden>
|
|
69
|
+
* <h3>Spam</h3>
|
|
70
|
+
* </section>
|
|
71
|
+
*
|
|
72
|
+
*/
|
|
73
|
+
class Tabs extends BaseCustomElement {
|
|
74
|
+
static tagName = "brut-tabs"
|
|
75
|
+
static observedAttributes = [
|
|
76
|
+
"tab-selection-pushes-and-restores-state",
|
|
77
|
+
"show-warnings",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
tabSelectionPushesAndRestoresStateChangedCallback({newValue,oldValue}) {
|
|
81
|
+
this.#pushAndRestoreTabState = newValue != null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
update() {
|
|
85
|
+
this.#tabs().forEach( (tab) => {
|
|
86
|
+
tab.addEventListener("click", this.#tabClicked)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#pushAndRestoreTabState = false
|
|
91
|
+
|
|
92
|
+
#tabClicked = (event) => {
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
this.#setTabAsSelected(event.target)
|
|
95
|
+
event.preventDefault()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#reloadTab = (event) => {
|
|
99
|
+
const tab = document.getElementById(event.state.tabId)
|
|
100
|
+
if (tab) {
|
|
101
|
+
this.#setTabAsSelected(tab, { skipPushState: true })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#setTabAsSelected(selectedTab, { skipPushState = false } = {}) {
|
|
106
|
+
this.#tabs().forEach( (tab) => {
|
|
107
|
+
const tabPanels = []
|
|
108
|
+
const ariaControls = tab.getAttribute("aria-controls")
|
|
109
|
+
if (ariaControls) {
|
|
110
|
+
ariaControls.split(/\s+/).forEach( (id) => {
|
|
111
|
+
const panel = document.getElementById(id)
|
|
112
|
+
if (panel) {
|
|
113
|
+
tabPanels.push(panel)
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.logger.warn("Tab %o references panel with id %s, but no such element exists with that id",tab,id)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
if (tab == selectedTab) {
|
|
121
|
+
tab.setAttribute("aria-selected",true)
|
|
122
|
+
tab.setAttribute("tabindex","0")
|
|
123
|
+
tabPanels.forEach( (panel) => panel.removeAttribute("hidden") )
|
|
124
|
+
if (this.#pushAndRestoreTabState && !skipPushState) {
|
|
125
|
+
let href = tab.getAttribute("href") || ""
|
|
126
|
+
if (href.startsWith("?")) {
|
|
127
|
+
let hrefQueryString = href.slice(1)
|
|
128
|
+
const anchorIndex = hrefQueryString.indexOf("#")
|
|
129
|
+
if (anchorIndex != -1) {
|
|
130
|
+
hrefQueryString = hrefQueryString.slice(-1 * (hrefQueryString.length - anchorIndex - 1))
|
|
131
|
+
}
|
|
132
|
+
const currentQuery = new URLSearchParams(window.location.search)
|
|
133
|
+
const hrefQuery = new URLSearchParams(hrefQueryString)
|
|
134
|
+
hrefQuery.forEach( (value,key) => {
|
|
135
|
+
currentQuery.set(key,value)
|
|
136
|
+
})
|
|
137
|
+
href = "?" + currentQuery.toString() + (anchorIndex == -1 ? "" : hrefQueryString.slice(anchorIndex))
|
|
138
|
+
}
|
|
139
|
+
window.history.pushState({ tabId: tab.id },"",href)
|
|
140
|
+
window.addEventListener("popstate", this.#reloadTab)
|
|
141
|
+
}
|
|
142
|
+
this.dispatchEvent(new CustomEvent("brut:tabselected", { tabId: tab.id }))
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
tab.setAttribute("aria-selected",false)
|
|
146
|
+
tab.setAttribute("tabindex","-1")
|
|
147
|
+
tabPanels.forEach( (panel) => panel.setAttribute("hidden", true) )
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
#tabs() {
|
|
154
|
+
const tabs = []
|
|
155
|
+
this.querySelectorAll("[role=tab]").forEach( (tab) => {
|
|
156
|
+
if ( (tab.tagName.toLowerCase() == "a") || (tab.tagName.toLowerCase() == "button") ) {
|
|
157
|
+
tabs.push(tab)
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
this.logger.warn("An element with tag %s was assigned role=tab, and %s doesn't work that way. Use an <a> or a <button>",tab.tagName,this.constructor.name)
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
return tabs
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
export default Tabs
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as BrutJS from "./index.js"
|
|
2
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
3
|
+
BrutJS.BrutCustomElements.define()
|
|
4
|
+
if (!HTMLDialogElement.prototype.showModal) {
|
|
5
|
+
HTMLDialogElement.prototype.showModal = function() {
|
|
6
|
+
this.open = true
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
if (!HTMLDialogElement.prototype.close) {
|
|
10
|
+
HTMLDialogElement.prototype.close = function(returnValue) {
|
|
11
|
+
this.open = false
|
|
12
|
+
this.returnValue = returnValue
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
})
|
package/src/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import BaseCustomElement from "./BaseCustomElement"
|
|
2
|
+
import RichString from "./RichString"
|
|
3
|
+
import AjaxSubmit from "./AjaxSubmit"
|
|
4
|
+
import ConfirmSubmit from "./ConfirmSubmit"
|
|
5
|
+
import ConfirmationDialog from "./ConfirmationDialog"
|
|
6
|
+
import ConstraintViolationMessage from "./ConstraintViolationMessage"
|
|
7
|
+
import ConstraintViolationMessages from "./ConstraintViolationMessages"
|
|
8
|
+
import Form from "./Form"
|
|
9
|
+
import I18nTranslation from "./I18nTranslation"
|
|
10
|
+
import Message from "./Message"
|
|
11
|
+
import Tabs from "./Tabs"
|
|
12
|
+
import LocaleDetection from "./LocaleDetection"
|
|
13
|
+
import Autosubmit from "./Autosubmit"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* This is the code for a test case. It may return a {@link external:Promise} if there is async behavior that must
|
|
17
|
+
* be waited-on to properly assert behavior.
|
|
18
|
+
*
|
|
19
|
+
* @callback testCodeCallback
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} objects - objects passed into your test that you may need.
|
|
22
|
+
* @param {Window} objects.window - Access to the top-level window object. Note that this provided by JSDOM and is not exactly like the `Window` you'd get in your browser.
|
|
23
|
+
* @param {Document} objects.document - Access to the top-level document object. Note that this provided by JSDOM and is not exactly like the `Document` you'd get in your browser.
|
|
24
|
+
* @param {Object} objects.assert - The NodeJS assert object that you should use to assert behavior.
|
|
25
|
+
* @param {Object} objects.fetchRequests - An array of `Request` instances given to `fetch`. This will be updated as `fetch` is
|
|
26
|
+
* called and can be useful to assert the contents of what was requested via `fetch`.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* test("some test", ({document,assert}) => {
|
|
30
|
+
* const element = document.querySelector("div")
|
|
31
|
+
* assert(div.getAttribute("data-foo") != null)
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* test("some other test", ({document,window,assert}) => {
|
|
36
|
+
* const element = document.querySelector("div")
|
|
37
|
+
* assert.equal(window.history.state["foo"], "bar")
|
|
38
|
+
* })
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @external Promise
|
|
43
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise|Promise}
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* @external fetch
|
|
47
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch|fetch}
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @external ValidityState
|
|
52
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ValidityState|ValidityState}
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The standard `CustomElementRegistry`
|
|
57
|
+
*
|
|
58
|
+
* @external CustomElementRegistry
|
|
59
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry|CustomElementRegistry}
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @external Window
|
|
64
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/|Window}
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @method confirm
|
|
69
|
+
* @memberof external:Window#
|
|
70
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm|confirm}
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Class that can be used to automatically define all of brut's custom
|
|
75
|
+
* elements.
|
|
76
|
+
*/
|
|
77
|
+
class BrutCustomElements {
|
|
78
|
+
static elementClasses = []
|
|
79
|
+
static define() {
|
|
80
|
+
this.elementClasses.forEach( (e) => {
|
|
81
|
+
e.define()
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
static addElementClasses(...classes) {
|
|
85
|
+
this.elementClasses.push(...classes)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
BrutCustomElements.addElementClasses(
|
|
90
|
+
// Ordering is important here - TBD how to make sure these are created in order
|
|
91
|
+
I18nTranslation,
|
|
92
|
+
Message,
|
|
93
|
+
ConfirmSubmit,
|
|
94
|
+
ConfirmationDialog,
|
|
95
|
+
ConstraintViolationMessages,
|
|
96
|
+
Form,
|
|
97
|
+
AjaxSubmit,
|
|
98
|
+
ConstraintViolationMessage,
|
|
99
|
+
Tabs,
|
|
100
|
+
LocaleDetection,
|
|
101
|
+
Autosubmit,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
AjaxSubmit,
|
|
106
|
+
Autosubmit,
|
|
107
|
+
BaseCustomElement,
|
|
108
|
+
BrutCustomElements,
|
|
109
|
+
ConfirmSubmit,
|
|
110
|
+
ConfirmationDialog,
|
|
111
|
+
ConstraintViolationMessage,
|
|
112
|
+
ConstraintViolationMessages,
|
|
113
|
+
Form,
|
|
114
|
+
I18nTranslation,
|
|
115
|
+
LocaleDetection,
|
|
116
|
+
Message,
|
|
117
|
+
RichString,
|
|
118
|
+
Tabs
|
|
119
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enables and implements a basic testing system for custom elements. This uses JSDOM and some hacks to allow
|
|
3
|
+
* you to test your custom elements using a DOM-like experience, but without having to run a real browser.
|
|
4
|
+
* It is designed to have very few dependencies to ensure the least amount of configuration and tool
|
|
5
|
+
* debt.
|
|
6
|
+
*
|
|
7
|
+
* It assumes you are using Mocha. To create a test, create a file named `Whatever.spec.js` in the folder where you are running
|
|
8
|
+
* Mocha. Convention is that `Whatever` is the name of your custom element's class.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { withHTML } from "../src/testing/index.js"
|
|
12
|
+
*
|
|
13
|
+
* describe("<some-element>", () => {
|
|
14
|
+
* withHTML(`
|
|
15
|
+
* <some-element>OK</some-element>
|
|
16
|
+
* `).test("lower-cases its contents", ({document,assert}) => {
|
|
17
|
+
* const element = document.querySelector("some-element")
|
|
18
|
+
* assert.equal(element.textContent,"ok")
|
|
19
|
+
* })
|
|
20
|
+
* })
|
|
21
|
+
*
|
|
22
|
+
* @module testing
|
|
23
|
+
*/
|
|
24
|
+
import { JSDOM, ResourceLoader, VirtualConsole } from "jsdom"
|
|
25
|
+
import fs from "node:fs"
|
|
26
|
+
import path from "node:path"
|
|
27
|
+
import assert from "assert"
|
|
28
|
+
|
|
29
|
+
const __dirname = import.meta.dirname
|
|
30
|
+
|
|
31
|
+
const appRoot = path.resolve(__dirname,"..","..","specs")
|
|
32
|
+
const publicRoot = path.resolve(appRoot,"public")
|
|
33
|
+
|
|
34
|
+
class AssetMetadata {
|
|
35
|
+
constructor(parsedJSON, publicRoot) {
|
|
36
|
+
this.assetMetadata = parsedJSON.asset_metadata
|
|
37
|
+
this.publicRoot = publicRoot
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
scriptURLs() {
|
|
41
|
+
return Object.entries(this.assetMetadata[".js"]).map( (entry) => {
|
|
42
|
+
return entry[0]
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fileContainingScriptURL(scriptURL) {
|
|
47
|
+
const file = Object.entries(this.assetMetadata[".js"]).find( (entry) => {
|
|
48
|
+
return entry[0] == scriptURL
|
|
49
|
+
})
|
|
50
|
+
if (!file || !file[1]) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
let relativePath = file[1]
|
|
54
|
+
if (relativePath[0] == "/") {
|
|
55
|
+
relativePath = relativePath.slice(1)
|
|
56
|
+
}
|
|
57
|
+
return fs.readFileSync(path.resolve(this.publicRoot,relativePath))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const assetMetadata = new AssetMetadata(
|
|
62
|
+
JSON.parse(fs.readFileSync(path.resolve(appRoot,"config","asset_metadata.json"))),
|
|
63
|
+
publicRoot,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
class AssetMetadataLoader extends ResourceLoader {
|
|
67
|
+
constructor(assetMetadata) {
|
|
68
|
+
super()
|
|
69
|
+
this.assetMetadata = assetMetadata
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fetch(url,options) {
|
|
73
|
+
const parsedURL = new URL(url)
|
|
74
|
+
const jsContents = this.assetMetadata.fileContainingScriptURL(parsedURL.pathname)
|
|
75
|
+
if (jsContents) {
|
|
76
|
+
return Promise.resolve(jsContents)
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
return super.fetch(url,options)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const resourceLoader = new AssetMetadataLoader(assetMetadata)
|
|
85
|
+
|
|
86
|
+
const createDOM = (html, queryString) => {
|
|
87
|
+
|
|
88
|
+
const url = "http://example.com" + ( queryString ? `?${queryString}` : "" )
|
|
89
|
+
|
|
90
|
+
const scripts = assetMetadata.scriptURLs().map( (url) => `<script src="${url}"></script>` )
|
|
91
|
+
const virtualConsole = new VirtualConsole()
|
|
92
|
+
virtualConsole.sendTo(console);
|
|
93
|
+
return new JSDOM(
|
|
94
|
+
`<!DOCTYPE html>
|
|
95
|
+
<html>
|
|
96
|
+
<head>
|
|
97
|
+
${scripts}
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
${html}
|
|
101
|
+
</body>
|
|
102
|
+
</html>
|
|
103
|
+
`,{
|
|
104
|
+
resources: "usable",
|
|
105
|
+
runScripts: "dangerously",
|
|
106
|
+
includeNodeLocations: true,
|
|
107
|
+
resources: resourceLoader,
|
|
108
|
+
url: url
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returned by {@link withHTML} as the basis for writing tests for custom elements. You won't create
|
|
115
|
+
* instances of this class. Rather, you'll be given an instance from `withHTML` and likely call
|
|
116
|
+
* `test` on it.
|
|
117
|
+
*
|
|
118
|
+
* @see withHTML
|
|
119
|
+
*/
|
|
120
|
+
class CustomElementTest {
|
|
121
|
+
constructor(html, queryString) {
|
|
122
|
+
this.html = html
|
|
123
|
+
this.queryString = queryString
|
|
124
|
+
this.fetchBehavior = {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Configure a query string to be present when the custom elements are defined and connected.
|
|
129
|
+
*/
|
|
130
|
+
andQueryString(queryString) {
|
|
131
|
+
this.queryString = queryString
|
|
132
|
+
return this
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Configure behavior when #{link external:fetch} is called, since it's not implemented by JSDom or NodeJS.
|
|
137
|
+
*
|
|
138
|
+
* @param {String} url - the URL that is expected. This should be a relative URL, as that is all that is currently supported. This
|
|
139
|
+
* URL must match exactly to a `fetch` call.
|
|
140
|
+
* @param {Object} behavior - an object describing what you want to happen when `url` is fetched. This currently supports only a
|
|
141
|
+
* few rudimentary behaviors:
|
|
142
|
+
* * `{then: { ok: { text: "some text" } } }` - This will return an "ok" response whose body is the given text, available only as
|
|
143
|
+
* text.
|
|
144
|
+
* * `{then: { status: XXX, text: "some text" }}` - This will return the given http status, with `ok` as true if it's 2xx or 3xx. If `text` is given, that will be available as text only.
|
|
145
|
+
*/
|
|
146
|
+
onFetch(url,behavior) {
|
|
147
|
+
if (!this.fetchBehavior[url]) {
|
|
148
|
+
this.fetchBehavior[url] = {
|
|
149
|
+
numCalls: 0,
|
|
150
|
+
responses: [],
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (behavior instanceof Array) {
|
|
154
|
+
behavior.forEach( (b) => {
|
|
155
|
+
this.fetchBehavior[url].responses.push(b)
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
this.fetchBehavior[url].responses.push(behavior)
|
|
160
|
+
}
|
|
161
|
+
return this
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Comment out a test without using code comments */
|
|
165
|
+
xtest() {
|
|
166
|
+
return this
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/** Declare a test to run with the previously-defined HTML, query string, and fetch behavior.
|
|
171
|
+
*
|
|
172
|
+
* @param {String} description - a description of the test.
|
|
173
|
+
* @param {testCodeCallback} testCode - a function containing the code for your test.
|
|
174
|
+
*/
|
|
175
|
+
test(description,testCode) {
|
|
176
|
+
it(description, () => {
|
|
177
|
+
const dom = createDOM(this.html,this.queryString)
|
|
178
|
+
const fetchRequests = []
|
|
179
|
+
|
|
180
|
+
dom.window.Request = Request
|
|
181
|
+
dom.window.fetch = (request) => {
|
|
182
|
+
const url = new URL(request.url)
|
|
183
|
+
const path = url.pathname + url.search
|
|
184
|
+
const behaviors = this.fetchBehavior[path]
|
|
185
|
+
if (!behaviors) {
|
|
186
|
+
throw `fetch() called with ${path}, which was not configured`
|
|
187
|
+
}
|
|
188
|
+
if (behaviors.numCalls > behaviors.responses.length) {
|
|
189
|
+
throw `fetch() called ${behaviors.numCalls} times, but we only have ${behaviors.response.length} responses configured`
|
|
190
|
+
}
|
|
191
|
+
const behavior = behaviors.responses[behaviors.numCalls]
|
|
192
|
+
behaviors.numCalls++
|
|
193
|
+
|
|
194
|
+
let promise = null
|
|
195
|
+
|
|
196
|
+
if (behavior.then) {
|
|
197
|
+
if (behavior.then.ok) {
|
|
198
|
+
if (behavior.then.ok.text) {
|
|
199
|
+
const response = {
|
|
200
|
+
ok: true,
|
|
201
|
+
text: () => {
|
|
202
|
+
return Promise.resolve(behavior.then.ok.text)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
promise = Promise.resolve(response)
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
throw `unknown fetch behavior: expected then.ok.text: ${JSON.stringify(behavior)}`
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (behavior.then.status) {
|
|
212
|
+
let ok = false
|
|
213
|
+
if ((behavior.then.status >= 200) && (behavior.then.status < 400) ){
|
|
214
|
+
ok = true
|
|
215
|
+
}
|
|
216
|
+
const response = {
|
|
217
|
+
ok: ok,
|
|
218
|
+
status: behavior.then.status,
|
|
219
|
+
text: () => {
|
|
220
|
+
return Promise.resolve(behavior.then.text)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
promise = Promise.resolve(response)
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
throw `unknown fetch behavior: expected then.ok or then.status: ${JSON.stringify(behavior)}`
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
throw `unknown fetch behavior: expected then: ${JSON.stringify(behavior)}`
|
|
231
|
+
}
|
|
232
|
+
request.promiseReturned = promise
|
|
233
|
+
fetchRequests.push(request)
|
|
234
|
+
return promise
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const window = dom.window
|
|
238
|
+
const document = window.document
|
|
239
|
+
let returnValue = null
|
|
240
|
+
return new Promise( (resolve, reject) => {
|
|
241
|
+
dom.window.addEventListener("load", () => {
|
|
242
|
+
try {
|
|
243
|
+
returnValue = testCode({window,document,assert,fetchRequests})
|
|
244
|
+
if (returnValue) {
|
|
245
|
+
resolve(returnValue)
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
resolve()
|
|
249
|
+
}
|
|
250
|
+
} catch (e) {
|
|
251
|
+
reject(e)
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
return this
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Sets up the HTML in which a test will be run. This allows you to configure what HTML will be "served"
|
|
262
|
+
* by JSDOM before any custom elements are defined or connected.
|
|
263
|
+
*
|
|
264
|
+
* This returns a {@link module:testing~CustomElementTest}, on which you can call additional setup methods, or start defining tests with the
|
|
265
|
+
* {@link module:testing~CustomElementTest#test} method.
|
|
266
|
+
*
|
|
267
|
+
* @see module:testing~CustomElementTest
|
|
268
|
+
*/
|
|
269
|
+
const withHTML = (html) => new CustomElementTest(html)
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Given a fetch request (available via `fetchRequests` passed to your test), turn the body into JSON
|
|
273
|
+
* and return a promise with the JSON. To use this in a test, you must include your assertions inside
|
|
274
|
+
* the `then` of the returned promise and you *must* return that from your test.
|
|
275
|
+
*
|
|
276
|
+
* @example
|
|
277
|
+
* withHTML(
|
|
278
|
+
* "<div-makes-a-fetch>"
|
|
279
|
+
* ).onFetch("/foo", { then: { status: 200 }}
|
|
280
|
+
* ).test("a test",({assert,fetchRequests}) => {
|
|
281
|
+
* assert.equal(fetchRequests.length,1)
|
|
282
|
+
* return readRequestBodyIntoJSON(fetchRequests[0]).then( (json) => {
|
|
283
|
+
* assert.equal(json["foo"],"bar")
|
|
284
|
+
* })
|
|
285
|
+
* })
|
|
286
|
+
*/
|
|
287
|
+
const readRequestBodyIntoJSON = (request) => {
|
|
288
|
+
return readRequestBodyIntoString(request).then( (string) => {
|
|
289
|
+
try {
|
|
290
|
+
const json = JSON.parse(string)
|
|
291
|
+
return Promise.resolve(json)
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
assert(false,`'${string}' could not be parsed as JSON`)
|
|
295
|
+
}
|
|
296
|
+
return Promise.resolve()
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Given a fetch request (available via `fetchRequests` passed to your test), turn the body into a string
|
|
302
|
+
* and return a promise with the string. To use this in a test, you must include your assertions inside
|
|
303
|
+
* the `then` of the returned promise and you *must* return that from your test.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* withHTML(
|
|
307
|
+
* "<div-makes-a-fetch>"
|
|
308
|
+
* ).onFetch("/foo", { then: { status: 200 }}
|
|
309
|
+
* ).test("a test",({assert,fetchRequests}) => {
|
|
310
|
+
* assert.equal(fetchRequests.length,1)
|
|
311
|
+
* return readRequestBodyIntoString(fetchRequests[0]).then( (string) => {
|
|
312
|
+
* assert.equal(string,"foo")
|
|
313
|
+
* })
|
|
314
|
+
* })
|
|
315
|
+
*/
|
|
316
|
+
const readRequestBodyIntoString = (request) => {
|
|
317
|
+
const reader = request.body.getReader()
|
|
318
|
+
const utf8Decoder = new TextDecoder("utf-8");
|
|
319
|
+
let string = ""
|
|
320
|
+
const parse = ({value,done}) => {
|
|
321
|
+
if (done) {
|
|
322
|
+
return Promise.resolve(string)
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
if (value) {
|
|
326
|
+
string = string + utf8Decoder.decode(value, { stream: true })
|
|
327
|
+
}
|
|
328
|
+
return reader.read().then(parse)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return reader.read().then(parse)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Used to wait for a few milliseconds before performing further assertions.
|
|
335
|
+
* This can be useful when a custom element has `setTimeout` calls
|
|
336
|
+
*/
|
|
337
|
+
const waitForSetTimeout = (ms) => {
|
|
338
|
+
return new Promise( (resolve) => {
|
|
339
|
+
setTimeout(resolve,ms)
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export {
|
|
344
|
+
withHTML,
|
|
345
|
+
readRequestBodyIntoJSON,
|
|
346
|
+
readRequestBodyIntoString,
|
|
347
|
+
waitForSetTimeout,
|
|
348
|
+
}
|