brut-js 0.0.2 → 0.0.5

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/src/Form.js CHANGED
@@ -88,9 +88,23 @@ class Form extends BaseCustomElement {
88
88
  #updateErrorMessages(event) {
89
89
  const element = event.target
90
90
  const selector = `${ConstraintViolationMessages.tagName}:not([server-side])`
91
- const errorLabels = element.parentNode.querySelectorAll(selector)
91
+ let errorLabels = element.parentNode.querySelectorAll(selector)
92
+ if (errorLabels.length == 0) {
93
+ if (element.name && element.form) {
94
+ const moreGeneralSelector = `${ConstraintViolationMessages.tagName}[input-name='${element.name}']:not([server-side])`
95
+ errorLabels = element.form.querySelectorAll(moreGeneralSelector)
96
+ if (errorLabels.length == 0) {
97
+ this.logger.warn(`Did not find any elements matching ${selector} or ${moreGeneralSelector}, so no error messages will be shown`)
98
+ }
99
+ }
100
+ else {
101
+ this.logger.warn("Did not find any elements matching %s and the form element has %s %s",
102
+ selector,
103
+ element.name ? "no name" : "a name, but",
104
+ element.form ? "no form" : "though has a form")
105
+ }
106
+ }
92
107
  if (errorLabels.length == 0) {
93
- this.logger.warn(`Did not find any elements matching ${selector}, so no error messages will be shown`)
94
108
  return
95
109
  }
96
110
  let anyErrors = false
@@ -0,0 +1,35 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ /**
4
+ * Provides structured access to the asset metadata for a BrutRB app. This is used to allow custom elements
5
+ * to be defined in JSDOM the same way they are in a real browser.
6
+ *
7
+ * @memberof testing
8
+ */
9
+ class AssetMetadata {
10
+ constructor(parsedJSON, publicRoot) {
11
+ this.assetMetadata = parsedJSON.asset_metadata
12
+ this.publicRoot = publicRoot
13
+ }
14
+
15
+ scriptURLs() {
16
+ return Object.entries(this.assetMetadata[".js"]).map( (entry) => {
17
+ return entry[0]
18
+ })
19
+ }
20
+
21
+ fileContainingScriptURL(scriptURL) {
22
+ const file = Object.entries(this.assetMetadata[".js"]).find( (entry) => {
23
+ return entry[0] == scriptURL
24
+ })
25
+ if (!file || !file[1]) {
26
+ return null
27
+ }
28
+ let relativePath = file[1]
29
+ if (relativePath[0] == "/") {
30
+ relativePath = relativePath.slice(1)
31
+ }
32
+ return fs.readFileSync(path.resolve(this.publicRoot,relativePath))
33
+ }
34
+ }
35
+ export default AssetMetadata
@@ -0,0 +1,25 @@
1
+ import { ResourceLoader } from "jsdom"
2
+
3
+ /**
4
+ * An JSDOM resource loader based on BrutRB asset metadata.
5
+ *
6
+ * @memberof testing
7
+ */
8
+ class AssetMetadataLoader extends ResourceLoader {
9
+ constructor(assetMetadata) {
10
+ super()
11
+ this.assetMetadata = assetMetadata
12
+ }
13
+
14
+ fetch(url,options) {
15
+ const parsedURL = new URL(url)
16
+ const jsContents = this.assetMetadata.fileContainingScriptURL(parsedURL.pathname)
17
+ if (jsContents) {
18
+ return Promise.resolve(jsContents)
19
+ }
20
+ else {
21
+ return super.fetch(url,options)
22
+ }
23
+ }
24
+ }
25
+ export default AssetMetadataLoader
@@ -0,0 +1,235 @@
1
+ import DOMCreator from "./DOMCreator.js"
2
+ import assert from "assert"
3
+ /**
4
+ * The class that implements a test case. Typically, an instance of this is created for you and you call `test` on that to write your
5
+ * test case.
6
+ * @memberof testing
7
+ */
8
+ class CustomElementTest {
9
+ constructor(html, queryString, assetMetadata) {
10
+ this.html = html
11
+ this.queryString = queryString
12
+ this.fetchBehavior = {}
13
+ this.assetMetadata = assetMetadata
14
+ }
15
+
16
+ /**
17
+ * Configure a query string to be present when the custom elements are defined and connected.
18
+ */
19
+ andQueryString(queryString) {
20
+ this.queryString = queryString
21
+ return this
22
+ }
23
+
24
+ /**
25
+ * Configure behavior when #{link external:fetch} is called, since it's not implemented by JSDom or NodeJS.
26
+ *
27
+ * @param {String} url - the URL that is expected. This should be a relative URL, as that is all that is currently supported. This
28
+ * URL must match exactly to a `fetch` call.
29
+ * @param {Object} behavior - an object describing what you want to happen when `url` is fetched. This currently supports only a
30
+ * few rudimentary behaviors:
31
+ * * `{then: { ok: { text: "some text" } } }` - This will return an "ok" response whose body is the given text, available only as
32
+ * text.
33
+ * * `{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.
34
+ */
35
+ onFetch(url,behavior) {
36
+ if (!this.fetchBehavior[url]) {
37
+ this.fetchBehavior[url] = {
38
+ numCalls: 0,
39
+ responses: [],
40
+ }
41
+ }
42
+ if (behavior instanceof Array) {
43
+ behavior.forEach( (b) => {
44
+ this.fetchBehavior[url].responses.push(b)
45
+ })
46
+ }
47
+ else {
48
+ this.fetchBehavior[url].responses.push(behavior)
49
+ }
50
+ return this
51
+ }
52
+
53
+ /** Comment out a test without using code comments */
54
+ xtest() {
55
+ return this
56
+ }
57
+
58
+
59
+ /** Declare a test to run with the previously-defined HTML, query string, and fetch behavior.
60
+ *
61
+ * @param {String} description - a description of the test.
62
+ * @param {testCodeCallback} testCode - a function containing the code for your test.
63
+ */
64
+ test(description,testCode) {
65
+ it(description, () => {
66
+ const domCreator = new DOMCreator(this.assetMetadata)
67
+ const dom = domCreator.create({
68
+ html: this.html,
69
+ queryString: this.queryString
70
+ })
71
+ const fetchRequests = []
72
+
73
+ dom.window.Request = Request
74
+ dom.window.fetch = (request) => {
75
+ const url = new URL(request.url)
76
+ const path = url.pathname + url.search
77
+ const behaviors = this.fetchBehavior[path]
78
+ if (!behaviors) {
79
+ throw `fetch() called with ${path}, which was not configured`
80
+ }
81
+ if (behaviors.numCalls > behaviors.responses.length) {
82
+ throw `fetch() called ${behaviors.numCalls} times, but we only have ${behaviors.response.length} responses configured`
83
+ }
84
+ const behavior = behaviors.responses[behaviors.numCalls]
85
+ behaviors.numCalls++
86
+
87
+ let promise = null
88
+
89
+ if (behavior.then) {
90
+ if (behavior.then.ok) {
91
+ if (behavior.then.ok.text) {
92
+ const response = {
93
+ ok: true,
94
+ text: () => {
95
+ return Promise.resolve(behavior.then.ok.text)
96
+ }
97
+ }
98
+ promise = Promise.resolve(response)
99
+ }
100
+ else {
101
+ throw `unknown fetch behavior: expected then.ok.text: ${JSON.stringify(behavior)}`
102
+ }
103
+ }
104
+ else if (behavior.then.status) {
105
+ let ok = false
106
+ if ((behavior.then.status >= 200) && (behavior.then.status < 400) ){
107
+ ok = true
108
+ }
109
+ const response = {
110
+ ok: ok,
111
+ status: behavior.then.status,
112
+ text: () => {
113
+ return Promise.resolve(behavior.then.text)
114
+ }
115
+ }
116
+ promise = Promise.resolve(response)
117
+ }
118
+ else {
119
+ throw `unknown fetch behavior: expected then.ok or then.status: ${JSON.stringify(behavior)}`
120
+ }
121
+ }
122
+ else {
123
+ throw `unknown fetch behavior: expected then: ${JSON.stringify(behavior)}`
124
+ }
125
+ request.promiseReturned = promise
126
+ fetchRequests.push(request)
127
+ return promise
128
+ }
129
+
130
+ const window = dom.window
131
+ const document = window.document
132
+ let returnValue = null
133
+ return new Promise( (resolve, reject) => {
134
+ dom.window.addEventListener("load", () => {
135
+ try {
136
+ const paramsGivenToTest = {
137
+ window,
138
+ document,
139
+ assert,
140
+ fetchRequests,
141
+ waitForSetTimeout: this.#waitForSetTimeout,
142
+ readRequestBodyIntoJSON: this.#readRequestBodyIntoJSON,
143
+ readRequestBodyIntoString: this.#readRequestBodyIntoString,
144
+ }
145
+ returnValue = testCode(paramsGivenToTest)
146
+ if (returnValue) {
147
+ resolve(returnValue)
148
+ }
149
+ else {
150
+ resolve()
151
+ }
152
+ } catch (e) {
153
+ reject(e)
154
+ }
155
+ })
156
+ })
157
+ })
158
+ return this
159
+ }
160
+
161
+ /** Used to wait for a few milliseconds before performing further assertions.
162
+ * This can be useful when a custom element has `setTimeout` calls
163
+ */
164
+ #waitForSetTimeout = (ms) => {
165
+ return new Promise( (resolve) => {
166
+ setTimeout(resolve,ms)
167
+ })
168
+ }
169
+
170
+ /**
171
+ * Given a fetch request (available via `fetchRequests` passed to your test), turn the body into JSON
172
+ * and return a promise with the JSON. To use this in a test, you must include your assertions inside
173
+ * the `then` of the returned promise and you *must* return that from your test.
174
+ *
175
+ * @example
176
+ * withHTML(
177
+ * "<div-makes-a-fetch>"
178
+ * ).onFetch("/foo", { then: { status: 200 }}
179
+ * ).test("a test",({assert,fetchRequests}) => {
180
+ * assert.equal(fetchRequests.length,1)
181
+ * return readRequestBodyIntoJSON(fetchRequests[0]).then( (json) => {
182
+ * assert.equal(json["foo"],"bar")
183
+ * })
184
+ * })
185
+ */
186
+ #readRequestBodyIntoJSON = (request) => {
187
+ return this.#readRequestBodyIntoString(request).then( (string) => {
188
+ try {
189
+ const json = JSON.parse(string)
190
+ return Promise.resolve(json)
191
+ }
192
+ catch (e) {
193
+ assert(false,`'${string}' could not be parsed as JSON`)
194
+ }
195
+ return Promise.resolve()
196
+ })
197
+ }
198
+
199
+ /**
200
+ * Given a fetch request (available via `fetchRequests` passed to your test), turn the body into a string
201
+ * and return a promise with the string. To use this in a test, you must include your assertions inside
202
+ * the `then` of the returned promise and you *must* return that from your test.
203
+ *
204
+ * @example
205
+ * withHTML(
206
+ * "<div-makes-a-fetch>"
207
+ * ).onFetch("/foo", { then: { status: 200 }}
208
+ * ).test("a test",({assert,fetchRequests}) => {
209
+ * assert.equal(fetchRequests.length,1)
210
+ * return readRequestBodyIntoString(fetchRequests[0]).then( (string) => {
211
+ * assert.equal(string,"foo")
212
+ * })
213
+ * })
214
+ */
215
+ #readRequestBodyIntoString = (request) => {
216
+ const reader = request.body.getReader()
217
+ const utf8Decoder = new TextDecoder("utf-8");
218
+ let string = ""
219
+ const parse = ({value,done}) => {
220
+ if (done) {
221
+ return Promise.resolve(string)
222
+ }
223
+ else {
224
+ if (value) {
225
+ string = string + utf8Decoder.decode(value, { stream: true })
226
+ }
227
+ return reader.read().then(parse)
228
+ }
229
+ }
230
+ return reader.read().then(parse)
231
+ }
232
+
233
+
234
+ }
235
+ export default CustomElementTest
@@ -0,0 +1,45 @@
1
+ import { JSDOM, VirtualConsole } from "jsdom"
2
+
3
+ import AssetMetadataLoader from "./AssetMetadataLoader.js"
4
+
5
+ /**
6
+ * Creates a JSDOM based on the the given HTML and query string.
7
+ *
8
+ * @memberof testing
9
+ */
10
+ class DOMCreator {
11
+ constructor(assetMetadata) {
12
+ this.assetMetadata = assetMetadata
13
+ }
14
+
15
+ create({html,queryString}) {
16
+
17
+ const resourceLoader = new AssetMetadataLoader(this.assetMetadata)
18
+
19
+
20
+ const url = "http://example.com" + ( queryString ? `?${queryString}` : "" )
21
+
22
+ const scripts = this.assetMetadata.scriptURLs().map( (url) => `<script src="${url}"></script>` )
23
+ const virtualConsole = new VirtualConsole()
24
+ virtualConsole.sendTo(console);
25
+ return new JSDOM(
26
+ `<!DOCTYPE html>
27
+ <html>
28
+ <head>
29
+ ${scripts}
30
+ </head>
31
+ <body>
32
+ ${html}
33
+ </body>
34
+ </html>
35
+ `,{
36
+ resources: "usable",
37
+ runScripts: "dangerously",
38
+ includeNodeLocations: true,
39
+ resources: resourceLoader,
40
+ url: url
41
+ }
42
+ )
43
+ }
44
+ }
45
+ export default DOMCreator