rails-jsdom 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +351 -0
- data/lib/generators/rails_jsdom/install/install_generator.rb +108 -0
- data/lib/rails_jsdom/bridge.rb +80 -0
- data/lib/rails_jsdom/element.rb +186 -0
- data/lib/rails_jsdom/page.rb +33 -0
- data/lib/rails_jsdom/query.rb +76 -0
- data/lib/rails_jsdom/runner.js +3 -0
- data/lib/rails_jsdom/runtime.js +926 -0
- data/lib/rails_jsdom/test_case.rb +29 -0
- data/lib/rails_jsdom/test_helper.rb +767 -0
- data/lib/rails_jsdom/turbo_stream_deliveries.rb +25 -0
- data/lib/rails_jsdom/version.rb +4 -0
- data/lib/rails_jsdom.rb +83 -0
- metadata +158 -0
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs")
|
|
4
|
+
const path = require("node:path")
|
|
5
|
+
const readline = require("node:readline")
|
|
6
|
+
const vm = require("node:vm")
|
|
7
|
+
const { createRequire } = require("node:module")
|
|
8
|
+
|
|
9
|
+
const STUB_NAMESPACE = "rails-jsdom-stub"
|
|
10
|
+
const STUB_SOURCE = "module.exports = Object.create(new Proxy({}, { get: (target, key) => typeof key === 'symbol' || key === 'then' || key === '__esModule' ? undefined : () => {} }))"
|
|
11
|
+
const INLINE_ELEMENTS = new Set(["a", "abbr", "b", "bdi", "bdo", "cite", "code", "data", "dfn", "em", "i", "kbd", "label", "mark", "output", "q", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var"])
|
|
12
|
+
const CLICKABLE = "button, input[type=submit], input[type=button], input[type=reset], input[type=image], input[type=checkbox], input[type=radio], a[href], summary, label, [role=button]"
|
|
13
|
+
const BUTTON_INPUTS = "input[type=submit], input[type=button], input[type=reset], input[type=image]"
|
|
14
|
+
const FIELDS = "input:not([type=hidden]), textarea, select"
|
|
15
|
+
const FILLABLE = "input:not([type=hidden], [type=checkbox], [type=radio], [type=file], [type=submit], [type=button], [type=reset], [type=image]), textarea"
|
|
16
|
+
const KINDS = { link: "a[href]", button: `button, ${BUTTON_INPUTS}`, link_or_button: `a[href], button, ${BUTTON_INPUTS}` }
|
|
17
|
+
const DIALOG_HANDLERS = { alert: "accept_alert", confirm: "accept_confirm or dismiss_confirm", prompt: "accept_prompt or dismiss_prompt" }
|
|
18
|
+
const UNANSWERED = { alert: undefined, confirm: false, prompt: null }
|
|
19
|
+
const QUERY_KINDS = { css: "*", field: FIELDS, button: KINDS.button, link: KINDS.link }
|
|
20
|
+
const FOCUSABLE = "a[href], button, input:not([type=hidden]), select, textarea, [tabindex], [contenteditable]"
|
|
21
|
+
const PRESSABLE = "button, input[type=checkbox], input[type=radio], input[type=submit], input[type=button], input[type=reset]"
|
|
22
|
+
const STEPPABLE = "input[type=range], input[type=number]"
|
|
23
|
+
const KEYS = { enter: "Enter", tab: "Tab", backspace: "Backspace", delete: "Delete", escape: "Escape", space: " ", left: "ArrowLeft", right: "ArrowRight", up: "ArrowUp", down: "ArrowDown", home: "Home", end: "End", page_up: "PageUp", page_down: "PageDown" }
|
|
24
|
+
const MODIFIERS = { shift: "shiftKey", control: "ctrlKey", ctrl: "ctrlKey", alt: "altKey", meta: "metaKey", command: "metaKey" }
|
|
25
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304])
|
|
26
|
+
const SETTLE_LIMIT = 10000
|
|
27
|
+
const STREAM_SOURCES = "turbo-cable-stream-source[signed-stream-name][channel='Turbo::StreamsChannel'], turbo-cable-stream-source[signed-stream-name]:not([channel])"
|
|
28
|
+
|
|
29
|
+
const replies = new Map()
|
|
30
|
+
const fetchStubs = new Map()
|
|
31
|
+
const pending = new Set()
|
|
32
|
+
const errors = []
|
|
33
|
+
const owners = new WeakMap()
|
|
34
|
+
let nextFetchId = 1
|
|
35
|
+
let nextPageId = 1
|
|
36
|
+
let config = null
|
|
37
|
+
let jsdom = null
|
|
38
|
+
let bundle = null
|
|
39
|
+
let page = null
|
|
40
|
+
const sessions = new Map()
|
|
41
|
+
let session = sessionNamed("default")
|
|
42
|
+
let modal = null
|
|
43
|
+
let modalMessages = []
|
|
44
|
+
|
|
45
|
+
let output = null
|
|
46
|
+
const send = message => output.write(`${JSON.stringify(message)}\n`)
|
|
47
|
+
const tick = () => new Promise(resolve => setTimeout(resolve, 0))
|
|
48
|
+
const normalize = text => String(text ?? "").replace(/\s+/g, " ").trim()
|
|
49
|
+
|
|
50
|
+
let queue = Promise.resolve()
|
|
51
|
+
|
|
52
|
+
function start(io) {
|
|
53
|
+
output = io.stdout
|
|
54
|
+
for (const method of ["log", "info", "warn", "error", "debug"]) {
|
|
55
|
+
console[method] = (...args) => io.stderr.write(`${args.map(String).join(" ")}\n`)
|
|
56
|
+
}
|
|
57
|
+
io.on("unhandledRejection", reason => errors.push(reason))
|
|
58
|
+
const input = readline.createInterface({ input: io.stdin })
|
|
59
|
+
input.on("line", line => {
|
|
60
|
+
const message = JSON.parse(line)
|
|
61
|
+
if (message.type === "fetch_response") {
|
|
62
|
+
const resolve = replies.get(message.id)
|
|
63
|
+
replies.delete(message.id)
|
|
64
|
+
resolve(message.response)
|
|
65
|
+
} else {
|
|
66
|
+
queue = queue.then(() => handle(message))
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
input.on("close", () => io.exit(0))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function handle(message) {
|
|
73
|
+
try {
|
|
74
|
+
send({ type: "result", value: await perform(message) })
|
|
75
|
+
} catch (error) {
|
|
76
|
+
send({ type: "error", message: describeError(error) })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function describeError(error) {
|
|
81
|
+
return error && error.stack ? String(error.stack) : String(error)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const READS = {
|
|
85
|
+
setup: message => setup(message),
|
|
86
|
+
snapshot: message => snapshot(message.scope),
|
|
87
|
+
query: message => query(message),
|
|
88
|
+
read: message => describe(elementFor(message.handle)),
|
|
89
|
+
session: message => switchSession(message.name),
|
|
90
|
+
stub_fetch: message => stubFetch(message.url, message.response),
|
|
91
|
+
reset: () => reset(),
|
|
92
|
+
streams: () => streams(),
|
|
93
|
+
expect_modal: message => expectModal(message),
|
|
94
|
+
modal_messages: () => takeModalMessages()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ACTIONS = {
|
|
98
|
+
load: message => loadFresh(message.html, message.url, message.history),
|
|
99
|
+
click: message => click(find(message, byClickable, KINDS[message.kind])),
|
|
100
|
+
hover: message => hover(find(message, byClickable)),
|
|
101
|
+
double_click: message => doubleClick(find(message, byClickable)),
|
|
102
|
+
right_click: message => rightClick(find(message, byClickable)),
|
|
103
|
+
fill_in: message => fill(find(message, byField, FILLABLE), message.with),
|
|
104
|
+
check: message => setChecked(find(message, byField, "input[type=checkbox]"), message.checked),
|
|
105
|
+
choose: message => setChecked(find(message, byField, "input[type=radio]"), true),
|
|
106
|
+
select: message => selectOption(message),
|
|
107
|
+
attach: message => attachFiles(find(message, byField, "input[type=file]"), message.files),
|
|
108
|
+
send_keys: message => sendKeys(null, message.keys),
|
|
109
|
+
element: message => elementAction(message),
|
|
110
|
+
traverse: message => traverse(message.delta),
|
|
111
|
+
evaluate: message => evaluate(message.script),
|
|
112
|
+
deliver: message => deliver(message.messages)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function perform(message) {
|
|
116
|
+
if (READS[message.command]) return READS[message.command](message)
|
|
117
|
+
if (!ACTIONS[message.command]) throw new Error(`RailsJsdom: unknown command ${message.command}`)
|
|
118
|
+
errors.length = 0
|
|
119
|
+
const value = await ACTIONS[message.command](message)
|
|
120
|
+
await settle()
|
|
121
|
+
if (errors.length > 0) throw new Error(errors.splice(0).map(describeError).join("\n\n"))
|
|
122
|
+
return value === undefined ? null : JSON.parse(JSON.stringify(value) ?? "null")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stubFetch(url, response) {
|
|
126
|
+
fetchStubs.set(url, response)
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function loadFresh(html, url, history) {
|
|
131
|
+
fetchStubs.clear()
|
|
132
|
+
return load(html, url, history)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function setup(message) {
|
|
136
|
+
config = message
|
|
137
|
+
const appRequire = createRequire(path.join(config.root, "package.json"))
|
|
138
|
+
jsdom = appRequire("jsdom")
|
|
139
|
+
const esbuild = appRequire("esbuild")
|
|
140
|
+
const result = await esbuild.build({
|
|
141
|
+
stdin: { contents: entrySource(), resolveDir: config.root, sourcefile: "rails-jsdom-entry.js", loader: "js" },
|
|
142
|
+
bundle: true,
|
|
143
|
+
write: false,
|
|
144
|
+
format: "iife",
|
|
145
|
+
globalName: "RailsJsdomBundle",
|
|
146
|
+
logLevel: "silent",
|
|
147
|
+
plugins: [importmapPlugin()]
|
|
148
|
+
})
|
|
149
|
+
bundle = new vm.Script(result.outputFiles[0].text, { filename: "rails-jsdom-bundle.js" })
|
|
150
|
+
return {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function controllerFiles(directory, prefix) {
|
|
154
|
+
return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => {
|
|
155
|
+
if (entry.isDirectory()) return controllerFiles(path.join(directory, entry.name), `${prefix}${entry.name}/`)
|
|
156
|
+
return entry.name.endsWith("_controller.js") ? [[`${prefix}${entry.name}`, path.join(directory, entry.name)]] : []
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function identifierFor(relative) {
|
|
161
|
+
return relative.replace(/_controller\.js$/, "").replace(/\//g, "--").replace(/_/g, "-")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function entrySource() {
|
|
165
|
+
const files = controllerFiles(config.controllers, "")
|
|
166
|
+
return [
|
|
167
|
+
"import \"@hotwired/turbo-rails\"",
|
|
168
|
+
"export { Application } from \"@hotwired/stimulus\"",
|
|
169
|
+
...files.map(([, file], index) => `import controller${index} from ${JSON.stringify(file)}`),
|
|
170
|
+
`export const controllers = { ${files.map(([relative], index) => `${JSON.stringify(identifierFor(relative))}: controller${index}`).join(", ")} }`
|
|
171
|
+
].join("\n")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function importmapPlugin() {
|
|
175
|
+
return {
|
|
176
|
+
name: "rails-jsdom-importmap",
|
|
177
|
+
setup(build) {
|
|
178
|
+
build.onResolve({ filter: /^[^./]/ }, args => resolveImport(args.path))
|
|
179
|
+
build.onLoad({ filter: /.*/, namespace: STUB_NAMESPACE }, () => ({ contents: STUB_SOURCE, loader: "js" }))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveImport(specifier) {
|
|
185
|
+
const directory = Object.keys(config.directories).find(prefix => specifier.startsWith(`${prefix}/`))
|
|
186
|
+
if (config.stubs.includes(specifier)) return { path: specifier, namespace: STUB_NAMESPACE }
|
|
187
|
+
if (config.modules[specifier]) return { path: config.modules[specifier] }
|
|
188
|
+
if (directory) return { path: `${path.join(config.directories[directory], specifier.slice(directory.length + 1))}.js` }
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class Clock {
|
|
193
|
+
constructor() {
|
|
194
|
+
this.now = 0
|
|
195
|
+
this.sequence = 0
|
|
196
|
+
this.timers = new Map()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
install(window) {
|
|
200
|
+
window.setTimeout = (callback, delay, ...args) => this.schedule(callback, delay, args, false)
|
|
201
|
+
window.setInterval = (callback, delay, ...args) => this.schedule(callback, delay, args, true)
|
|
202
|
+
window.requestAnimationFrame = callback => this.schedule(() => callback(this.now), 0, [], false)
|
|
203
|
+
window.clearTimeout = window.clearInterval = window.cancelAnimationFrame = id => { this.timers.delete(id) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
schedule(callback, delay, args, repeat) {
|
|
207
|
+
const wait = Math.max(repeat ? 4 : 0, Number(delay) || 0)
|
|
208
|
+
this.sequence += 1
|
|
209
|
+
this.timers.set(this.sequence, { callback, args, at: this.now + wait, every: repeat ? wait : 0, order: this.sequence })
|
|
210
|
+
return this.sequence
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
due(limit) {
|
|
214
|
+
let next = null
|
|
215
|
+
for (const entry of this.timers) {
|
|
216
|
+
const timer = entry[1]
|
|
217
|
+
if (timer.at <= limit && (!next || timer.at < next[1].at || (timer.at === next[1].at && timer.order < next[1].order))) next = entry
|
|
218
|
+
}
|
|
219
|
+
return next
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
run([id, timer]) {
|
|
223
|
+
this.now = timer.at
|
|
224
|
+
if (timer.every) {
|
|
225
|
+
this.sequence += 1
|
|
226
|
+
timer.at += timer.every
|
|
227
|
+
timer.order = this.sequence
|
|
228
|
+
} else {
|
|
229
|
+
this.timers.delete(id)
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
if (typeof timer.callback === "function") timer.callback(...timer.args)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
errors.push(error)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function settle() {
|
|
240
|
+
let idle = 0
|
|
241
|
+
let current = null
|
|
242
|
+
let limit = 0
|
|
243
|
+
for (let round = 0; idle < 3; round += 1) {
|
|
244
|
+
if (round > SETTLE_LIMIT) throw new Error("RailsJsdom: the page did not settle")
|
|
245
|
+
await tick()
|
|
246
|
+
if (current !== page) {
|
|
247
|
+
current = page
|
|
248
|
+
limit = page.clock.now + config.horizon
|
|
249
|
+
}
|
|
250
|
+
const timer = page.clock.due(limit)
|
|
251
|
+
if (pending.size > 0) {
|
|
252
|
+
idle = 0
|
|
253
|
+
await Promise.race(pending)
|
|
254
|
+
} else if (timer) {
|
|
255
|
+
idle = 0
|
|
256
|
+
page.clock.run(timer)
|
|
257
|
+
} else {
|
|
258
|
+
idle += 1
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function track(promise) {
|
|
264
|
+
pending.add(promise)
|
|
265
|
+
const forget = () => pending.delete(promise)
|
|
266
|
+
promise.then(forget, forget)
|
|
267
|
+
return promise
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function requestRails(request) {
|
|
271
|
+
const id = nextFetchId
|
|
272
|
+
nextFetchId += 1
|
|
273
|
+
return track(new Promise(resolve => {
|
|
274
|
+
replies.set(id, resolve)
|
|
275
|
+
send({ type: "fetch", id, request })
|
|
276
|
+
}))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function sessionNamed(name) {
|
|
280
|
+
if (!sessions.has(name)) sessions.set(name, { page: null, back: [], forward: [] })
|
|
281
|
+
return sessions.get(name)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function switchSession(name) {
|
|
285
|
+
session = sessionNamed(name)
|
|
286
|
+
page = session.page
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function load(html, url, history = "push") {
|
|
291
|
+
const previous = page
|
|
292
|
+
if (history === "push" && previous) pushHistory(previous.window.location.href)
|
|
293
|
+
const current = { id: nextPageId, clock: new Clock(), closed: false, position: 0, length: 1, handles: new Map(), ids: new WeakMap(), nextHandle: 0, edited: new Map() }
|
|
294
|
+
nextPageId += 1
|
|
295
|
+
const report = error => { if (page === current) errors.push(error) }
|
|
296
|
+
session.page = current
|
|
297
|
+
page = current
|
|
298
|
+
current.dom = new jsdom.JSDOM(html, {
|
|
299
|
+
url,
|
|
300
|
+
runScripts: "dangerously",
|
|
301
|
+
pretendToBeVisual: true,
|
|
302
|
+
virtualConsole: pageConsole(report),
|
|
303
|
+
beforeParse: window => prepare(window, current)
|
|
304
|
+
})
|
|
305
|
+
current.window = current.dom.window
|
|
306
|
+
bundle.runInContext(current.dom.getInternalVMContext())
|
|
307
|
+
boot(current.window, report)
|
|
308
|
+
if (previous) close(previous)
|
|
309
|
+
return current.id
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function pushHistory(url) {
|
|
313
|
+
session.back.push(url)
|
|
314
|
+
session.forward = []
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function pageConsole(report) {
|
|
318
|
+
const virtualConsole = new jsdom.VirtualConsole()
|
|
319
|
+
virtualConsole.forwardTo(console, { jsdomErrors: "none" })
|
|
320
|
+
virtualConsole.on("jsdomError", report)
|
|
321
|
+
return virtualConsole
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function evaluate(script) {
|
|
325
|
+
return await page.window.eval(script)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function reset() {
|
|
329
|
+
for (const each of sessions.values()) if (each.page) close(each.page)
|
|
330
|
+
sessions.clear()
|
|
331
|
+
session = sessionNamed("default")
|
|
332
|
+
page = null
|
|
333
|
+
modal = null
|
|
334
|
+
modalMessages = []
|
|
335
|
+
errors.length = 0
|
|
336
|
+
fetchStubs.clear()
|
|
337
|
+
replies.clear()
|
|
338
|
+
pending.clear()
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function close(target) {
|
|
343
|
+
target.closed = true
|
|
344
|
+
target.clock.timers.clear()
|
|
345
|
+
if (target.window) target.window.close()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function prepare(window, current) {
|
|
349
|
+
owners.set(window, current)
|
|
350
|
+
current.clock.install(window)
|
|
351
|
+
trackHistory(window, current)
|
|
352
|
+
window.fetch = (resource, init) => pageFetch(window, resource, init ?? {})
|
|
353
|
+
window.alert = message => dialog("alert", message)
|
|
354
|
+
window.confirm = message => dialog("confirm", message)
|
|
355
|
+
window.prompt = (message, defaultValue) => dialog("prompt", message, defaultValue)
|
|
356
|
+
window.HTMLFormElement.prototype.submit = function () { submitNatively(this, null) }
|
|
357
|
+
stubMissingBrowserApis(window)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function trackHistory(window, current) {
|
|
361
|
+
const pushState = window.history.pushState.bind(window.history)
|
|
362
|
+
window.history.pushState = (...args) => {
|
|
363
|
+
pushState(...args)
|
|
364
|
+
current.position += 1
|
|
365
|
+
current.length = current.position + 1
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function stubMissingBrowserApis(window) {
|
|
370
|
+
window.Headers = Headers
|
|
371
|
+
window.Request = Request
|
|
372
|
+
window.Response = Response
|
|
373
|
+
window.matchMedia = media => ({ matches: false, media, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false })
|
|
374
|
+
window.scrollTo = window.scroll = window.scrollBy = () => {}
|
|
375
|
+
window.Element.prototype.scrollIntoView = () => {}
|
|
376
|
+
window.IntersectionObserver = window.ResizeObserver = class {
|
|
377
|
+
observe() {}
|
|
378
|
+
unobserve() {}
|
|
379
|
+
disconnect() {}
|
|
380
|
+
takeRecords() { return [] }
|
|
381
|
+
}
|
|
382
|
+
Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { writeText: async () => {}, readText: async () => "" } })
|
|
383
|
+
window.HTMLDialogElement.prototype.show = window.HTMLDialogElement.prototype.showModal = function () { this.setAttribute("open", "") }
|
|
384
|
+
window.HTMLDialogElement.prototype.close = function (returnValue) {
|
|
385
|
+
if (returnValue !== undefined) this.returnValue = returnValue
|
|
386
|
+
if (this.hasAttribute("open")) {
|
|
387
|
+
this.removeAttribute("open")
|
|
388
|
+
this.dispatchEvent(new window.Event("close"))
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function boot(window, report) {
|
|
394
|
+
const { Application, controllers } = window.RailsJsdomBundle
|
|
395
|
+
const application = Application.start()
|
|
396
|
+
const registered = new Set()
|
|
397
|
+
application.handleError = (error, message) => report(new Error(`${message}: ${describeError(error)}`))
|
|
398
|
+
window.Stimulus = application
|
|
399
|
+
const discover = () => {
|
|
400
|
+
for (const identifier of controllerIdentifiers(window.document)) {
|
|
401
|
+
if (controllers[identifier] && !registered.has(identifier)) {
|
|
402
|
+
registered.add(identifier)
|
|
403
|
+
application.register(identifier, controllers[identifier])
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
discover()
|
|
408
|
+
new window.MutationObserver(discover).observe(window.document, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-controller"] })
|
|
409
|
+
fallBackLast(window, "click", event => followLink(window, event))
|
|
410
|
+
fallBackLast(window, "submit", event => submitUnhandled(event))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function controllerIdentifiers(document) {
|
|
414
|
+
return [...document.querySelectorAll("[data-controller]")].flatMap(element => element.getAttribute("data-controller").split(/\s+/))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function fallBackLast(window, type, listener) {
|
|
418
|
+
window.addEventListener(type, () => {
|
|
419
|
+
window.removeEventListener(type, listener)
|
|
420
|
+
window.addEventListener(type, listener)
|
|
421
|
+
}, true)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function followLink(window, event) {
|
|
425
|
+
const link = event.target.closest && event.target.closest("a[href]")
|
|
426
|
+
if (event.defaultPrevented || !link || staysInDocument(window, link.href)) return
|
|
427
|
+
event.preventDefault()
|
|
428
|
+
if (!link.hasAttribute("download") && ["", "_self"].includes(link.target) && ["http:", "https:"].includes(new window.URL(link.href).protocol)) navigate({ method: "GET", url: link.href })
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function staysInDocument(window, href) {
|
|
432
|
+
const target = new window.URL(href)
|
|
433
|
+
const here = new window.URL(window.location.href)
|
|
434
|
+
return href.includes("#") && target.origin === here.origin && target.pathname === here.pathname && target.search === here.search
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function submitUnhandled(event) {
|
|
438
|
+
if (!event.defaultPrevented) {
|
|
439
|
+
event.preventDefault()
|
|
440
|
+
submitNatively(event.target, event.submitter)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function submitNatively(form, submitter) {
|
|
445
|
+
const { window, method, action, data } = submission(form, submitter)
|
|
446
|
+
if (method === "dialog") {
|
|
447
|
+
form.closest("dialog")?.close(submitter?.value)
|
|
448
|
+
} else if (method === "get") {
|
|
449
|
+
action.search = new window.URLSearchParams(textEntries(data)).toString()
|
|
450
|
+
navigate({ method: "GET", url: action.href })
|
|
451
|
+
} else {
|
|
452
|
+
const headers = {}
|
|
453
|
+
const entries = form.enctype === "multipart/form-data" ? [...data] : textEntries(data)
|
|
454
|
+
track(encodeBody(window, entries, headers).then(body => navigate({ method: "POST", url: action.href, body, headers })))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function submission(form, submitter) {
|
|
459
|
+
const window = form.ownerDocument.defaultView
|
|
460
|
+
const data = new window.FormData(form)
|
|
461
|
+
if (submitter?.name) data.append(submitter.name, submitter.value)
|
|
462
|
+
return {
|
|
463
|
+
window,
|
|
464
|
+
method: (formSetting(form, submitter, "method") || "get").toLowerCase(),
|
|
465
|
+
action: new window.URL(formSetting(form, submitter, "action") || window.location.href, window.document.baseURI),
|
|
466
|
+
data
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function formSetting(form, submitter, name) {
|
|
471
|
+
return submitter?.getAttribute(`form${name}`) || form.getAttribute(name)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function textEntries(data) {
|
|
475
|
+
return [...data].filter(([, value]) => typeof value === "string")
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function navigate({ method, url, body = null, headers = {} }, history = "push") {
|
|
479
|
+
requestRails({ method, url, headers: { accept: "text/html,application/xhtml+xml", ...headers }, body }).then(reply => {
|
|
480
|
+
if (reply.error) errors.push(new Error(reply.error))
|
|
481
|
+
else load(reply.body, reply.url, history)
|
|
482
|
+
}).catch(error => errors.push(error))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function traverse(delta) {
|
|
486
|
+
if (!page) throw new Error("RailsJsdom: visit a page or mount a partial first")
|
|
487
|
+
const position = page.position + delta
|
|
488
|
+
if (position >= 0 && position < page.length) {
|
|
489
|
+
page.position = position
|
|
490
|
+
page.window.history.go(delta)
|
|
491
|
+
return null
|
|
492
|
+
}
|
|
493
|
+
const url = (delta < 0 ? session.back : session.forward).pop()
|
|
494
|
+
if (url) {
|
|
495
|
+
(delta < 0 ? session.forward : session.back).push(page.window.location.href)
|
|
496
|
+
navigate({ method: "GET", url }, "none")
|
|
497
|
+
}
|
|
498
|
+
return null
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function pageFetch(window, resource, init) {
|
|
502
|
+
if (owners.get(window).closed) return new Promise(() => {})
|
|
503
|
+
const url = resourceUrl(window, resource)
|
|
504
|
+
const headers = Object.fromEntries(new Headers(init.headers ?? {}))
|
|
505
|
+
const body = await encodeBody(window, init.body, headers)
|
|
506
|
+
const stub = stubFor(url)
|
|
507
|
+
const reply = stub ? { ...stub, url: url.href, redirected: false } : await requestRails({ method: String(init.method ?? "GET").toUpperCase(), url: url.href, headers, body })
|
|
508
|
+
if (reply.error) throw new TypeError(reply.error)
|
|
509
|
+
return pageResponse(reply)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function resourceUrl(window, resource) {
|
|
513
|
+
const address = typeof resource === "object" && "url" in resource ? resource.url : String(resource)
|
|
514
|
+
return new window.URL(address, window.document.baseURI)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function stubFor(url) {
|
|
518
|
+
return fetchStubs.get(url.href) || fetchStubs.get(`${url.pathname}${url.search}`)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function pageResponse(reply) {
|
|
522
|
+
const response = new Response(NULL_BODY_STATUSES.has(reply.status) ? null : reply.body, { status: reply.status, headers: reply.headers })
|
|
523
|
+
Object.defineProperties(response, { url: { value: reply.url }, redirected: { value: reply.redirected } })
|
|
524
|
+
return response
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function encodeBody(window, body, headers) {
|
|
528
|
+
if (body === undefined || body === null) return null
|
|
529
|
+
if (Array.isArray(body) || body instanceof window.FormData || body instanceof window.URLSearchParams) {
|
|
530
|
+
const entries = [...body]
|
|
531
|
+
if (entries.some(([, value]) => typeof value !== "string")) return multipart(entries, headers)
|
|
532
|
+
headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8"
|
|
533
|
+
return Buffer.from(new window.URLSearchParams(entries).toString()).toString("base64")
|
|
534
|
+
}
|
|
535
|
+
return Buffer.from(String(body)).toString("base64")
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function multipart(entries, headers) {
|
|
539
|
+
const boundary = `----RailsJsdomBoundary${nextFetchId}${Date.now()}`
|
|
540
|
+
const quote = text => String(text).replace(/"/g, "%22").replace(/\r?\n/g, " ")
|
|
541
|
+
const parts = []
|
|
542
|
+
for (const [name, value] of entries) {
|
|
543
|
+
const file = typeof value !== "string"
|
|
544
|
+
const head = file ? `Content-Disposition: form-data; name="${quote(name)}"; filename="${quote(value.name)}"\r\nContent-Type: ${value.type || "application/octet-stream"}` : `Content-Disposition: form-data; name="${quote(name)}"`
|
|
545
|
+
parts.push(Buffer.from(`--${boundary}\r\n${head}\r\n\r\n`), file ? Buffer.from(await value.arrayBuffer()) : Buffer.from(value), Buffer.from("\r\n"))
|
|
546
|
+
}
|
|
547
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`))
|
|
548
|
+
headers["content-type"] = `multipart/form-data; boundary=${boundary}`
|
|
549
|
+
return Buffer.concat(parts).toString("base64")
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function expectModal({ type, accept, with: answer }) {
|
|
553
|
+
modal = { type, accept, answer }
|
|
554
|
+
modalMessages = []
|
|
555
|
+
return null
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function takeModalMessages() {
|
|
559
|
+
const messages = modalMessages
|
|
560
|
+
modal = null
|
|
561
|
+
modalMessages = []
|
|
562
|
+
return messages
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function dialog(type, message, defaultValue) {
|
|
566
|
+
if (!modal || modal.type !== type) {
|
|
567
|
+
errors.push(new Error(`RailsJsdom: unexpected ${type} dialog "${message}". Wrap the action in ${DIALOG_HANDLERS[type]}.`))
|
|
568
|
+
return UNANSWERED[type]
|
|
569
|
+
}
|
|
570
|
+
modalMessages.push(String(message))
|
|
571
|
+
if (type === "confirm") return modal.accept
|
|
572
|
+
if (type === "prompt") return modal.accept ? String(modal.answer ?? defaultValue ?? "") : null
|
|
573
|
+
return undefined
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function find({ locator, scope, match }, strategy, kind) {
|
|
577
|
+
const root = scopeRoot(scope)
|
|
578
|
+
const candidates = [byAction, strategy, byCss].map(search => search(root, locator).filter(element => !kind || element.matches(kind))).find(list => list.length > 0) || []
|
|
579
|
+
if (candidates.length === 0) throw new Error(`RailsJsdom: unable to find ${JSON.stringify(locator)}`)
|
|
580
|
+
return pick(candidates, JSON.stringify(locator), match)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function scopeRoot(scope = []) {
|
|
584
|
+
return scope.reduce((parent, entry) => only(scoped(parent, entry), `within ${JSON.stringify(entry)}`), page.window.document)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function scoped(parent, entry) {
|
|
588
|
+
if (entry.handle !== undefined) return [elementFor(entry.handle)]
|
|
589
|
+
if (entry.fieldset !== undefined) return [...parent.querySelectorAll("fieldset")].filter(fieldset => fieldset.id === entry.fieldset || normalize(fieldset.querySelector(":scope > legend")?.textContent) === entry.fieldset)
|
|
590
|
+
if (entry.table !== undefined) return [...parent.querySelectorAll("table")].filter(table => table.id === entry.table || normalize(table.caption?.textContent) === entry.table)
|
|
591
|
+
return [...parent.querySelectorAll(entry.css)]
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function pick(list, description, match) {
|
|
595
|
+
return match === "first" && list.length > 0 ? list[0] : only(list, description)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function only(list, description) {
|
|
599
|
+
if (list.length !== 1) throw new Error(`RailsJsdom: ${description} matches ${list.length} elements`)
|
|
600
|
+
return list[0]
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function byAction(root, locator) {
|
|
604
|
+
if (!/^[\w-]+#\w+$/.test(locator)) return []
|
|
605
|
+
return [...root.querySelectorAll("[data-action]")].filter(element => element.getAttribute("data-action").split(/\s+/).some(token => token.split("->").pop().split(":")[0] === locator))
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function byCss(root, locator) {
|
|
609
|
+
try {
|
|
610
|
+
return [...root.querySelectorAll(locator)]
|
|
611
|
+
} catch {
|
|
612
|
+
return []
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function names(element) {
|
|
617
|
+
return [element.textContent, element.getAttribute("aria-label"), element.getAttribute("title"), element.id, element.matches(BUTTON_INPUTS) ? element.value : ""].map(normalize).filter(Boolean)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function byText(elements, text) {
|
|
621
|
+
const exact = elements.filter(element => names(element).includes(text))
|
|
622
|
+
return exact.length > 0 ? exact : elements.filter(element => names(element).some(name => name.includes(text)))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function byClickable(root, text) {
|
|
626
|
+
return byText([...root.querySelectorAll(CLICKABLE)], text)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function byField(root, text) {
|
|
630
|
+
const fields = [...root.querySelectorAll(FIELDS)]
|
|
631
|
+
const labelled = byText([...root.querySelectorAll("label")], text).map(label => label.control).filter(Boolean)
|
|
632
|
+
const attributes = ["placeholder", "name", "id", "aria-label"].map(attribute => fields.filter(field => field.getAttribute(attribute) === text))
|
|
633
|
+
return [labelled, ...attributes].find(list => list.length > 0) || []
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function click(element) {
|
|
637
|
+
const window = element.ownerDocument.defaultView
|
|
638
|
+
const options = { bubbles: true, cancelable: true, composed: true, view: window }
|
|
639
|
+
if (element.disabled) throw new Error(`RailsJsdom: ${element.outerHTML.slice(0, 120)} is disabled`)
|
|
640
|
+
element.dispatchEvent(new window.MouseEvent("mousedown", options))
|
|
641
|
+
if (typeof element.focus === "function") element.focus()
|
|
642
|
+
element.dispatchEvent(new window.MouseEvent("mouseup", options))
|
|
643
|
+
if (typeof element.click === "function") element.click()
|
|
644
|
+
else element.dispatchEvent(new window.MouseEvent("click", options))
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function fill(element, value) {
|
|
648
|
+
if (element.disabled || element.readOnly) throw new Error(`RailsJsdom: ${element.outerHTML.slice(0, 120)} is not editable`)
|
|
649
|
+
element.focus()
|
|
650
|
+
element.value = value
|
|
651
|
+
changed(element)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function changed(element) {
|
|
655
|
+
const window = element.ownerDocument.defaultView
|
|
656
|
+
element.dispatchEvent(new window.Event("input", { bubbles: true }))
|
|
657
|
+
element.dispatchEvent(new window.Event("change", { bubbles: true }))
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function setChecked(element, checked) {
|
|
661
|
+
if (element.checked !== checked) click(element)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function setValue(element, value) {
|
|
665
|
+
if (element.matches("input[type=checkbox]")) return setChecked(element, Boolean(value))
|
|
666
|
+
if (element.matches("input[type=radio]")) return value ? setChecked(element, true) : null
|
|
667
|
+
if (element.matches(FILLABLE)) return fill(element, String(value))
|
|
668
|
+
if (element.matches("[contenteditable]:not([contenteditable=false])")) {
|
|
669
|
+
element.textContent = String(value)
|
|
670
|
+
return element.dispatchEvent(new element.ownerDocument.defaultView.Event("input", { bubbles: true }))
|
|
671
|
+
}
|
|
672
|
+
throw new Error(`RailsJsdom: set does not work on ${element.outerHTML.slice(0, 120)}; use select, attach_file or click`)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function chooseOption(option, selected) {
|
|
676
|
+
const select = option.closest("select")
|
|
677
|
+
if (!select) throw new Error("RailsJsdom: select_option works only on an option inside a select")
|
|
678
|
+
if (select.disabled) throw new Error(`RailsJsdom: ${select.outerHTML.slice(0, 120)} is disabled`)
|
|
679
|
+
if (!selected && !select.multiple) throw new Error("RailsJsdom: unselect works only on a select that allows multiple options")
|
|
680
|
+
select.focus()
|
|
681
|
+
option.selected = selected
|
|
682
|
+
changed(select)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function attachFiles(input, files) {
|
|
686
|
+
if (input.disabled) throw new Error(`RailsJsdom: ${input.outerHTML.slice(0, 120)} is disabled`)
|
|
687
|
+
if (files.length > 1 && !input.multiple) throw new Error("RailsJsdom: the file field does not allow multiple files")
|
|
688
|
+
const window = input.ownerDocument.defaultView
|
|
689
|
+
const list = internal(input.files)
|
|
690
|
+
list.length = 0
|
|
691
|
+
for (const file of files) list.push(internal(new window.File([Buffer.from(file.base64, "base64")], file.name, { type: file.type })))
|
|
692
|
+
changed(input)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function internal(object) {
|
|
696
|
+
const symbol = Object.getOwnPropertySymbols(object).find(candidate => candidate.description === "impl")
|
|
697
|
+
if (!symbol) throw new Error("RailsJsdom: attach_file does not work with this jsdom version")
|
|
698
|
+
return object[symbol]
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function handleFor(element) {
|
|
702
|
+
if (!page.ids.has(element)) {
|
|
703
|
+
page.nextHandle += 1
|
|
704
|
+
page.ids.set(element, page.nextHandle)
|
|
705
|
+
page.handles.set(page.nextHandle, element)
|
|
706
|
+
}
|
|
707
|
+
return page.ids.get(element)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function elementFor(handle) {
|
|
711
|
+
const element = page && page.handles.get(handle)
|
|
712
|
+
if (!element || !element.isConnected) throw new Error("RailsJsdom: the element is no longer on the page")
|
|
713
|
+
return element
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function describe(element) {
|
|
717
|
+
return {
|
|
718
|
+
handle: handleFor(element),
|
|
719
|
+
tag: element.localName,
|
|
720
|
+
text: normalize(visibleText(element)),
|
|
721
|
+
all_text: normalize(element.textContent),
|
|
722
|
+
visible: isVisible(element),
|
|
723
|
+
value: "value" in element ? String(element.value) : null,
|
|
724
|
+
checked: Boolean(element.checked),
|
|
725
|
+
selected: Boolean(element.selected),
|
|
726
|
+
disabled: Boolean(element.disabled),
|
|
727
|
+
type: element.localName === "input" ? element.type : element.localName,
|
|
728
|
+
attributes: Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]))
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function query({ strategy, locator, scope }) {
|
|
733
|
+
if (!page) throw new Error("RailsJsdom: visit a page or mount a partial first")
|
|
734
|
+
return candidates(scopeRoot(scope), strategy, locator).map(describe)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function candidates(root, strategy, locator) {
|
|
738
|
+
const kind = QUERY_KINDS[strategy]
|
|
739
|
+
if (locator === null) return [...root.querySelectorAll(kind)]
|
|
740
|
+
if (strategy === "css") return [...root.querySelectorAll(locator)]
|
|
741
|
+
const search = strategy === "field" ? byField : byClickable
|
|
742
|
+
return search(root, locator).filter(element => element.matches(kind))
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const ELEMENT_ACTIONS = {
|
|
746
|
+
click: element => click(element),
|
|
747
|
+
hover: element => hover(element),
|
|
748
|
+
double_click: element => doubleClick(element),
|
|
749
|
+
right_click: element => rightClick(element),
|
|
750
|
+
set: (element, message) => setValue(element, message.value),
|
|
751
|
+
select_option: element => chooseOption(element, true),
|
|
752
|
+
unselect_option: element => chooseOption(element, false),
|
|
753
|
+
send_keys: (element, message) => sendKeys(element, message.keys)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function elementAction(message) {
|
|
757
|
+
const element = elementFor(message.handle)
|
|
758
|
+
if (!ELEMENT_ACTIONS[message.action]) throw new Error(`RailsJsdom: unknown element action ${message.action}`)
|
|
759
|
+
return ELEMENT_ACTIONS[message.action](element, message)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function sendKeys(element, keys) {
|
|
763
|
+
if (element) element.focus()
|
|
764
|
+
for (const entry of keys) {
|
|
765
|
+
const modifiers = modifierFlags(entry.modifiers || [])
|
|
766
|
+
for (const key of pressedKeys(entry)) press(key, modifiers)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function modifierFlags(names) {
|
|
771
|
+
return Object.fromEntries(names.map(name => {
|
|
772
|
+
if (!MODIFIERS[name]) throw new Error(`RailsJsdom: unknown modifier key ${name}`)
|
|
773
|
+
return [MODIFIERS[name], true]
|
|
774
|
+
}))
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function pressedKeys(entry) {
|
|
778
|
+
if (entry.text !== undefined) return [...entry.text]
|
|
779
|
+
if (KEYS[entry.key]) return [KEYS[entry.key]]
|
|
780
|
+
throw new Error(`RailsJsdom: unknown key ${entry.key}`)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function keyCode(key) {
|
|
784
|
+
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`
|
|
785
|
+
if (/^[0-9]$/.test(key)) return `Digit${key}`
|
|
786
|
+
return key === " " ? "Space" : key
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function press(key, modifiers) {
|
|
790
|
+
const window = page.window
|
|
791
|
+
const target = window.document.activeElement
|
|
792
|
+
const options = { key, code: keyCode(key), bubbles: true, cancelable: true, composed: true, view: window, ...modifiers }
|
|
793
|
+
const proceed = target.dispatchEvent(new window.KeyboardEvent("keydown", options))
|
|
794
|
+
if (proceed && (key.length > 1 || target.dispatchEvent(new window.KeyboardEvent("keypress", options)))) keyAction(target, key, modifiers)
|
|
795
|
+
target.dispatchEvent(new window.KeyboardEvent("keyup", options))
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function keyAction(target, key, modifiers) {
|
|
799
|
+
if (key === "Tab") return moveFocus(target, modifiers.shiftKey)
|
|
800
|
+
if (key === "Enter") return pressEnter(target)
|
|
801
|
+
if (key === " " && target.matches(PRESSABLE)) return click(target)
|
|
802
|
+
if (target.matches(STEPPABLE) && ["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft", "Home", "End"].includes(key)) return step(target, key)
|
|
803
|
+
if (target.matches(`${FILLABLE}`)) return editText(target, key, modifiers)
|
|
804
|
+
return null
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function editText(target, key, modifiers) {
|
|
808
|
+
if (target.disabled || target.readOnly) return null
|
|
809
|
+
const before = target.value
|
|
810
|
+
const after = editedValue(before, key, modifiers)
|
|
811
|
+
if (after === before) return null
|
|
812
|
+
target.value = after
|
|
813
|
+
if (!page.edited.has(target)) page.edited.set(target, before)
|
|
814
|
+
const deleting = key === "Backspace"
|
|
815
|
+
return target.dispatchEvent(new target.ownerDocument.defaultView.InputEvent("input", { bubbles: true, inputType: deleting ? "deleteContentBackward" : "insertText", data: deleting ? null : key }))
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function editedValue(value, key, modifiers) {
|
|
819
|
+
if (key === "Backspace") return value.slice(0, -1)
|
|
820
|
+
return typesCharacter(key, modifiers) ? value + key : value
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function typesCharacter(key, modifiers) {
|
|
824
|
+
return key.length === 1 && !modifiers.ctrlKey && !modifiers.metaKey && !modifiers.altKey
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function pressEnter(target) {
|
|
828
|
+
if (target.localName === "textarea") return editText(target, "\n", {})
|
|
829
|
+
if (target.matches(`a[href], ${KINDS.button}`)) return click(target)
|
|
830
|
+
const form = target.form
|
|
831
|
+
if (!form || !target.matches(FILLABLE)) return null
|
|
832
|
+
const submitter = form.querySelector(`button:not([type]), button[type=submit], input[type=submit], input[type=image]`)
|
|
833
|
+
return submitter ? click(submitter) : form.requestSubmit()
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function step(target, key) {
|
|
837
|
+
if (key === "Home") target.value = target.min || "0"
|
|
838
|
+
else if (key === "End") target.value = target.max || "100"
|
|
839
|
+
else if (["ArrowUp", "ArrowRight"].includes(key)) target.stepUp()
|
|
840
|
+
else target.stepDown()
|
|
841
|
+
changed(target)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function moveFocus(from, backwards) {
|
|
845
|
+
if (page.edited.has(from) && page.edited.get(from) !== from.value) from.dispatchEvent(new from.ownerDocument.defaultView.Event("change", { bubbles: true }))
|
|
846
|
+
page.edited.delete(from)
|
|
847
|
+
const next = nextFocus(tabOrder(from.ownerDocument), from, backwards)
|
|
848
|
+
if (next) next.focus()
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function tabOrder(document) {
|
|
852
|
+
const focusable = [...document.querySelectorAll(FOCUSABLE)].filter(element => !element.disabled && element.tabIndex >= 0 && isVisible(element))
|
|
853
|
+
const positive = focusable.filter(element => element.tabIndex > 0).sort((a, b) => a.tabIndex - b.tabIndex)
|
|
854
|
+
return [...positive, ...focusable.filter(element => element.tabIndex === 0)]
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function nextFocus(ordered, from, backwards) {
|
|
858
|
+
const index = ordered.indexOf(from)
|
|
859
|
+
if (index === -1) return backwards ? ordered[ordered.length - 1] : ordered[0]
|
|
860
|
+
const step = backwards ? -1 : 1
|
|
861
|
+
return ordered[(index + step + ordered.length) % ordered.length]
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function selectOption(message) {
|
|
865
|
+
const root = scopeRoot(message.scope)
|
|
866
|
+
const selects = message.from === null ? [...root.querySelectorAll("select")] : [find({ ...message, locator: message.from }, byField, "select")]
|
|
867
|
+
const options = selects.flatMap(select => [...select.options])
|
|
868
|
+
const exact = options.filter(option => normalize(option.textContent) === message.option)
|
|
869
|
+
const option = pick(exact.length > 0 ? exact : options.filter(option => normalize(option.textContent).includes(message.option)), `option ${JSON.stringify(message.option)}`, message.match)
|
|
870
|
+
chooseOption(option, message.selected)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function mouse(element, type, options = {}) {
|
|
874
|
+
const window = element.ownerDocument.defaultView
|
|
875
|
+
const Event = type.startsWith("pointer") ? window.PointerEvent : window.MouseEvent
|
|
876
|
+
element.dispatchEvent(new Event(type, { bubbles: !type.endsWith("enter"), cancelable: true, composed: true, view: window, ...options }))
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function hover(element) {
|
|
880
|
+
for (const type of ["pointerover", "pointerenter", "mouseover", "mouseenter", "pointermove", "mousemove"]) mouse(element, type)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function doubleClick(element) {
|
|
884
|
+
click(element)
|
|
885
|
+
click(element)
|
|
886
|
+
mouse(element, "dblclick", { detail: 2 })
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function rightClick(element) {
|
|
890
|
+
for (const type of ["mousedown", "mouseup", "contextmenu"]) mouse(element, type, { button: 2, buttons: 2 })
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function isVisible(element) {
|
|
894
|
+
for (let node = element; node; node = node.parentElement) {
|
|
895
|
+
if (node.matches(config.hidden)) return false
|
|
896
|
+
if (node.parentElement?.localName === "details" && !node.parentElement.open && node.localName !== "summary") return false
|
|
897
|
+
}
|
|
898
|
+
return true
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function visibleText(node) {
|
|
902
|
+
if (node.nodeType === 3) return node.data
|
|
903
|
+
if (node.nodeType !== 1 || node.matches(config.hidden)) return ""
|
|
904
|
+
const children = node.localName === "details" && !node.open ? [...node.children].filter(child => child.localName === "summary") : [...node.childNodes]
|
|
905
|
+
const text = children.map(visibleText).join("")
|
|
906
|
+
return INLINE_ELEMENTS.has(node.localName) ? text : ` ${text} `
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function streams() {
|
|
910
|
+
if (!page) return []
|
|
911
|
+
return [...page.window.document.querySelectorAll(STREAM_SOURCES)].map(source => source.getAttribute("signed-stream-name"))
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function deliver(messages) {
|
|
915
|
+
for (const message of messages) page.window.Turbo.renderStreamMessage(message)
|
|
916
|
+
return null
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function snapshot(scope) {
|
|
920
|
+
if (!page) throw new Error("RailsJsdom: visit a page or mount a partial first")
|
|
921
|
+
const root = scopeRoot(scope)
|
|
922
|
+
const document = root.nodeType === 9
|
|
923
|
+
return { html: document ? page.dom.serialize() : root.outerHTML, text: normalize(visibleText(document ? root.body : root)), url: page.window.location.href, title: page.window.document.title }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
module.exports = { start }
|