opal-vite 0.2.3 → 0.2.6
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 +4 -4
- data/lib/opal/vite/compiler.rb +331 -18
- data/lib/opal/vite/testing/stable_helpers.rb +399 -0
- data/lib/opal/vite/version.rb +1 -1
- data/lib/opal-vite.rb +9 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19c908ad19445dc3ca95af2873ad94c885fd3546de85a78005cf3d45a10c2b7f
|
|
4
|
+
data.tar.gz: 10fd5fb0daf18cf0eeb466079307eb6f47f913c4e7e1e7031c7d7944beff4cb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0fca963918760eb442d55982d5de06875ed92293ad58a0d422f095f4e72bf2181ebbdb4ea99bdd978473d860b5f2948d98e92550938df0e7310fd707e94228b2
|
|
7
|
+
data.tar.gz: 6016bfab349b9b24af874cdf416bbc2dde40e6e106c30ce18157c609d7de418f1935c79d6fe782d75cb3ff03d49385f1c4b93994baf77e5e45aea90b834e3e1c
|
data/lib/opal/vite/compiler.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require 'opal'
|
|
2
2
|
require 'json'
|
|
3
|
+
require 'pathname'
|
|
3
4
|
|
|
4
5
|
module Opal
|
|
5
6
|
module Vite
|
|
@@ -38,12 +39,10 @@ module Opal
|
|
|
38
39
|
dependencies: extract_dependencies(builder)
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
#
|
|
42
|
+
# Extract source map if enabled
|
|
42
43
|
if @config.source_map_enabled
|
|
43
44
|
begin
|
|
44
|
-
|
|
45
|
-
# Try to get it from the processed assets
|
|
46
|
-
source_map = extract_source_map(builder)
|
|
45
|
+
source_map = extract_source_map(builder, file_path)
|
|
47
46
|
result[:map] = source_map if source_map
|
|
48
47
|
rescue => e
|
|
49
48
|
# Source map extraction failed, log but don't fail compilation
|
|
@@ -141,26 +140,340 @@ module Opal
|
|
|
141
140
|
builder.processed.map { |asset| asset.filename }.compact
|
|
142
141
|
end
|
|
143
142
|
|
|
144
|
-
def extract_source_map(builder)
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
return nil unless builder.
|
|
143
|
+
def extract_source_map(builder, file_path)
|
|
144
|
+
# Use builder's combined source_map which includes all compiled files
|
|
145
|
+
# This allows debugging of all required files (controllers, services, etc.)
|
|
146
|
+
return nil unless builder.respond_to?(:source_map) && builder.source_map
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return nil unless
|
|
148
|
+
source_map = builder.source_map
|
|
149
|
+
map_hash = deep_stringify_keys(source_map.to_h)
|
|
150
|
+
return nil unless map_hash
|
|
152
151
|
|
|
153
|
-
#
|
|
154
|
-
if
|
|
155
|
-
|
|
152
|
+
# If it's an index format with sections, merge all sections
|
|
153
|
+
if map_hash['sections']
|
|
154
|
+
map_hash = merge_all_sections(map_hash, file_path)
|
|
156
155
|
end
|
|
157
156
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
return nil unless map_hash
|
|
158
|
+
|
|
159
|
+
# Add sourceRoot for proper browser debugging
|
|
160
|
+
# This helps DevTools organize source files in a logical tree
|
|
161
|
+
map_hash['sourceRoot'] = ''
|
|
162
|
+
|
|
163
|
+
# Normalize source paths for browser debugging
|
|
164
|
+
# Prefix with /opal-sources/ so they appear in a dedicated folder in DevTools
|
|
165
|
+
if map_hash['sources']
|
|
166
|
+
map_hash['sources'] = map_hash['sources'].map do |source|
|
|
167
|
+
normalize_source_path_for_devtools(source, file_path)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
map_hash.to_json
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Recursively convert all hash keys to strings
|
|
175
|
+
def deep_stringify_keys(obj)
|
|
176
|
+
case obj
|
|
177
|
+
when Hash
|
|
178
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
179
|
+
result[key.to_s] = deep_stringify_keys(value)
|
|
180
|
+
end
|
|
181
|
+
when Array
|
|
182
|
+
obj.map { |item| deep_stringify_keys(item) }
|
|
183
|
+
else
|
|
184
|
+
obj
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def merge_all_sections(index_map, file_path)
|
|
189
|
+
sections = index_map['sections']
|
|
190
|
+
return nil if sections.nil? || sections.empty?
|
|
191
|
+
|
|
192
|
+
# For single section, just return that section's map
|
|
193
|
+
if sections.length == 1
|
|
194
|
+
return sections.first['map']
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Merge all sections into a single standard source map
|
|
198
|
+
# This allows debugging of all files (application.rb + all required files)
|
|
199
|
+
merged = {
|
|
200
|
+
'version' => 3,
|
|
201
|
+
'file' => File.basename(file_path),
|
|
202
|
+
'sources' => [],
|
|
203
|
+
'sourcesContent' => [],
|
|
204
|
+
'names' => [],
|
|
205
|
+
'mappings' => ''
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Track cumulative state for VLQ relative encoding
|
|
209
|
+
# These track the "previous" values across all sections
|
|
210
|
+
prev_source = 0
|
|
211
|
+
prev_orig_line = 0
|
|
212
|
+
prev_orig_col = 0
|
|
213
|
+
prev_name = 0
|
|
214
|
+
|
|
215
|
+
current_line = 0
|
|
216
|
+
|
|
217
|
+
sections.each_with_index do |section, idx|
|
|
218
|
+
section_map = section['map']
|
|
219
|
+
next unless section_map
|
|
220
|
+
|
|
221
|
+
offset = section['offset'] || { 'line' => 0, 'column' => 0 }
|
|
222
|
+
section_start_line = offset['line'] || 0
|
|
223
|
+
|
|
224
|
+
# Add empty lines to reach the section's starting line
|
|
225
|
+
lines_to_add = section_start_line - current_line
|
|
226
|
+
if lines_to_add > 0
|
|
227
|
+
merged['mappings'] += ';' * lines_to_add
|
|
228
|
+
current_line = section_start_line
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Track source and name index offsets for this section
|
|
232
|
+
source_offset = merged['sources'].length
|
|
233
|
+
name_offset = merged['names'].length
|
|
234
|
+
|
|
235
|
+
# Add sources and sourcesContent from this section
|
|
236
|
+
if section_map['sources']
|
|
237
|
+
section_map['sources'].each_with_index do |source, i|
|
|
238
|
+
merged['sources'] << source
|
|
239
|
+
if section_map['sourcesContent'] && section_map['sourcesContent'][i]
|
|
240
|
+
merged['sourcesContent'] << section_map['sourcesContent'][i]
|
|
241
|
+
else
|
|
242
|
+
merged['sourcesContent'] << nil
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Add names from this section
|
|
248
|
+
if section_map['names']
|
|
249
|
+
merged['names'].concat(section_map['names'])
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Process mappings from this section with index adjustment
|
|
253
|
+
if section_map['mappings'] && !section_map['mappings'].empty?
|
|
254
|
+
adjusted_mappings, prev_source, prev_orig_line, prev_orig_col, prev_name =
|
|
255
|
+
adjust_section_mappings(
|
|
256
|
+
section_map['mappings'],
|
|
257
|
+
source_offset,
|
|
258
|
+
name_offset,
|
|
259
|
+
prev_source,
|
|
260
|
+
prev_orig_line,
|
|
261
|
+
prev_orig_col,
|
|
262
|
+
prev_name
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if idx > 0 && !merged['mappings'].empty? && !merged['mappings'].end_with?(';')
|
|
266
|
+
merged['mappings'] += ';'
|
|
267
|
+
end
|
|
268
|
+
merged['mappings'] += adjusted_mappings
|
|
269
|
+
|
|
270
|
+
# Update current_line to the last line we wrote to
|
|
271
|
+
# section_start_line + number of semicolons in this section's mappings
|
|
272
|
+
current_line = section_start_line + section_map['mappings'].count(';')
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
merged
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Adjust mappings from a section by adding offsets to source/name indices
|
|
280
|
+
# VLQ mappings use relative deltas. When merging sections:
|
|
281
|
+
# - First section: no adjustment, just track final absolute state
|
|
282
|
+
# - Later sections: adjust first segment's source/name delta to bridge from previous section's end state
|
|
283
|
+
#
|
|
284
|
+
# Returns: [adjusted_mappings, new_prev_source, new_prev_orig_line, new_prev_orig_col, new_prev_name]
|
|
285
|
+
def adjust_section_mappings(mappings, source_offset, name_offset, prev_source, prev_orig_line, prev_orig_col, prev_name)
|
|
286
|
+
return ['', prev_source, prev_orig_line, prev_orig_col, prev_name] if mappings.nil? || mappings.empty?
|
|
287
|
+
|
|
288
|
+
result_lines = []
|
|
289
|
+
lines = mappings.split(';', -1)
|
|
290
|
+
|
|
291
|
+
# Track absolute state within this section (section-local, starting from 0)
|
|
292
|
+
section_abs_source = 0
|
|
293
|
+
section_abs_orig_line = 0
|
|
294
|
+
section_abs_orig_col = 0
|
|
295
|
+
section_abs_name = 0
|
|
296
|
+
|
|
297
|
+
first_segment_processed = false
|
|
298
|
+
|
|
299
|
+
lines.each do |line|
|
|
300
|
+
if line.empty?
|
|
301
|
+
result_lines << ''
|
|
302
|
+
next
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
result_segments = []
|
|
306
|
+
segments = line.split(',')
|
|
307
|
+
|
|
308
|
+
segments.each do |segment|
|
|
309
|
+
values = decode_vlq(segment)
|
|
310
|
+
next if values.empty?
|
|
311
|
+
|
|
312
|
+
# values[0] = generated column delta (always present, relative within line)
|
|
313
|
+
# values[1] = source index delta
|
|
314
|
+
# values[2] = original line delta
|
|
315
|
+
# values[3] = original column delta
|
|
316
|
+
# values[4] = name index delta
|
|
317
|
+
|
|
318
|
+
gen_col_delta = values[0]
|
|
319
|
+
|
|
320
|
+
if values.length > 1
|
|
321
|
+
# Update section-local absolute positions
|
|
322
|
+
section_abs_source += values[1]
|
|
323
|
+
section_abs_orig_line += values[2] if values.length > 2
|
|
324
|
+
section_abs_orig_col += values[3] if values.length > 3
|
|
325
|
+
section_abs_name += values[4] if values.length > 4
|
|
326
|
+
|
|
327
|
+
# Calculate global absolute positions (with offset)
|
|
328
|
+
global_abs_source = section_abs_source + source_offset
|
|
329
|
+
global_abs_name = section_abs_name + name_offset
|
|
330
|
+
|
|
331
|
+
if !first_segment_processed
|
|
332
|
+
# First segment: calculate delta from previous section's end state to this segment's global position
|
|
333
|
+
new_source_delta = global_abs_source - prev_source
|
|
334
|
+
new_orig_line_delta = section_abs_orig_line - prev_orig_line
|
|
335
|
+
new_orig_col_delta = section_abs_orig_col - prev_orig_col
|
|
336
|
+
new_name_delta = global_abs_name - prev_name
|
|
337
|
+
|
|
338
|
+
new_values = [gen_col_delta, new_source_delta, new_orig_line_delta, new_orig_col_delta]
|
|
339
|
+
new_values << new_name_delta if values.length > 4
|
|
340
|
+
|
|
341
|
+
first_segment_processed = true
|
|
342
|
+
else
|
|
343
|
+
# Subsequent segments: deltas are already correct (relative within section = relative within merged)
|
|
344
|
+
new_values = values.dup
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
result_segments << encode_vlq(new_values)
|
|
348
|
+
|
|
349
|
+
# Update tracking for next section
|
|
350
|
+
prev_source = global_abs_source
|
|
351
|
+
prev_orig_line = section_abs_orig_line
|
|
352
|
+
prev_orig_col = section_abs_orig_col
|
|
353
|
+
prev_name = global_abs_name if values.length > 4
|
|
354
|
+
else
|
|
355
|
+
# Only generated column, no source mapping
|
|
356
|
+
result_segments << encode_vlq([gen_col_delta])
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
result_lines << result_segments.join(',')
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
[result_lines.join(';'), prev_source, prev_orig_line, prev_orig_col, prev_name]
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# VLQ Base64 character set
|
|
367
|
+
VLQ_BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.freeze
|
|
368
|
+
VLQ_BASE64_VALUES = VLQ_BASE64_CHARS.each_char.with_index.to_h.freeze
|
|
369
|
+
VLQ_BASE_SHIFT = 5
|
|
370
|
+
VLQ_BASE = 1 << VLQ_BASE_SHIFT # 32
|
|
371
|
+
VLQ_BASE_MASK = VLQ_BASE - 1 # 31
|
|
372
|
+
VLQ_CONTINUATION_BIT = VLQ_BASE # 32
|
|
373
|
+
|
|
374
|
+
# Decode a VLQ-encoded segment into an array of integers
|
|
375
|
+
def decode_vlq(segment)
|
|
376
|
+
return [] if segment.nil? || segment.empty?
|
|
377
|
+
|
|
378
|
+
values = []
|
|
379
|
+
shift = 0
|
|
380
|
+
value = 0
|
|
381
|
+
|
|
382
|
+
segment.each_char do |char|
|
|
383
|
+
digit = VLQ_BASE64_VALUES[char]
|
|
384
|
+
return values if digit.nil? # Invalid character
|
|
385
|
+
|
|
386
|
+
continuation = (digit & VLQ_CONTINUATION_BIT) != 0
|
|
387
|
+
digit &= VLQ_BASE_MASK
|
|
388
|
+
value += digit << shift
|
|
389
|
+
|
|
390
|
+
if continuation
|
|
391
|
+
shift += VLQ_BASE_SHIFT
|
|
392
|
+
else
|
|
393
|
+
# Convert from VLQ signed representation
|
|
394
|
+
negative = (value & 1) == 1
|
|
395
|
+
value >>= 1
|
|
396
|
+
value = -value if negative
|
|
397
|
+
values << value
|
|
398
|
+
|
|
399
|
+
# Reset for next value
|
|
400
|
+
value = 0
|
|
401
|
+
shift = 0
|
|
402
|
+
end
|
|
161
403
|
end
|
|
162
404
|
|
|
163
|
-
|
|
405
|
+
values
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Encode an array of integers into a VLQ-encoded string
|
|
409
|
+
def encode_vlq(values)
|
|
410
|
+
return '' if values.nil? || values.empty?
|
|
411
|
+
|
|
412
|
+
result = ''
|
|
413
|
+
|
|
414
|
+
values.each do |value|
|
|
415
|
+
# Convert to VLQ signed representation
|
|
416
|
+
vlq = value < 0 ? ((-value) << 1) + 1 : value << 1
|
|
417
|
+
|
|
418
|
+
loop do
|
|
419
|
+
digit = vlq & VLQ_BASE_MASK
|
|
420
|
+
vlq >>= VLQ_BASE_SHIFT
|
|
421
|
+
digit |= VLQ_CONTINUATION_BIT if vlq > 0
|
|
422
|
+
result += VLQ_BASE64_CHARS[digit]
|
|
423
|
+
break if vlq == 0
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
result
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def normalize_source_path(source, file_path)
|
|
431
|
+
# Convert absolute paths to relative for better browser debugging
|
|
432
|
+
return source if source.nil? || source.empty?
|
|
433
|
+
|
|
434
|
+
# If source is already relative or a URL, keep it
|
|
435
|
+
return source unless source.start_with?('/')
|
|
436
|
+
|
|
437
|
+
# Try to make path relative to the original file
|
|
438
|
+
begin
|
|
439
|
+
Pathname.new(source).relative_path_from(Pathname.new(File.dirname(file_path))).to_s
|
|
440
|
+
rescue ArgumentError
|
|
441
|
+
# On different drives/mounts, keep absolute path
|
|
442
|
+
source
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def normalize_source_path_for_devtools(source, file_path)
|
|
447
|
+
# Format source paths so they appear properly in browser DevTools
|
|
448
|
+
# Chrome DevTools uses the source path to build the file tree
|
|
449
|
+
return source if source.nil? || source.empty?
|
|
450
|
+
|
|
451
|
+
# Remove any leading ./ for consistency
|
|
452
|
+
source = source.sub(/^\.\//, '')
|
|
453
|
+
|
|
454
|
+
# Handle absolute paths - make them relative to show properly
|
|
455
|
+
if source.start_with?('/')
|
|
456
|
+
# Extract just the relevant path (last few components)
|
|
457
|
+
parts = source.split('/')
|
|
458
|
+
# Keep last 3 path components for context (e.g., app/opal/controllers/...)
|
|
459
|
+
source = parts.last(3).join('/')
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Prefix paths for user code (controllers, services, etc.) to group them
|
|
463
|
+
# This ensures they appear under a dedicated folder in DevTools
|
|
464
|
+
if source.include?('controllers/') || source.include?('services/')
|
|
465
|
+
# Already has recognizable path, prefix with opal-sources for visibility
|
|
466
|
+
"/opal-sources/#{source}"
|
|
467
|
+
elsif source.start_with?('corelib/') || source.start_with?('opal/')
|
|
468
|
+
# Opal core library files - put under opal-core
|
|
469
|
+
"/opal-core/#{source}"
|
|
470
|
+
elsif source.include?('opal_stimulus/') || source.include?('opal_vite/')
|
|
471
|
+
# Opal library files
|
|
472
|
+
"/opal-libs/#{source}"
|
|
473
|
+
else
|
|
474
|
+
# Other files (native.rb, js/proxy.rb, etc.)
|
|
475
|
+
"/opal-other/#{source}"
|
|
476
|
+
end
|
|
164
477
|
end
|
|
165
478
|
end
|
|
166
479
|
end
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opal
|
|
4
|
+
module Vite
|
|
5
|
+
module Testing
|
|
6
|
+
# StableHelpers - JavaScript-based element interaction methods for Capybara
|
|
7
|
+
# All polling and retry logic runs in JavaScript for reliability
|
|
8
|
+
# Reduces Ruby ↔ JavaScript communication overhead
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# # In spec_helper.rb
|
|
12
|
+
# require 'opal/vite/testing/stable_helpers'
|
|
13
|
+
#
|
|
14
|
+
# RSpec.configure do |config|
|
|
15
|
+
# config.include Opal::Vite::Testing::StableHelpers, type: :feature
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
module StableHelpers
|
|
19
|
+
# Default configuration
|
|
20
|
+
DEFAULT_TIMEOUT = 10
|
|
21
|
+
DEFAULT_INTERVAL = 50 # ms (JavaScript side)
|
|
22
|
+
|
|
23
|
+
# Wait for element to be present and stable in DOM using JavaScript polling
|
|
24
|
+
# @param selector [String] CSS selector
|
|
25
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
26
|
+
# @return [Capybara::Node::Element] The found element
|
|
27
|
+
def stable_find(selector, timeout: DEFAULT_TIMEOUT, visible: true)
|
|
28
|
+
js_wait_for_element(selector, timeout: timeout, visible: visible)
|
|
29
|
+
find(selector, visible: visible, wait: 1)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Find all elements after ensuring DOM stability
|
|
33
|
+
# @param selector [String] CSS selector
|
|
34
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
35
|
+
# @return [Array<Capybara::Node::Element>] Found elements
|
|
36
|
+
def stable_all(selector, timeout: DEFAULT_TIMEOUT)
|
|
37
|
+
wait_for_dom_stable(timeout: timeout)
|
|
38
|
+
all(selector, wait: 1)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Click element with retry logic
|
|
42
|
+
# Uses Capybara native click with JS fallback for reliability
|
|
43
|
+
# @param selector [String] CSS selector
|
|
44
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
45
|
+
def stable_click(selector, timeout: DEFAULT_TIMEOUT)
|
|
46
|
+
escaped_selector = escape_js(selector)
|
|
47
|
+
start_time = Time.now
|
|
48
|
+
|
|
49
|
+
loop do
|
|
50
|
+
begin
|
|
51
|
+
# Use Capybara's find with wait, then click
|
|
52
|
+
element = find(selector, wait: 2, visible: true)
|
|
53
|
+
element.click
|
|
54
|
+
# Small delay to allow event processing
|
|
55
|
+
sleep(0.05)
|
|
56
|
+
return true
|
|
57
|
+
rescue Capybara::ElementNotFound, Ferrum::NodeNotFoundError, Ferrum::TimeoutError,
|
|
58
|
+
Capybara::Cuprite::MouseEventFailed => e
|
|
59
|
+
# If native click fails, try JavaScript click
|
|
60
|
+
js_clicked = page.evaluate_script(<<~JS)
|
|
61
|
+
(function() {
|
|
62
|
+
var el = document.querySelector('#{escaped_selector}');
|
|
63
|
+
if (el && el.offsetParent !== null) {
|
|
64
|
+
el.click();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
})()
|
|
69
|
+
JS
|
|
70
|
+
return true if js_clicked
|
|
71
|
+
|
|
72
|
+
elapsed = Time.now - start_time
|
|
73
|
+
raise e if elapsed > timeout
|
|
74
|
+
sleep(DEFAULT_INTERVAL / 1000.0)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Set value on input with stability check
|
|
80
|
+
# @param selector [String] CSS selector
|
|
81
|
+
# @param value [String] Value to set
|
|
82
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
83
|
+
def stable_set(selector, value, timeout: DEFAULT_TIMEOUT)
|
|
84
|
+
escaped_value = escape_js(value)
|
|
85
|
+
js_retry_action(selector, 'set', value: escaped_value, timeout: timeout)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Send keys to element with stability check
|
|
89
|
+
# @param selector [String] CSS selector
|
|
90
|
+
# @param keys [Array] Keys to send
|
|
91
|
+
def stable_send_keys(selector, *keys, timeout: DEFAULT_TIMEOUT)
|
|
92
|
+
element = stable_find(selector, timeout: timeout)
|
|
93
|
+
element.native.send_keys(*keys)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Combined set and send_keys for form inputs (common pattern)
|
|
97
|
+
# Uses JavaScript for value setting, Capybara native for key events
|
|
98
|
+
# @param selector [String] CSS selector
|
|
99
|
+
# @param value [String] Value to type
|
|
100
|
+
# @param submit_key [Symbol] Key to send after value (e.g., :enter)
|
|
101
|
+
def stable_input(selector, value, submit_key: nil, timeout: DEFAULT_TIMEOUT)
|
|
102
|
+
escaped_selector = escape_js(selector)
|
|
103
|
+
escaped_value = escape_js(value)
|
|
104
|
+
start_time = Time.now
|
|
105
|
+
|
|
106
|
+
loop do
|
|
107
|
+
# Set value via JavaScript for reliability
|
|
108
|
+
result = page.evaluate_script(<<~JS)
|
|
109
|
+
(function() {
|
|
110
|
+
var el = document.querySelector('#{escaped_selector}');
|
|
111
|
+
if (!el || el.offsetParent === null) {
|
|
112
|
+
return { success: false, reason: 'not_found' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
el.focus();
|
|
117
|
+
el.value = '#{escaped_value}';
|
|
118
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
119
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
120
|
+
|
|
121
|
+
if (el.value !== '#{escaped_value}') {
|
|
122
|
+
return { success: false, reason: 'value_not_set' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { success: true };
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return { success: false, reason: e.message };
|
|
128
|
+
}
|
|
129
|
+
})()
|
|
130
|
+
JS
|
|
131
|
+
|
|
132
|
+
if result && result['success']
|
|
133
|
+
# Use Capybara's native send_keys for Enter key (more reliable with Stimulus)
|
|
134
|
+
if submit_key
|
|
135
|
+
# Small delay to ensure Stimulus has processed the value change
|
|
136
|
+
sleep(0.05)
|
|
137
|
+
element = find(selector)
|
|
138
|
+
element.native.send_keys(submit_key)
|
|
139
|
+
end
|
|
140
|
+
return true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
elapsed = Time.now - start_time
|
|
144
|
+
if elapsed > timeout
|
|
145
|
+
reason = result ? result['reason'] : 'unknown'
|
|
146
|
+
raise Capybara::ElementNotFound, "stable_input failed on '#{selector}': #{reason}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
sleep(DEFAULT_INTERVAL / 1000.0)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Wait for element count to match expected (all polling in JavaScript)
|
|
154
|
+
# @param selector [String] CSS selector
|
|
155
|
+
# @param count [Integer] Expected count
|
|
156
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
157
|
+
def wait_for_count(selector, count, timeout: DEFAULT_TIMEOUT)
|
|
158
|
+
escaped_selector = escape_js(selector)
|
|
159
|
+
js_poll_until(
|
|
160
|
+
"document.querySelectorAll('#{escaped_selector}').length === #{count}",
|
|
161
|
+
timeout: timeout,
|
|
162
|
+
error_message: "Expected #{count} elements for '#{selector}'"
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Wait for element to contain text
|
|
167
|
+
# @param selector [String] CSS selector
|
|
168
|
+
# @param text [String] Text to find
|
|
169
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
170
|
+
def wait_for_text(selector, text, timeout: DEFAULT_TIMEOUT)
|
|
171
|
+
escaped_selector = escape_js(selector)
|
|
172
|
+
escaped_text = escape_js(text)
|
|
173
|
+
js_poll_until(
|
|
174
|
+
"(function() { var el = document.querySelector('#{escaped_selector}'); return el && el.textContent.includes('#{escaped_text}'); })()",
|
|
175
|
+
timeout: timeout,
|
|
176
|
+
error_message: "Text '#{text}' not found in '#{selector}'"
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Wait for element to have specific class
|
|
181
|
+
# @param selector [String] CSS selector
|
|
182
|
+
# @param class_name [String] CSS class name
|
|
183
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
184
|
+
def wait_for_class(selector, class_name, timeout: DEFAULT_TIMEOUT)
|
|
185
|
+
escaped_selector = escape_js(selector)
|
|
186
|
+
escaped_class = escape_js(class_name)
|
|
187
|
+
js_poll_until(
|
|
188
|
+
"(function() { var el = document.querySelector('#{escaped_selector}'); return el && el.classList.contains('#{escaped_class}'); })()",
|
|
189
|
+
timeout: timeout,
|
|
190
|
+
error_message: "Class '#{class_name}' not found on '#{selector}'"
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Wait for element to NOT have specific class
|
|
195
|
+
# @param selector [String] CSS selector
|
|
196
|
+
# @param class_name [String] CSS class name
|
|
197
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
198
|
+
def wait_for_no_class(selector, class_name, timeout: DEFAULT_TIMEOUT)
|
|
199
|
+
escaped_selector = escape_js(selector)
|
|
200
|
+
escaped_class = escape_js(class_name)
|
|
201
|
+
js_poll_until(
|
|
202
|
+
"(function() { var el = document.querySelector('#{escaped_selector}'); return el && !el.classList.contains('#{escaped_class}'); })()",
|
|
203
|
+
timeout: timeout,
|
|
204
|
+
error_message: "Class '#{class_name}' still present on '#{selector}'"
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Wait for checkbox to be checked
|
|
209
|
+
# @param selector [String] CSS selector
|
|
210
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
211
|
+
def wait_for_checked(selector, timeout: DEFAULT_TIMEOUT)
|
|
212
|
+
escaped_selector = escape_js(selector)
|
|
213
|
+
js_poll_until(
|
|
214
|
+
"(function() { var el = document.querySelector('#{escaped_selector}'); return el && el.checked === true; })()",
|
|
215
|
+
timeout: timeout,
|
|
216
|
+
error_message: "Checkbox '#{selector}' not checked"
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Public API for custom JavaScript conditions
|
|
221
|
+
# @param js_condition [String] JavaScript expression that returns true when ready
|
|
222
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
223
|
+
def js_wait_for(js_condition, timeout: DEFAULT_TIMEOUT)
|
|
224
|
+
js_poll_until(js_condition, timeout: timeout, error_message: "Condition not met: #{js_condition}")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Wait for DOM to be stable (no pending mutations)
|
|
228
|
+
# Uses MutationObserver setup in JS with Ruby-based polling
|
|
229
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
230
|
+
# @param stability_time [Integer] Time in ms with no changes to consider stable
|
|
231
|
+
def wait_for_dom_stable(timeout: DEFAULT_TIMEOUT, stability_time: 100)
|
|
232
|
+
# Setup MutationObserver in JavaScript
|
|
233
|
+
page.execute_script(<<~JS)
|
|
234
|
+
window.__domStabilityState = { stable: false, lastChange: Date.now() };
|
|
235
|
+
if (window.__domObserver) window.__domObserver.disconnect();
|
|
236
|
+
|
|
237
|
+
window.__domObserver = new MutationObserver(function() {
|
|
238
|
+
window.__domStabilityState.stable = false;
|
|
239
|
+
window.__domStabilityState.lastChange = Date.now();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
window.__domObserver.observe(document.body, {
|
|
243
|
+
childList: true,
|
|
244
|
+
subtree: true,
|
|
245
|
+
attributes: true
|
|
246
|
+
});
|
|
247
|
+
JS
|
|
248
|
+
|
|
249
|
+
start_time = Time.now
|
|
250
|
+
|
|
251
|
+
# Poll for stability using Ruby loop
|
|
252
|
+
loop do
|
|
253
|
+
result = page.evaluate_script(<<~JS)
|
|
254
|
+
(function() {
|
|
255
|
+
var state = window.__domStabilityState;
|
|
256
|
+
if (!state) return { stable: true };
|
|
257
|
+
var elapsed = Date.now() - state.lastChange;
|
|
258
|
+
return { stable: elapsed >= #{stability_time}, elapsed: elapsed };
|
|
259
|
+
})()
|
|
260
|
+
JS
|
|
261
|
+
|
|
262
|
+
if result && result['stable']
|
|
263
|
+
# Cleanup observer
|
|
264
|
+
page.execute_script(<<~JS)
|
|
265
|
+
if (window.__domObserver) {
|
|
266
|
+
window.__domObserver.disconnect();
|
|
267
|
+
delete window.__domObserver;
|
|
268
|
+
}
|
|
269
|
+
delete window.__domStabilityState;
|
|
270
|
+
JS
|
|
271
|
+
return true
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
elapsed = Time.now - start_time
|
|
275
|
+
if elapsed > timeout
|
|
276
|
+
# Cleanup observer on timeout
|
|
277
|
+
page.execute_script('if (window.__domObserver) window.__domObserver.disconnect();')
|
|
278
|
+
raise Capybara::ElementNotFound, 'DOM did not stabilize within timeout'
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
sleep(DEFAULT_INTERVAL / 1000.0)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
# Wait for element to be present and visible using JavaScript polling
|
|
288
|
+
def js_wait_for_element(selector, timeout:, visible:)
|
|
289
|
+
escaped_selector = escape_js(selector)
|
|
290
|
+
visibility_check = visible ? ' && el.offsetParent !== null' : ''
|
|
291
|
+
|
|
292
|
+
js_poll_until(
|
|
293
|
+
"(function() { var el = document.querySelector('#{escaped_selector}'); return el !== null#{visibility_check}; })()",
|
|
294
|
+
timeout: timeout,
|
|
295
|
+
error_message: "Element not found: #{selector}"
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Wait for input to have specific value
|
|
300
|
+
def wait_for_value(selector, value, timeout:)
|
|
301
|
+
escaped_selector = escape_js(selector)
|
|
302
|
+
escaped_value = escape_js(value)
|
|
303
|
+
js_poll_until(
|
|
304
|
+
"(function() { var el = document.querySelector('#{escaped_selector}'); return el && el.value === '#{escaped_value}'; })()",
|
|
305
|
+
timeout: timeout,
|
|
306
|
+
error_message: "Value '#{value}' not set on '#{selector}'"
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Core JavaScript polling - all retry logic runs in browser
|
|
311
|
+
# @param js_condition [String] JavaScript expression that returns true when ready
|
|
312
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
313
|
+
# @param error_message [String] Error message on timeout
|
|
314
|
+
def js_poll_until(js_condition, timeout:, error_message: nil)
|
|
315
|
+
error_msg = error_message || "Condition not met: #{js_condition}"
|
|
316
|
+
|
|
317
|
+
# Use synchronous polling with Ruby timeout to avoid async script timeout issues
|
|
318
|
+
start_time = Time.now
|
|
319
|
+
|
|
320
|
+
loop do
|
|
321
|
+
result = page.evaluate_script(<<~JS)
|
|
322
|
+
(function() {
|
|
323
|
+
try {
|
|
324
|
+
return #{js_condition} ? true : false;
|
|
325
|
+
} catch (e) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
})()
|
|
329
|
+
JS
|
|
330
|
+
|
|
331
|
+
return true if result
|
|
332
|
+
|
|
333
|
+
elapsed = Time.now - start_time
|
|
334
|
+
raise Capybara::ElementNotFound, error_msg if elapsed > timeout
|
|
335
|
+
|
|
336
|
+
sleep(DEFAULT_INTERVAL / 1000.0)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# JavaScript-based action with Ruby retry loop (click, set, etc.)
|
|
341
|
+
# @param selector [String] CSS selector
|
|
342
|
+
# @param action [String] Action to perform ('click', 'set')
|
|
343
|
+
# @param value [String] Value for 'set' action
|
|
344
|
+
# @param timeout [Integer] Maximum wait time in seconds
|
|
345
|
+
def js_retry_action(selector, action, value: nil, timeout: DEFAULT_TIMEOUT)
|
|
346
|
+
escaped_selector = escape_js(selector)
|
|
347
|
+
escaped_value = value ? escape_js(value) : ''
|
|
348
|
+
start_time = Time.now
|
|
349
|
+
|
|
350
|
+
loop do
|
|
351
|
+
result = page.evaluate_script(<<~JS)
|
|
352
|
+
(function() {
|
|
353
|
+
var el = document.querySelector('#{escaped_selector}');
|
|
354
|
+
if (!el || el.offsetParent === null) {
|
|
355
|
+
return { success: false, reason: 'not_found' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
if ('#{action}' === 'click') {
|
|
360
|
+
el.click();
|
|
361
|
+
return { success: true };
|
|
362
|
+
} else if ('#{action}' === 'set') {
|
|
363
|
+
el.focus();
|
|
364
|
+
el.value = '#{escaped_value}';
|
|
365
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
366
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
367
|
+
return { success: true };
|
|
368
|
+
} else {
|
|
369
|
+
return { success: false, reason: 'unknown_action' };
|
|
370
|
+
}
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return { success: false, reason: e.message };
|
|
373
|
+
}
|
|
374
|
+
})()
|
|
375
|
+
JS
|
|
376
|
+
|
|
377
|
+
return true if result && result['success']
|
|
378
|
+
|
|
379
|
+
elapsed = Time.now - start_time
|
|
380
|
+
if elapsed > timeout
|
|
381
|
+
reason = result ? result['reason'] : 'unknown'
|
|
382
|
+
raise Capybara::ElementNotFound, "Action '#{action}' failed on '#{selector}': #{reason}"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
sleep(DEFAULT_INTERVAL / 1000.0)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Escape string for JavaScript
|
|
390
|
+
def escape_js(str)
|
|
391
|
+
str.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'").gsub("\n", '\\n')
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Alias for backward compatibility
|
|
399
|
+
StableHelpers = Opal::Vite::Testing::StableHelpers
|
data/lib/opal/vite/version.rb
CHANGED
data/lib/opal-vite.rb
CHANGED
|
@@ -30,12 +30,20 @@ module Opal
|
|
|
30
30
|
# CLI entry point for compilation
|
|
31
31
|
# @param file_path [String] The path to the Ruby file to compile
|
|
32
32
|
# @param include_concerns [Boolean] Whether to include built-in concerns
|
|
33
|
-
|
|
33
|
+
# @param source_map [Boolean] Whether to generate source maps
|
|
34
|
+
def compile_for_vite(file_path, include_concerns: true, source_map: true)
|
|
35
|
+
# Temporarily override source map setting if specified
|
|
36
|
+
original_source_map = config.source_map_enabled
|
|
37
|
+
config.source_map_enabled = source_map
|
|
38
|
+
|
|
34
39
|
compiler = Compiler.new(include_concerns: include_concerns)
|
|
35
40
|
result = compiler.compile_file(file_path)
|
|
36
41
|
|
|
37
42
|
# Output JSON to stdout for the Vite plugin to consume
|
|
38
43
|
puts JSON.generate(result)
|
|
44
|
+
|
|
45
|
+
# Restore original setting
|
|
46
|
+
config.source_map_enabled = original_source_map
|
|
39
47
|
rescue Compiler::CompilationError => e
|
|
40
48
|
STDERR.puts e.message
|
|
41
49
|
exit 1
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: opal-vite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- stofu1234
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- lib/opal/vite/compiler.rb
|
|
110
110
|
- lib/opal/vite/config.rb
|
|
111
111
|
- lib/opal/vite/source_map.rb
|
|
112
|
+
- lib/opal/vite/testing/stable_helpers.rb
|
|
112
113
|
- lib/opal/vite/version.rb
|
|
113
114
|
- opal/opal_vite.rb
|
|
114
115
|
- opal/opal_vite/concerns.rb
|