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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e029e7e67ed9e66aa052a3a3d22270d54dae425acf1a59727258f89a5c4ef958
4
- data.tar.gz: c49bc230e812d7b59eb27461481ea6b0b0181c1161f06df6e20161d528ff4a5b
3
+ metadata.gz: 19c908ad19445dc3ca95af2873ad94c885fd3546de85a78005cf3d45a10c2b7f
4
+ data.tar.gz: 10fd5fb0daf18cf0eeb466079307eb6f47f913c4e7e1e7031c7d7944beff4cb2
5
5
  SHA512:
6
- metadata.gz: d40a68ab5d0014a65fa51a4afd3ed7f464cb3a6c3c31df660197de4c6123e61dfd1c40ede9bde91c9c4cf125d89dce725c0b5c3958efad1be669a98e679f23ad
7
- data.tar.gz: 1e3f8787982405109068f33b4beff9ca9a9a6860a1069a442bdf09a19f96335997e3a0ba2fe2895a57d7f002f38eaf0217130a7b4dfa44e62b4ea6f7459b019d
6
+ metadata.gz: 0fca963918760eb442d55982d5de06875ed92293ad58a0d422f095f4e72bf2181ebbdb4ea99bdd978473d860b5f2948d98e92550938df0e7310fd707e94228b2
7
+ data.tar.gz: 6016bfab349b9b24af874cdf416bbc2dde40e6e106c30ce18157c609d7de418f1935c79d6fe782d75cb3ff03d49385f1c4b93994baf77e5e45aea90b834e3e1c
@@ -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
- # Try to extract source map if available
42
+ # Extract source map if enabled
42
43
  if @config.source_map_enabled
43
44
  begin
44
- # Opal::Builder should have source map information
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
- # Get source map from the builder's processed assets
146
- # Opal stores source maps in the compiled assets
147
- return nil unless builder.processed.any?
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
- # Get the main compiled asset (usually the last one)
150
- main_asset = builder.processed.last
151
- return nil unless main_asset
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
- # Check if the asset has source map data
154
- if main_asset.respond_to?(:source_map) && main_asset.source_map
155
- return main_asset.source_map.to_json
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
- # Try to get from the builder directly
159
- if builder.respond_to?(:source_map) && builder.source_map
160
- return builder.source_map.to_json
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
- nil
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
@@ -1,5 +1,5 @@
1
1
  module Opal
2
2
  module Vite
3
- VERSION = "0.2.3"
3
+ VERSION = "0.2.6"
4
4
  end
5
5
  end
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
- def compile_for_vite(file_path, include_concerns: true)
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.3
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