tui-td 0.2.10 → 0.2.12
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/CHANGELOG.md +29 -0
- data/lib/tui_td/ansi_parser.rb +3 -719
- data/lib/tui_td/ansi_utils.rb +3 -71
- data/lib/tui_td/cairo_renderer.rb +5 -2
- data/lib/tui_td/cli.rb +12 -10
- data/lib/tui_td/driver.rb +54 -14
- data/lib/tui_td/html_renderer.rb +19 -17
- data/lib/tui_td/matchers.rb +21 -12
- data/lib/tui_td/mcp/server.rb +104 -87
- data/lib/tui_td/screenshot.rb +70 -52
- data/lib/tui_td/state.rb +3 -117
- data/lib/tui_td/test_runner.rb +41 -27
- data/lib/tui_td/unifont_glyphs.rb +2142 -2141
- data/lib/tui_td/version.rb +1 -1
- data/lib/tui_td.rb +7 -3
- metadata +50 -7
data/lib/tui_td/screenshot.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockNesting, Metrics/ParameterLists, Metrics/ClassLength, Metrics/CollectionLiteralLength
|
|
4
|
+
|
|
3
5
|
require "chunky_png"
|
|
4
6
|
require_relative "ansi_utils"
|
|
5
7
|
require_relative "cairo_renderer"
|
|
@@ -200,7 +202,7 @@ module TUITD
|
|
|
200
202
|
"╧" => [true, false, true, true, :double],
|
|
201
203
|
"╨" => [true, false, true, true, :double],
|
|
202
204
|
"╪" => [true, true, true, true, :double],
|
|
203
|
-
"╫" => [true, true, true, true, :double]
|
|
205
|
+
"╫" => [true, true, true, true, :double],
|
|
204
206
|
}.freeze
|
|
205
207
|
|
|
206
208
|
private_constant :FONT
|
|
@@ -219,8 +221,10 @@ module TUITD
|
|
|
219
221
|
|
|
220
222
|
@grid.each_with_index do |row, ri|
|
|
221
223
|
next unless row
|
|
224
|
+
|
|
222
225
|
row.each_with_index do |cell, ci|
|
|
223
226
|
next unless cell
|
|
227
|
+
|
|
224
228
|
render_cell(image, ri, ci, cell)
|
|
225
229
|
end
|
|
226
230
|
end
|
|
@@ -256,15 +260,15 @@ module TUITD
|
|
|
256
260
|
end
|
|
257
261
|
|
|
258
262
|
char_ord = char.ord
|
|
259
|
-
if char_ord ==
|
|
263
|
+
if char_ord == 10_095 # '❯'
|
|
260
264
|
draw_chevron(image, px, py, fg_rgb)
|
|
261
265
|
draw_underline(image, px, py, CELL_W, fg_rgb) if underline
|
|
262
266
|
return
|
|
263
|
-
elsif
|
|
267
|
+
elsif [9210, 9679].include?(char_ord) # '⏺' or '●'
|
|
264
268
|
draw_circle(image, px, py, fg_rgb)
|
|
265
269
|
draw_underline(image, px, py, CELL_W, fg_rgb) if underline
|
|
266
270
|
return
|
|
267
|
-
elsif char_ord
|
|
271
|
+
elsif char_ord.between?(0x2800, 0x28ff) # Braille spinner
|
|
268
272
|
draw_braille(image, px, py, char, fg_rgb)
|
|
269
273
|
draw_underline(image, px, py, CELL_W, fg_rgb) if underline
|
|
270
274
|
return
|
|
@@ -399,7 +403,7 @@ module TUITD
|
|
|
399
403
|
|
|
400
404
|
def glyph_rows(char)
|
|
401
405
|
idx = (char.ord - 32) * 16
|
|
402
|
-
return nil if idx
|
|
406
|
+
return nil if idx.negative? || idx + 15 >= FONT.length
|
|
403
407
|
|
|
404
408
|
FONT[idx, 16]
|
|
405
409
|
end
|
|
@@ -408,7 +412,7 @@ module TUITD
|
|
|
408
412
|
color = ChunkyPNG::Color.rgb(*fg_rgb)
|
|
409
413
|
|
|
410
414
|
rows.each_with_index do |byte, dy|
|
|
411
|
-
next if byte
|
|
415
|
+
next if byte.zero?
|
|
412
416
|
|
|
413
417
|
slant = italic ? dy / 8 : 0
|
|
414
418
|
|
|
@@ -429,7 +433,7 @@ module TUITD
|
|
|
429
433
|
|
|
430
434
|
def box_drawing?(char)
|
|
431
435
|
char_ord = char.ord
|
|
432
|
-
char_ord
|
|
436
|
+
char_ord.between?(0x2500, 0x257F)
|
|
433
437
|
end
|
|
434
438
|
|
|
435
439
|
def draw_box_character(image, px, py, char, fg_rgb)
|
|
@@ -438,10 +442,18 @@ module TUITD
|
|
|
438
442
|
unless config
|
|
439
443
|
char_ord = char.ord
|
|
440
444
|
if [0x2500, 0x2501, 0x2504, 0x2505, 0x2508, 0x2509, 0x254c, 0x254d, 0x2550].include?(char_ord)
|
|
441
|
-
style = [0x2501, 0x2505, 0x2509, 0x254d].include?(char_ord)
|
|
445
|
+
style = if [0x2501, 0x2505, 0x2509, 0x254d].include?(char_ord)
|
|
446
|
+
:heavy
|
|
447
|
+
else
|
|
448
|
+
(char_ord == 0x2550 ? :double : :light)
|
|
449
|
+
end
|
|
442
450
|
config = [false, false, true, true, style]
|
|
443
451
|
elsif [0x2502, 0x2503, 0x2506, 0x2507, 0x250a, 0x250b, 0x254e, 0x254f, 0x2551].include?(char_ord)
|
|
444
|
-
style = [0x2503, 0x2507, 0x250b, 0x254f].include?(char_ord)
|
|
452
|
+
style = if [0x2503, 0x2507, 0x250b, 0x254f].include?(char_ord)
|
|
453
|
+
:heavy
|
|
454
|
+
else
|
|
455
|
+
(char_ord == 0x2551 ? :double : :light)
|
|
456
|
+
end
|
|
445
457
|
config = [true, true, false, false, style]
|
|
446
458
|
else
|
|
447
459
|
config = [true, true, true, true, :light]
|
|
@@ -454,24 +466,31 @@ module TUITD
|
|
|
454
466
|
|
|
455
467
|
color = ChunkyPNG::Color.rgb(*fg_rgb)
|
|
456
468
|
|
|
457
|
-
|
|
469
|
+
case style
|
|
470
|
+
when :double
|
|
458
471
|
if left
|
|
459
|
-
(px..(cx + 2)).each
|
|
460
|
-
|
|
472
|
+
(px..(cx + 2)).each do |x|
|
|
473
|
+
image[x, py + 6] = color
|
|
474
|
+
image[x, py + 10] = color
|
|
475
|
+
end
|
|
461
476
|
end
|
|
462
477
|
if right
|
|
463
478
|
((cx - 2)..(px + 7)).each { |x| image[x, py + 6] = color }
|
|
464
479
|
((cx - 2)..(px + 10)).each { |x| image[x, py + 10] = color }
|
|
465
480
|
end
|
|
466
481
|
if up
|
|
467
|
-
(py..(cy + 2)).each
|
|
468
|
-
|
|
482
|
+
(py..(cy + 2)).each do |y|
|
|
483
|
+
image[px + 2, y] = color
|
|
484
|
+
image[px + 6, y] = color
|
|
485
|
+
end
|
|
469
486
|
end
|
|
470
487
|
if down
|
|
471
|
-
((cy - 2)..(py + 15)).each
|
|
472
|
-
|
|
488
|
+
((cy - 2)..(py + 15)).each do |y|
|
|
489
|
+
image[px + 2, y] = color
|
|
490
|
+
image[px + 6, y] = color
|
|
491
|
+
end
|
|
473
492
|
end
|
|
474
|
-
|
|
493
|
+
when :heavy
|
|
475
494
|
if left
|
|
476
495
|
(px..cx).each do |x|
|
|
477
496
|
image[x, cy - 1] = color
|
|
@@ -500,42 +519,34 @@ module TUITD
|
|
|
500
519
|
image[cx + 1, y] = color
|
|
501
520
|
end
|
|
502
521
|
end
|
|
503
|
-
|
|
522
|
+
when :light_rounded
|
|
504
523
|
case char
|
|
505
524
|
when "╭"
|
|
506
|
-
(px + 5..px + 7).each { |x| image[x, py + 8] = color }
|
|
507
|
-
(py + 10..py + 15).each { |y| image[px + 4, y] = color }
|
|
525
|
+
((px + 5)..(px + 7)).each { |x| image[x, py + 8] = color }
|
|
526
|
+
((py + 10)..(py + 15)).each { |y| image[px + 4, y] = color }
|
|
508
527
|
image[px + 4, py + 9] = color
|
|
509
528
|
image[px + 5, py + 9] = color
|
|
510
529
|
when "╮"
|
|
511
|
-
(px..px + 3).each { |x| image[x, py + 8] = color }
|
|
512
|
-
(py + 10..py + 15).each { |y| image[px + 4, y] = color }
|
|
530
|
+
(px..(px + 3)).each { |x| image[x, py + 8] = color }
|
|
531
|
+
((py + 10)..(py + 15)).each { |y| image[px + 4, y] = color }
|
|
513
532
|
image[px + 4, py + 9] = color
|
|
514
533
|
image[px + 3, py + 9] = color
|
|
515
534
|
when "╯"
|
|
516
|
-
(px..px + 3).each { |x| image[x, py + 8] = color }
|
|
517
|
-
(py..py + 6).each { |y| image[px + 4, y] = color }
|
|
535
|
+
(px..(px + 3)).each { |x| image[x, py + 8] = color }
|
|
536
|
+
(py..(py + 6)).each { |y| image[px + 4, y] = color }
|
|
518
537
|
image[px + 4, py + 7] = color
|
|
519
538
|
image[px + 3, py + 7] = color
|
|
520
539
|
when "╰"
|
|
521
|
-
(px + 5..px + 7).each { |x| image[x, py + 8] = color }
|
|
522
|
-
(py..py + 6).each { |y| image[px + 4, y] = color }
|
|
540
|
+
((px + 5)..(px + 7)).each { |x| image[x, py + 8] = color }
|
|
541
|
+
(py..(py + 6)).each { |y| image[px + 4, y] = color }
|
|
523
542
|
image[px + 4, py + 7] = color
|
|
524
543
|
image[px + 5, py + 7] = color
|
|
525
544
|
end
|
|
526
545
|
else # :light
|
|
527
|
-
if left
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if
|
|
531
|
-
(cx..(px + 7)).each { |x| image[x, cy] = color }
|
|
532
|
-
end
|
|
533
|
-
if up
|
|
534
|
-
(py..cy).each { |y| image[cx, y] = color }
|
|
535
|
-
end
|
|
536
|
-
if down
|
|
537
|
-
(cy..(py + 15)).each { |y| image[cx, y] = color }
|
|
538
|
-
end
|
|
546
|
+
(px..cx).each { |x| image[x, cy] = color } if left
|
|
547
|
+
(cx..(px + 7)).each { |x| image[x, cy] = color } if right
|
|
548
|
+
(py..cy).each { |y| image[cx, y] = color } if up
|
|
549
|
+
(cy..(py + 15)).each { |y| image[cx, y] = color } if down
|
|
539
550
|
end
|
|
540
551
|
end
|
|
541
552
|
|
|
@@ -551,7 +562,7 @@ module TUITD
|
|
|
551
562
|
ri = cursor_info[:row] || cursor_info["row"] || 0
|
|
552
563
|
ci = cursor_info[:col] || cursor_info["col"] || 0
|
|
553
564
|
|
|
554
|
-
return if ri
|
|
565
|
+
return if ri.negative? || ri >= @rows || ci.negative? || ci >= @cols
|
|
555
566
|
|
|
556
567
|
style_val = @state[:cursor_style] || cursor_info[:style] || cursor_info["style"] || 1
|
|
557
568
|
|
|
@@ -568,6 +579,7 @@ module TUITD
|
|
|
568
579
|
x = px + dx
|
|
569
580
|
y = py + dy
|
|
570
581
|
next if x >= image.width || y >= image.height
|
|
582
|
+
|
|
571
583
|
original_color = image[x, y]
|
|
572
584
|
r = 255 - ChunkyPNG::Color.r(original_color)
|
|
573
585
|
g = 255 - ChunkyPNG::Color.g(original_color)
|
|
@@ -579,9 +591,11 @@ module TUITD
|
|
|
579
591
|
2.times do |h_offset|
|
|
580
592
|
y = py + CELL_H - 1 - h_offset
|
|
581
593
|
next if y >= image.height
|
|
594
|
+
|
|
582
595
|
CELL_W.times do |dx|
|
|
583
596
|
x = px + dx
|
|
584
597
|
next if x >= image.width
|
|
598
|
+
|
|
585
599
|
image[x, y] = color
|
|
586
600
|
end
|
|
587
601
|
end
|
|
@@ -589,9 +603,11 @@ module TUITD
|
|
|
589
603
|
2.times do |w_offset|
|
|
590
604
|
x = px + w_offset
|
|
591
605
|
next if x >= image.width
|
|
606
|
+
|
|
592
607
|
CELL_H.times do |dy|
|
|
593
608
|
y = py + dy
|
|
594
609
|
next if y >= image.height
|
|
610
|
+
|
|
595
611
|
image[x, y] = color
|
|
596
612
|
end
|
|
597
613
|
end
|
|
@@ -603,7 +619,7 @@ module TUITD
|
|
|
603
619
|
(0..3).each do |i|
|
|
604
620
|
image[px + 2 + i, py + 4 + i] = color
|
|
605
621
|
image[px + 3 + i, py + 4 + i] = color # bold/thick chevron
|
|
606
|
-
|
|
622
|
+
|
|
607
623
|
image[px + 5 - i, py + 8 + i] = color
|
|
608
624
|
image[px + 6 - i, py + 8 + i] = color # bold/thick chevron
|
|
609
625
|
end
|
|
@@ -623,6 +639,7 @@ module TUITD
|
|
|
623
639
|
x = cx + dx
|
|
624
640
|
y = cy + dy
|
|
625
641
|
next if x < px || x >= px + CELL_W || y < py || y >= py + CELL_H
|
|
642
|
+
|
|
626
643
|
image[x, y] = color
|
|
627
644
|
end
|
|
628
645
|
end
|
|
@@ -639,17 +656,18 @@ module TUITD
|
|
|
639
656
|
[5, 6], # Dot 5
|
|
640
657
|
[5, 9], # Dot 6
|
|
641
658
|
[2, 12], # Dot 7
|
|
642
|
-
[5, 12]
|
|
659
|
+
[5, 12], # Dot 8
|
|
643
660
|
]
|
|
644
661
|
dot_coords.each_with_index do |(dx, dy), idx|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
662
|
+
next unless mask.anybits?(1 << idx)
|
|
663
|
+
|
|
664
|
+
2.times do |ddy|
|
|
665
|
+
2.times do |ddx|
|
|
666
|
+
x = px + dx + ddx
|
|
667
|
+
y = py + dy + ddy
|
|
668
|
+
next if x >= image.width || y >= image.height
|
|
669
|
+
|
|
670
|
+
image[x, y] = color
|
|
653
671
|
end
|
|
654
672
|
end
|
|
655
673
|
end
|
|
@@ -659,7 +677,7 @@ module TUITD
|
|
|
659
677
|
color = ChunkyPNG::Color.rgb(*fg_rgb)
|
|
660
678
|
(5..8).each do |dy|
|
|
661
679
|
width = dy - 5
|
|
662
|
-
(4 - width..4 + width).each do |dx|
|
|
680
|
+
((4 - width)..(4 + width)).each do |dx|
|
|
663
681
|
image[px + dx, py + dy] = color
|
|
664
682
|
end
|
|
665
683
|
end
|
|
@@ -669,7 +687,7 @@ module TUITD
|
|
|
669
687
|
color = ChunkyPNG::Color.rgb(*fg_rgb)
|
|
670
688
|
(5..8).each do |dy|
|
|
671
689
|
width = 8 - dy
|
|
672
|
-
(4 - width..4 + width).each do |dx|
|
|
690
|
+
((4 - width)..(4 + width)).each do |dx|
|
|
673
691
|
image[px + dx, py + dy] = color
|
|
674
692
|
end
|
|
675
693
|
end
|
|
@@ -874,6 +892,6 @@ module TUITD
|
|
|
874
892
|
image[px + 3, py + 8] = color
|
|
875
893
|
image[px + 4, py + 8] = color
|
|
876
894
|
end
|
|
877
|
-
|
|
878
895
|
end
|
|
879
896
|
end
|
|
897
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockNesting, Metrics/ParameterLists, Metrics/ClassLength, Metrics/CollectionLiteralLength
|
data/lib/tui_td/state.rb
CHANGED
|
@@ -1,121 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
# Represents the parsed state of a terminal screen.
|
|
5
|
-
# Provides high-level query methods for AI consumption.
|
|
6
|
-
class State
|
|
7
|
-
attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
|
|
8
|
-
|
|
9
|
-
def initialize(data)
|
|
10
|
-
raise ArgumentError, "State data must include :size key" unless data[:size]
|
|
11
|
-
raise ArgumentError, "State data must include :rows key" unless data[:rows]
|
|
12
|
-
|
|
13
|
-
@rows = data[:size][:rows]
|
|
14
|
-
@cols = data[:size][:cols]
|
|
15
|
-
@grid = data[:rows]
|
|
16
|
-
@cursor = data[:cursor]
|
|
17
|
-
|
|
18
|
-
cursor_info = data[:cursor].is_a?(Hash) ? data[:cursor] : {}
|
|
19
|
-
@cursor_visible = data.key?(:cursor_visible) ? data[:cursor_visible] : (cursor_info[:visible] != false)
|
|
20
|
-
@cursor_style = data.key?(:cursor_style) ? data[:cursor_style] : (cursor_info[:style] || 1)
|
|
21
|
-
|
|
22
|
-
@mouse_mode = data[:mouse_mode] || :none
|
|
23
|
-
@mouse_format = data[:mouse_format] || :normal
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Get plain text of the entire terminal (no ANSI)
|
|
27
|
-
def plain_text
|
|
28
|
-
@grid.map { |row| row.map { |c| c[:char] }.join.rstrip }.join("\n")
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Get text at a specific position
|
|
32
|
-
def text_at(row, col, length = @cols - col)
|
|
33
|
-
return "" if row >= @rows || col >= @cols
|
|
34
|
-
@grid[row][col, length].map { |c| c[:char] }.join
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Search for text across the entire terminal
|
|
38
|
-
def find_text(pattern)
|
|
39
|
-
results = []
|
|
40
|
-
@grid.each_with_index do |row, ri|
|
|
41
|
-
text = row.map { |c| c[:char] }.join
|
|
42
|
-
pos = 0
|
|
43
|
-
while (match = text.index(pattern, pos))
|
|
44
|
-
results << { row: ri, col: match, text: pattern, full_line: text }
|
|
45
|
-
pos = match + 1
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
results
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Get the color at a specific cell
|
|
52
|
-
def foreground_at(row, col)
|
|
53
|
-
return nil if row >= @rows || col >= @cols
|
|
54
|
-
@grid[row][col][:fg]
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def background_at(row, col)
|
|
58
|
-
return nil if row >= @rows || col >= @cols
|
|
59
|
-
@grid[row][col][:bg]
|
|
60
|
-
end
|
|
3
|
+
require "tans-parser"
|
|
61
4
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
cell = @grid[row][col]
|
|
65
|
-
{ bold: cell[:bold], italic: cell[:italic], underline: cell[:underline] }
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def to_ai_json
|
|
69
|
-
h = extract_highlights
|
|
70
|
-
cursor_info = @cursor.is_a?(Hash) ? @cursor : {}
|
|
71
|
-
r = cursor_info[:row] || cursor_info["row"] || 0
|
|
72
|
-
c = cursor_info[:col] || cursor_info["col"] || 0
|
|
73
|
-
styled_count = h.count { |hl| hl[:bold] || hl[:italic] || hl[:underline] || hl[:fg] || hl[:bg] }
|
|
74
|
-
|
|
75
|
-
summary = +"Cursor at [#{r},#{c}]. "
|
|
76
|
-
summary << "#{styled_count} styled row#{styled_count == 1 ? '' : 's'}"
|
|
77
|
-
fgs = h.flat_map { |hl| hl[:fg] }.compact.uniq
|
|
78
|
-
bgs = h.flat_map { |hl| hl[:bg] }.compact.uniq
|
|
79
|
-
summary << ", colors: fg=#{fgs.sort.join(',')}" unless fgs.empty?
|
|
80
|
-
summary << ", bg=#{bgs.sort.join(',')}" unless bgs.empty?
|
|
81
|
-
summary << "."
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
size: { rows: @rows, cols: @cols },
|
|
85
|
-
cursor: cursor_info,
|
|
86
|
-
text: plain_text,
|
|
87
|
-
highlights: h,
|
|
88
|
-
summary: summary,
|
|
89
|
-
}
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
private
|
|
93
|
-
|
|
94
|
-
def extract_highlights
|
|
95
|
-
highlights = []
|
|
96
|
-
@grid.each_with_index do |row, ri|
|
|
97
|
-
row_text = row.map { |c| c[:char] }.join
|
|
98
|
-
next if row_text.strip.empty?
|
|
99
|
-
|
|
100
|
-
fgs = row.map { |c| c[:fg] || c["fg"] || "default" }
|
|
101
|
-
.uniq.reject { |c| c == "default" }
|
|
102
|
-
bgs = row.map { |c| c[:bg] || c["bg"] || "default" }
|
|
103
|
-
.uniq.reject { |c| c == "default" }
|
|
104
|
-
bold = row.any? { |c| c[:bold] || c["bold"] }
|
|
105
|
-
italic = row.any? { |c| c[:italic] || c["italic"] }
|
|
106
|
-
underline = row.any? { |c| c[:underline] || c["underline"] }
|
|
107
|
-
|
|
108
|
-
next if fgs.empty? && bgs.empty? && !bold && !italic && !underline
|
|
109
|
-
|
|
110
|
-
h = { row: ri, text: row_text }
|
|
111
|
-
h[:bold] = true if bold
|
|
112
|
-
h[:italic] = true if italic
|
|
113
|
-
h[:underline] = true if underline
|
|
114
|
-
h[:fg] = fgs.size == 1 ? fgs.first : fgs unless fgs.empty?
|
|
115
|
-
h[:bg] = bgs.size == 1 ? bgs.first : bgs unless bgs.empty?
|
|
116
|
-
highlights << h
|
|
117
|
-
end
|
|
118
|
-
highlights
|
|
119
|
-
end
|
|
120
|
-
end
|
|
5
|
+
module TUITD
|
|
6
|
+
State = TansParser::State
|
|
121
7
|
end
|
data/lib/tui_td/test_runner.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
|
|
4
|
+
|
|
3
5
|
require "json"
|
|
6
|
+
require "shellwords"
|
|
4
7
|
|
|
5
8
|
module TUITD
|
|
6
9
|
# Executes TUI tests defined in JSON format.
|
|
@@ -39,10 +42,6 @@ module TUITD
|
|
|
39
42
|
@on_step = on_step
|
|
40
43
|
rescue JSON::ParserError => e
|
|
41
44
|
raise Error, "Invalid JSON: #{e.message}"
|
|
42
|
-
@plan[:steps] = @plan[:steps].map { |s| s.transform_keys(&:to_sym) }
|
|
43
|
-
@plan[:before_all] = @plan[:before_all]&.map { |s| s.transform_keys(&:to_sym) }
|
|
44
|
-
@plan[:after_all] = @plan[:after_all]&.map { |s| s.transform_keys(&:to_sym) }
|
|
45
|
-
@on_step = on_step
|
|
46
45
|
end
|
|
47
46
|
|
|
48
47
|
def run
|
|
@@ -55,7 +54,7 @@ module TUITD
|
|
|
55
54
|
hooks = [
|
|
56
55
|
{ label: :before_all, steps: @plan[:before_all] || [] },
|
|
57
56
|
{ label: :main, steps: @plan[:steps] },
|
|
58
|
-
{ label: :after_all, steps: @plan[:after_all] || [] }
|
|
57
|
+
{ label: :after_all, steps: @plan[:after_all] || [] },
|
|
59
58
|
]
|
|
60
59
|
|
|
61
60
|
all_results = []
|
|
@@ -120,18 +119,19 @@ module TUITD
|
|
|
120
119
|
if match
|
|
121
120
|
Result.new(step: action, passed: true, message: "Style at [#{row},#{col}] matches #{expected}")
|
|
122
121
|
else
|
|
123
|
-
Result.new(step: action, passed: false,
|
|
122
|
+
Result.new(step: action, passed: false,
|
|
123
|
+
message: "Style at [#{row},#{col}] is #{actual}, expected #{expected}",)
|
|
124
124
|
end
|
|
125
125
|
|
|
126
126
|
when "screenshot"
|
|
127
127
|
ensure_driver!(driver)
|
|
128
|
-
path =
|
|
128
|
+
path = safe_output_path(value, "png")
|
|
129
129
|
driver.screenshot(path)
|
|
130
130
|
Result.new(step: action, passed: true, message: "Saved: #{path}")
|
|
131
131
|
|
|
132
132
|
when "html"
|
|
133
133
|
ensure_driver!(driver)
|
|
134
|
-
path =
|
|
134
|
+
path = safe_output_path(value, "html")
|
|
135
135
|
HtmlRenderer.new(driver.state_data).render(path)
|
|
136
136
|
Result.new(step: action, passed: true, message: "Saved: #{path}")
|
|
137
137
|
|
|
@@ -159,7 +159,6 @@ module TUITD
|
|
|
159
159
|
else
|
|
160
160
|
Result.new(step: action, passed: false, message: "Unknown action: #{action}")
|
|
161
161
|
end
|
|
162
|
-
|
|
163
162
|
rescue StandardError => e
|
|
164
163
|
r = Result.new(step: action, passed: false, message: "#{e.class}: #{e.message}")
|
|
165
164
|
end
|
|
@@ -167,23 +166,23 @@ module TUITD
|
|
|
167
166
|
all_results << r
|
|
168
167
|
all_passed &&= r.passed
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
@on_step.call(
|
|
178
|
-
index: all_results.size - 1,
|
|
179
|
-
total: total_steps,
|
|
180
|
-
action: action,
|
|
181
|
-
value: value,
|
|
182
|
-
result: r,
|
|
183
|
-
driver: driver,
|
|
184
|
-
state_data: state_data
|
|
185
|
-
)
|
|
169
|
+
next unless @on_step
|
|
170
|
+
|
|
171
|
+
state_data = nil
|
|
172
|
+
begin
|
|
173
|
+
state_data = driver.state_data if driver
|
|
174
|
+
rescue StandardError
|
|
175
|
+
# ignore — state retrieval is best-effort
|
|
186
176
|
end
|
|
177
|
+
@on_step.call(
|
|
178
|
+
index: all_results.size - 1,
|
|
179
|
+
total: total_steps,
|
|
180
|
+
action: action,
|
|
181
|
+
value: value,
|
|
182
|
+
result: r,
|
|
183
|
+
driver: driver,
|
|
184
|
+
state_data: state_data,
|
|
185
|
+
)
|
|
187
186
|
end
|
|
188
187
|
end
|
|
189
188
|
|
|
@@ -192,12 +191,25 @@ module TUITD
|
|
|
192
191
|
{
|
|
193
192
|
name: @plan[:name] || "(unnamed)",
|
|
194
193
|
passed: all_passed,
|
|
195
|
-
results: all_results.map(&:to_h)
|
|
194
|
+
results: all_results.map(&:to_h),
|
|
196
195
|
}
|
|
197
196
|
end
|
|
198
197
|
|
|
198
|
+
ALLOWED_OUTPUT_DIRS = ["/tmp"].freeze
|
|
199
|
+
|
|
199
200
|
private
|
|
200
201
|
|
|
202
|
+
def safe_output_path(value, ext)
|
|
203
|
+
default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
|
|
204
|
+
resolved = File.expand_path(value.is_a?(String) ? value : default)
|
|
205
|
+
|
|
206
|
+
unless ALLOWED_OUTPUT_DIRS.any? { |dir| resolved.start_with?(File.expand_path(dir)) }
|
|
207
|
+
raise TUITD::Error, "Output path must be under one of: #{ALLOWED_OUTPUT_DIRS.join(", ")}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
resolved
|
|
211
|
+
end
|
|
212
|
+
|
|
201
213
|
def ensure_driver!(driver)
|
|
202
214
|
raise Error, "No session. Add a 'start' step first." if driver.nil?
|
|
203
215
|
end
|
|
@@ -261,8 +273,10 @@ module TUITD
|
|
|
261
273
|
if actual == expected
|
|
262
274
|
Result.new(step: step.keys.first.to_s, passed: true, message: "#{label} at [#{row},#{col}] is #{expected}")
|
|
263
275
|
else
|
|
264
|
-
Result.new(step: step.keys.first.to_s, passed: false,
|
|
276
|
+
Result.new(step: step.keys.first.to_s, passed: false,
|
|
277
|
+
message: "#{label} at [#{row},#{col}] is #{actual}, expected #{expected}",)
|
|
265
278
|
end
|
|
266
279
|
end
|
|
267
280
|
end
|
|
268
281
|
end
|
|
282
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
|