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.
@@ -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 }