lumitrace 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/docs/spec.md +119 -2
- data/docs/supported_syntax.md +7 -0
- data/docs/tutorial.ja.md +67 -31
- data/docs/tutorial.md +67 -0
- data/lib/lumitrace/generate_resulted_html.rb +327 -393
- data/lib/lumitrace/generate_resulted_html_renderer.js +774 -0
- data/lib/lumitrace/record_instrument.rb +127 -27
- data/lib/lumitrace/version.rb +1 -1
- data/lib/lumitrace.rb +36 -7
- data/runv/index.html +1234 -427
- data/runv/sync_inline.rb +13 -1
- data/test/test_lumitrace.rb +295 -0
- metadata +2 -1
data/runv/index.html
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>LumiTrace Playground</title>
|
|
7
|
-
<meta name="description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.
|
|
7
|
+
<meta name="description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.">
|
|
8
8
|
<meta property="og:title" content="LumiTrace Playground">
|
|
9
|
-
<meta property="og:description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.
|
|
9
|
+
<meta property="og:description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.">
|
|
10
10
|
<meta property="og:type" content="website">
|
|
11
11
|
<meta property="og:url" content="https://github.com/ko1/lumitrace/tree/master/runv">
|
|
12
12
|
<meta property="og:see_also" content="https://github.com/ko1/lumitrace/tree/master/runv">
|
|
@@ -290,7 +290,7 @@
|
|
|
290
290
|
<header>
|
|
291
291
|
<div>
|
|
292
292
|
<h1>LumiTrace Playground</h1>
|
|
293
|
-
<p class="sub">Run Ruby in the browser and see traced values inline with lumitrace 0.
|
|
293
|
+
<p class="sub">Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.</p>
|
|
294
294
|
</div>
|
|
295
295
|
</header>
|
|
296
296
|
|
|
@@ -448,6 +448,8 @@ module Lumitrace
|
|
|
448
448
|
end
|
|
449
449
|
|
|
450
450
|
module RecordInstrument
|
|
451
|
+
IDENTIFIER_METHOD_NAME_RE = /\A[a-z_]\w*[!?=]?\z/.freeze
|
|
452
|
+
|
|
451
453
|
SKIP_NODE_CLASSES = [
|
|
452
454
|
Prism::DefNode,
|
|
453
455
|
Prism::ClassNode,
|
|
@@ -488,7 +490,7 @@ module RecordInstrument
|
|
|
488
490
|
Prism::GlobalVariableReadNode
|
|
489
491
|
].freeze
|
|
490
492
|
|
|
491
|
-
def self.instrument_source(src, ranges, file_label: nil, record_method: "Lumitrace::R")
|
|
493
|
+
def self.instrument_source(src, ranges, file_label: nil, record_method: "::Lumitrace::R")
|
|
492
494
|
file_label ||= "(unknown)"
|
|
493
495
|
ranges = normalize_ranges(ranges)
|
|
494
496
|
|
|
@@ -540,7 +542,7 @@ module RecordInstrument
|
|
|
540
542
|
end
|
|
541
543
|
end
|
|
542
544
|
|
|
543
|
-
node.
|
|
545
|
+
instrumentable_child_nodes(node).each { |child| stack << [child, node] }
|
|
544
546
|
end
|
|
545
547
|
locs
|
|
546
548
|
end
|
|
@@ -577,7 +579,7 @@ module RecordInstrument
|
|
|
577
579
|
end
|
|
578
580
|
end
|
|
579
581
|
|
|
580
|
-
node.
|
|
582
|
+
instrumentable_child_nodes(node).each { |child| stack << [child, node] }
|
|
581
583
|
end
|
|
582
584
|
|
|
583
585
|
inserts
|
|
@@ -618,6 +620,8 @@ module RecordInstrument
|
|
|
618
620
|
def self.wrap_expr?(node, parent = nil)
|
|
619
621
|
return false unless node.respond_to?(:location)
|
|
620
622
|
return false if literal_value_node?(node)
|
|
623
|
+
return false if command_style_call_node?(node)
|
|
624
|
+
return false if parent.is_a?(Prism::DefinedNode)
|
|
621
625
|
if parent.is_a?(Prism::AliasGlobalVariableNode) || parent.is_a?(Prism::AliasMethodNode)
|
|
622
626
|
return false
|
|
623
627
|
end
|
|
@@ -637,6 +641,27 @@ module RecordInstrument
|
|
|
637
641
|
WRAP_NODE_CLASSES.include?(node.class)
|
|
638
642
|
end
|
|
639
643
|
|
|
644
|
+
def self.command_style_call_node?(node)
|
|
645
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
646
|
+
return false unless node.respond_to?(:arguments) && node.arguments
|
|
647
|
+
return false if node.respond_to?(:opening_loc) && node.opening_loc
|
|
648
|
+
|
|
649
|
+
name = node.respond_to?(:name) ? node.name : nil
|
|
650
|
+
return false unless name
|
|
651
|
+
|
|
652
|
+
IDENTIFIER_METHOD_NAME_RE.match?(name.to_s)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def self.instrumentable_child_nodes(node)
|
|
656
|
+
return [] unless node
|
|
657
|
+
return [] if node.is_a?(Prism::DefinedNode)
|
|
658
|
+
if command_style_call_node?(node) && node.respond_to?(:block) && node.block
|
|
659
|
+
[node.block]
|
|
660
|
+
else
|
|
661
|
+
node.child_nodes
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
640
665
|
def self.expr_location(node)
|
|
641
666
|
loc = node.location
|
|
642
667
|
return {
|
|
@@ -782,22 +807,31 @@ module RecordInstrument
|
|
|
782
807
|
|
|
783
808
|
def self.events_from_ids
|
|
784
809
|
out = []
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
loc = @loc_by_id[id]
|
|
810
|
+
summary_cache = {}
|
|
811
|
+
@loc_by_id.each_with_index do |loc, id|
|
|
788
812
|
next unless loc
|
|
813
|
+
|
|
814
|
+
e = @events_by_id[id]
|
|
789
815
|
case collect_mode
|
|
790
816
|
when :history
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
all_types
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
817
|
+
if e
|
|
818
|
+
raw_values = values_from_ring(e)
|
|
819
|
+
all_types = history_type_set(e)
|
|
820
|
+
if all_types.nil? || all_types.empty?
|
|
821
|
+
all_types = {}
|
|
822
|
+
raw_values.each do |v|
|
|
823
|
+
t = value_type_name(v)
|
|
824
|
+
all_types[t] = (all_types[t] || 0) + 1
|
|
825
|
+
end
|
|
798
826
|
end
|
|
827
|
+
max = history_ring_size(e)
|
|
828
|
+
total = e[max + 1]
|
|
829
|
+
else
|
|
830
|
+
raw_values = []
|
|
831
|
+
all_types = {}
|
|
832
|
+
total = 0
|
|
799
833
|
end
|
|
800
|
-
|
|
834
|
+
|
|
801
835
|
out << {
|
|
802
836
|
file: loc[:file],
|
|
803
837
|
start_line: loc[:start_line],
|
|
@@ -806,9 +840,20 @@ module RecordInstrument
|
|
|
806
840
|
end_col: loc[:end_col],
|
|
807
841
|
kind: loc[:kind].to_s,
|
|
808
842
|
name: loc[:name],
|
|
809
|
-
sampled_values: raw_values.map
|
|
843
|
+
sampled_values: raw_values.map do |v|
|
|
844
|
+
key = v.__id__
|
|
845
|
+
cached = summary_cache[key]
|
|
846
|
+
if cached
|
|
847
|
+
cached.dup
|
|
848
|
+
else
|
|
849
|
+
type = value_type_name(v)
|
|
850
|
+
summary = summarize_value(v, type: type)
|
|
851
|
+
summary_cache[key] = summary
|
|
852
|
+
summary.dup
|
|
853
|
+
end
|
|
854
|
+
end,
|
|
810
855
|
types: sorted_type_counts(all_types),
|
|
811
|
-
total:
|
|
856
|
+
total: total
|
|
812
857
|
}
|
|
813
858
|
when :types
|
|
814
859
|
out << {
|
|
@@ -819,12 +864,12 @@ module RecordInstrument
|
|
|
819
864
|
end_col: loc[:end_col],
|
|
820
865
|
kind: loc[:kind].to_s,
|
|
821
866
|
name: loc[:name],
|
|
822
|
-
types: sorted_type_counts(e[:types]),
|
|
823
|
-
total: e[:total]
|
|
867
|
+
types: sorted_type_counts(e ? e[:types] : nil),
|
|
868
|
+
total: e ? e[:total] : 0
|
|
824
869
|
}
|
|
825
870
|
else # :last
|
|
826
|
-
last_raw = e[:last_value]
|
|
827
|
-
last_type = value_type_name(last_raw)
|
|
871
|
+
last_raw = e && e[:last_value]
|
|
872
|
+
last_type = value_type_name(last_raw) if e
|
|
828
873
|
out << {
|
|
829
874
|
file: loc[:file],
|
|
830
875
|
start_line: loc[:start_line],
|
|
@@ -833,9 +878,21 @@ module RecordInstrument
|
|
|
833
878
|
end_col: loc[:end_col],
|
|
834
879
|
kind: loc[:kind].to_s,
|
|
835
880
|
name: loc[:name],
|
|
836
|
-
last_value:
|
|
837
|
-
|
|
838
|
-
|
|
881
|
+
last_value: if e
|
|
882
|
+
key = last_raw.__id__
|
|
883
|
+
cached = summary_cache[key]
|
|
884
|
+
if cached
|
|
885
|
+
cached.dup
|
|
886
|
+
else
|
|
887
|
+
summary = summarize_value(last_raw, type: last_type)
|
|
888
|
+
summary_cache[key] = summary
|
|
889
|
+
summary.dup
|
|
890
|
+
end
|
|
891
|
+
else
|
|
892
|
+
nil
|
|
893
|
+
end,
|
|
894
|
+
types: sorted_type_counts(e ? e[:types] : nil),
|
|
895
|
+
total: e ? e[:total] : 0
|
|
839
896
|
}
|
|
840
897
|
end
|
|
841
898
|
end
|
|
@@ -879,10 +936,42 @@ module RecordInstrument
|
|
|
879
936
|
|
|
880
937
|
def self.dump_events_json(events, path = nil)
|
|
881
938
|
path ||= File.expand_path("lumitrace_recorded.json", Dir.pwd)
|
|
882
|
-
|
|
939
|
+
payload = {
|
|
940
|
+
version: 1,
|
|
941
|
+
events: events,
|
|
942
|
+
coverage: compute_coverage(events)
|
|
943
|
+
}
|
|
944
|
+
File.write(path, JSON.dump(payload), perm: 0o600)
|
|
883
945
|
path
|
|
884
946
|
end
|
|
885
947
|
|
|
948
|
+
def self.compute_coverage(events)
|
|
949
|
+
by_file = {}
|
|
950
|
+
events.each do |e|
|
|
951
|
+
file = e[:file] || e["file"]
|
|
952
|
+
next unless file
|
|
953
|
+
total = e[:total] || e["total"] || 0
|
|
954
|
+
start_line = e[:start_line] || e["start_line"]
|
|
955
|
+
next unless start_line
|
|
956
|
+
|
|
957
|
+
entry = (by_file[file] ||= { lines: {}, covered_lines: {} })
|
|
958
|
+
entry[:lines][start_line] = true
|
|
959
|
+
entry[:covered_lines][start_line] = true if total > 0
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
by_file.map do |file, entry|
|
|
963
|
+
total_lines = entry[:lines].size
|
|
964
|
+
covered_lines = entry[:covered_lines].size
|
|
965
|
+
pct = total_lines > 0 ? (covered_lines * 100.0 / total_lines).round(1) : 0.0
|
|
966
|
+
{
|
|
967
|
+
file: file,
|
|
968
|
+
total_lines: total_lines,
|
|
969
|
+
covered_lines: covered_lines,
|
|
970
|
+
coverage_percent: pct
|
|
971
|
+
}
|
|
972
|
+
end.sort_by { |e| e[:file] }
|
|
973
|
+
end
|
|
974
|
+
|
|
886
975
|
def self.load_events_json(path)
|
|
887
976
|
JSON.parse(File.read(path))
|
|
888
977
|
end
|
|
@@ -1070,25 +1159,36 @@ module RecordInstrument
|
|
|
1070
1159
|
events_from_ids
|
|
1071
1160
|
end
|
|
1072
1161
|
|
|
1162
|
+
KERNEL_INSPECT = ::Kernel.instance_method(:inspect)
|
|
1163
|
+
|
|
1164
|
+
def self.safe_inspect(v)
|
|
1165
|
+
v.inspect
|
|
1166
|
+
rescue ::NoMethodError
|
|
1167
|
+
KERNEL_INSPECT.bind_call(v)
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1073
1170
|
def self.safe_value(v)
|
|
1074
1171
|
case v
|
|
1075
1172
|
when Numeric, TrueClass, FalseClass, NilClass
|
|
1076
1173
|
v
|
|
1077
1174
|
else
|
|
1078
|
-
s = v
|
|
1175
|
+
s = safe_inspect(v)
|
|
1079
1176
|
s.bytesize > 1000 ? s[0, 1000] + "..." : s
|
|
1080
1177
|
end
|
|
1081
1178
|
end
|
|
1082
1179
|
|
|
1180
|
+
KERNEL_CLASS = ::Kernel.instance_method(:class)
|
|
1181
|
+
|
|
1083
1182
|
def self.value_type_name(v)
|
|
1084
|
-
|
|
1085
|
-
name
|
|
1183
|
+
klass = KERNEL_CLASS.bind_call(v)
|
|
1184
|
+
name = klass.name
|
|
1185
|
+
name && !name.empty? ? name : klass.to_s
|
|
1086
1186
|
end
|
|
1087
1187
|
|
|
1088
1188
|
def self.summarize_value(v, type: nil)
|
|
1089
1189
|
type ||= value_type_name(v)
|
|
1090
1190
|
preview_limit = 120
|
|
1091
|
-
inspected = v
|
|
1191
|
+
inspected = safe_inspect(v)
|
|
1092
1192
|
if inspected.length > preview_limit
|
|
1093
1193
|
{
|
|
1094
1194
|
type: type,
|
|
@@ -1292,6 +1392,20 @@ require "json"
|
|
|
1292
1392
|
|
|
1293
1393
|
module Lumitrace
|
|
1294
1394
|
module GenerateResultedHtml
|
|
1395
|
+
RENDERER_JS_PATH = File.expand_path("generate_resulted_html_renderer.js", __dir__)
|
|
1396
|
+
|
|
1397
|
+
def self.monotonic_now
|
|
1398
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
def self.time_step(label, logger = nil)
|
|
1402
|
+
start = monotonic_now
|
|
1403
|
+
result = yield
|
|
1404
|
+
elapsed_ms = (monotonic_now - start) * 1000.0
|
|
1405
|
+
logger&.call(format("html render: %s %.1fms", label, elapsed_ms))
|
|
1406
|
+
result
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1295
1409
|
def self.render(source_path, events_path, ranges: nil, collect_mode: nil, max_samples: nil)
|
|
1296
1410
|
unless File.exist?(events_path)
|
|
1297
1411
|
abort "missing #{events_path}"
|
|
@@ -1300,51 +1414,972 @@ module GenerateResultedHtml
|
|
|
1300
1414
|
abort "missing #{source_path}"
|
|
1301
1415
|
end
|
|
1302
1416
|
|
|
1303
|
-
|
|
1417
|
+
raw = JSON.parse(File.read(events_path))
|
|
1418
|
+
raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
|
|
1419
|
+
src = File.read(source_path)
|
|
1304
1420
|
mode_info = resolve_mode_info(raw_events, collect_mode: collect_mode, max_samples: max_samples)
|
|
1305
|
-
|
|
1306
|
-
events =
|
|
1421
|
+
normalized_ranges = normalize_ranges(ranges)
|
|
1422
|
+
events = normalize_events(raw_events).select { |e| e[:file] == source_path }
|
|
1423
|
+
|
|
1424
|
+
payload = build_html_payload(
|
|
1425
|
+
mode_info: mode_info,
|
|
1426
|
+
files: [
|
|
1427
|
+
build_html_payload_file(
|
|
1428
|
+
path: source_path,
|
|
1429
|
+
display_path: File.basename(source_path),
|
|
1430
|
+
source: src,
|
|
1431
|
+
ranges: normalized_ranges,
|
|
1432
|
+
trace_events: events
|
|
1433
|
+
)
|
|
1434
|
+
]
|
|
1435
|
+
)
|
|
1307
1436
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
ranges = normalize_ranges(ranges)
|
|
1311
|
-
expected_by_line, executed_by_line = line_stats(src, ranges, events, source_path)
|
|
1437
|
+
render_payload_html(payload)
|
|
1438
|
+
end
|
|
1312
1439
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
last_lineno = lineno
|
|
1440
|
+
def self.esc(s)
|
|
1441
|
+
s.to_s
|
|
1442
|
+
.gsub("&", "&")
|
|
1443
|
+
.gsub("<", "<")
|
|
1444
|
+
.gsub(">", ">")
|
|
1445
|
+
.gsub('"', """)
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
def self.build_html_payload(mode_info:, files:, command_text: nil)
|
|
1449
|
+
meta = {
|
|
1450
|
+
mode: mode_info[:mode],
|
|
1451
|
+
mode_text: mode_info[:text],
|
|
1452
|
+
max_samples: mode_info[:max_samples]
|
|
1453
|
+
}
|
|
1454
|
+
meta[:command] = command_text if command_text && !command_text.to_s.empty?
|
|
1455
|
+
{
|
|
1456
|
+
version: 1,
|
|
1457
|
+
meta: meta,
|
|
1458
|
+
files: files
|
|
1459
|
+
}
|
|
1460
|
+
end
|
|
1461
|
+
|
|
1462
|
+
def self.build_html_payload_file(path:, display_path:, source:, ranges:, trace_events:, logger: nil)
|
|
1463
|
+
sort_start = logger ? monotonic_now : nil
|
|
1464
|
+
sorted_events = Array(trace_events).sort_by do |e|
|
|
1465
|
+
[e[:start_line].to_i, e[:start_col].to_i, e[:end_line].to_i, e[:end_col].to_i]
|
|
1340
1466
|
end
|
|
1341
|
-
|
|
1342
|
-
|
|
1467
|
+
sort_ms = sort_start ? (monotonic_now - sort_start) * 1000.0 : nil
|
|
1468
|
+
|
|
1469
|
+
map_start = logger ? monotonic_now : nil
|
|
1470
|
+
trace_payload = sorted_events.map { |e| event_to_html_trace_payload(e) }
|
|
1471
|
+
map_ms = map_start ? (monotonic_now - map_start) * 1000.0 : nil
|
|
1472
|
+
if logger
|
|
1473
|
+
logger.call(format("html render: payload_file %s sort=%.1fms map=%.1fms events=%d", display_path, sort_ms, map_ms, sorted_events.length))
|
|
1343
1474
|
end
|
|
1344
|
-
|
|
1345
|
-
|
|
1475
|
+
|
|
1476
|
+
{
|
|
1477
|
+
path: path,
|
|
1478
|
+
display_path: display_path,
|
|
1479
|
+
source: source,
|
|
1480
|
+
ranges: ranges,
|
|
1481
|
+
trace: trace_payload
|
|
1482
|
+
}
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
def self.event_to_html_trace_payload(e)
|
|
1486
|
+
sampled_values = e[:sampled_values]
|
|
1487
|
+
if (sampled_values.nil? || sampled_values.empty?) && e[:last_value]
|
|
1488
|
+
sampled_values = [e[:last_value]]
|
|
1346
1489
|
end
|
|
1490
|
+
{
|
|
1491
|
+
location: [
|
|
1492
|
+
e[:start_line].to_i,
|
|
1493
|
+
e[:start_col].to_i,
|
|
1494
|
+
e[:end_line].to_i,
|
|
1495
|
+
e[:end_col].to_i
|
|
1496
|
+
],
|
|
1497
|
+
kind: (e[:kind] || "expr").to_s,
|
|
1498
|
+
name: e[:name],
|
|
1499
|
+
sampled_values: sampled_values || [],
|
|
1500
|
+
types: sorted_type_counts(e[:types]),
|
|
1501
|
+
total: e[:total].to_i
|
|
1502
|
+
}
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
def self.payload_json_for_script(payload)
|
|
1506
|
+
JSON.generate(payload)
|
|
1507
|
+
.gsub("</", "<\\/")
|
|
1508
|
+
.gsub("\u2028", "\\u2028")
|
|
1509
|
+
.gsub("\u2029", "\\u2029")
|
|
1510
|
+
end
|
|
1511
|
+
|
|
1512
|
+
def self.html_renderer_js
|
|
1513
|
+
@html_renderer_js ||= '(function() {
|
|
1514
|
+
const payloadEl = document.getElementById("lumitrace-payload");
|
|
1515
|
+
const app = document.getElementById("lumitrace-app");
|
|
1516
|
+
if (!payloadEl || !app) return;
|
|
1517
|
+
|
|
1518
|
+
const SL = 0;
|
|
1519
|
+
const SC = 1;
|
|
1520
|
+
const EL = 2;
|
|
1521
|
+
const EC = 3;
|
|
1522
|
+
|
|
1523
|
+
function escHtml(s) {
|
|
1524
|
+
return String(s)
|
|
1525
|
+
.replace(/&/g, "&")
|
|
1526
|
+
.replace(/</g, "<")
|
|
1527
|
+
.replace(/>/g, ">")
|
|
1528
|
+
.replace(/\\"/g, """);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function normalizeTypeCounts(types) {
|
|
1532
|
+
if (!types) return {};
|
|
1533
|
+
if (Array.isArray(types)) {
|
|
1534
|
+
const out = {};
|
|
1535
|
+
for (const t of types) {
|
|
1536
|
+
const key = String(t || "");
|
|
1537
|
+
if (!key) continue;
|
|
1538
|
+
out[key] = (out[key] || 0) + 1;
|
|
1539
|
+
}
|
|
1540
|
+
return out;
|
|
1541
|
+
}
|
|
1542
|
+
if (typeof types === "object") {
|
|
1543
|
+
const out = {};
|
|
1544
|
+
for (const [k, v] of Object.entries(types)) {
|
|
1545
|
+
const key = String(k || "");
|
|
1546
|
+
if (!key) continue;
|
|
1547
|
+
let count = Number(v) || 0;
|
|
1548
|
+
if (count <= 0) count = 1;
|
|
1549
|
+
out[key] = (out[key] || 0) + count;
|
|
1550
|
+
}
|
|
1551
|
+
return out;
|
|
1552
|
+
}
|
|
1553
|
+
const key = String(types || "");
|
|
1554
|
+
return key ? { [key]: 1 } : {};
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function typeListText(types, onlyIfMultiple) {
|
|
1558
|
+
const counts = normalizeTypeCounts(types);
|
|
1559
|
+
const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
|
1560
|
+
if (onlyIfMultiple && entries.length <= 1) return null;
|
|
1561
|
+
if (entries.length === 0) return "(no types)";
|
|
1562
|
+
return "types: " + entries.map(([k, v]) => `${k}(${v})`).join(", ");
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function valueTypeName(v) {
|
|
1566
|
+
if (v === null) return "NilClass";
|
|
1567
|
+
if (Array.isArray(v)) return "Array";
|
|
1568
|
+
switch (typeof v) {
|
|
1569
|
+
case "number": return Number.isInteger(v) ? "Integer" : "Float";
|
|
1570
|
+
case "string": return "String";
|
|
1571
|
+
case "boolean": return "Boolean";
|
|
1572
|
+
case "undefined": return "NilClass";
|
|
1573
|
+
case "object": return "Object";
|
|
1574
|
+
default: return typeof v;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function formatValue(v, type) {
|
|
1579
|
+
const value = v == null ? "nil" : String(v);
|
|
1580
|
+
return `${value} (${type || valueTypeName(v)})`;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function lastValueToPair(lastValue) {
|
|
1584
|
+
if (lastValue == null) return [null, null];
|
|
1585
|
+
if (typeof lastValue !== "object" || Array.isArray(lastValue)) return [lastValue, null];
|
|
1586
|
+
const type = lastValue.type || null;
|
|
1587
|
+
if (Object.prototype.hasOwnProperty.call(lastValue, "value")) return [lastValue.value, type];
|
|
1588
|
+
if (Object.prototype.hasOwnProperty.call(lastValue, "preview")) return [lastValue.preview, type];
|
|
1589
|
+
if (Object.prototype.hasOwnProperty.call(lastValue, "inspect")) return [lastValue.inspect, type];
|
|
1590
|
+
return [JSON.stringify(lastValue), type];
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function summarizeValues(values, total, allTypes) {
|
|
1594
|
+
if (!values || values.length === 0) {
|
|
1595
|
+
const multi = typeListText(allTypes, false);
|
|
1596
|
+
return multi || "";
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const n = total == null ? values.length : Number(total);
|
|
1600
|
+
const lastVals = values.slice(-3);
|
|
1601
|
+
const firstIndex = n - lastVals.length + 1;
|
|
1602
|
+
const lines = [];
|
|
1603
|
+
const extra = n - lastVals.length;
|
|
1604
|
+
if (extra > 0) lines.push(`... (+${extra} more)`);
|
|
1605
|
+
|
|
1606
|
+
lastVals.forEach((v, i) => {
|
|
1607
|
+
const idx = firstIndex + i;
|
|
1608
|
+
const [valueText, typeText] = lastValueToPair(v);
|
|
1609
|
+
lines.push(`#${idx}: ${formatValue(valueText, typeText)}`);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
const multi = typeListText(allTypes, true);
|
|
1613
|
+
if (multi) lines.push(multi);
|
|
1614
|
+
return lines.join("\\n");
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function lineClassFor(expected, executed) {
|
|
1618
|
+
if (executed > 0) return " hit";
|
|
1619
|
+
if (expected > 0) return " miss";
|
|
1620
|
+
return "";
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function lineInRanges(line, ranges) {
|
|
1624
|
+
if (!ranges || ranges.length === 0) return true;
|
|
1625
|
+
return ranges.some((range) => {
|
|
1626
|
+
if (!Array.isArray(range) || range.length < 2) return false;
|
|
1627
|
+
const start = Number(range[0]);
|
|
1628
|
+
const end = Number(range[1]);
|
|
1629
|
+
return line >= start && line <= end;
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function sourceLines(source) {
|
|
1634
|
+
const matches = String(source || "").match(/[^\\n]*\\n|[^\\n]+/g);
|
|
1635
|
+
if (!matches) return [];
|
|
1636
|
+
return matches.map((line) => line.endsWith("\\n") ? line.slice(0, -1) : line);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function lineStatsForFile(trace) {
|
|
1640
|
+
const expectedByLine = Object.create(null);
|
|
1641
|
+
const executedByLine = Object.create(null);
|
|
1642
|
+
const seen = Object.create(null);
|
|
1643
|
+
|
|
1644
|
+
for (const event of trace || []) {
|
|
1645
|
+
const loc = event && event.location;
|
|
1646
|
+
if (!Array.isArray(loc) || loc.length < 4) continue;
|
|
1647
|
+
const sl = Number(loc[SL]);
|
|
1648
|
+
const el = Number(loc[EL]);
|
|
1649
|
+
if (!(sl > 0) || !(el > 0)) continue;
|
|
1650
|
+
const key = `${sl}:${loc[SC]}:${el}:${loc[EC]}`;
|
|
1651
|
+
if (seen[key]) continue;
|
|
1652
|
+
seen[key] = true;
|
|
1653
|
+
for (let line = sl; line <= el; line += 1) {
|
|
1654
|
+
expectedByLine[line] = (expectedByLine[line] || 0) + 1;
|
|
1655
|
+
if (Number(event.total) > 0) {
|
|
1656
|
+
executedByLine[line] = (executedByLine[line] || 0) + 1;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
return { expectedByLine, executedByLine };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function expressionLineCoverageForTrace(trace) {
|
|
1665
|
+
const expectedLines = new Set();
|
|
1666
|
+
const executedLines = new Set();
|
|
1667
|
+
const seen = new Set();
|
|
1668
|
+
|
|
1669
|
+
for (const event of trace || []) {
|
|
1670
|
+
if (!event || event.kind === "arg") continue;
|
|
1671
|
+
const loc = event.location;
|
|
1672
|
+
if (!Array.isArray(loc) || loc.length < 4) continue;
|
|
1673
|
+
|
|
1674
|
+
const sl = Number(loc[SL]);
|
|
1675
|
+
const sc = Number(loc[SC]);
|
|
1676
|
+
const el = Number(loc[EL]);
|
|
1677
|
+
const ec = Number(loc[EC]);
|
|
1678
|
+
if (!(sl > 0) || !(el > 0)) continue;
|
|
1679
|
+
|
|
1680
|
+
const key = `${sl}:${sc}:${el}:${ec}`;
|
|
1681
|
+
if (seen.has(key)) continue;
|
|
1682
|
+
seen.add(key);
|
|
1683
|
+
|
|
1684
|
+
for (let line = sl; line <= el; line += 1) {
|
|
1685
|
+
expectedLines.add(line);
|
|
1686
|
+
if (Number(event.total) > 0) executedLines.add(line);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
return {
|
|
1691
|
+
executed: executedLines.size,
|
|
1692
|
+
expected: expectedLines.size
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function aggregateEventsForLine(trace, lineno, lineLen, fileIndex) {
|
|
1697
|
+
const buckets = new Map();
|
|
1698
|
+
const spans = [];
|
|
1699
|
+
|
|
1700
|
+
for (const event of trace || []) {
|
|
1701
|
+
const loc = event && event.location;
|
|
1702
|
+
if (!Array.isArray(loc) || loc.length < 4) continue;
|
|
1703
|
+
const sline = Number(loc[SL]);
|
|
1704
|
+
const scol = Number(loc[SC]);
|
|
1705
|
+
const eline = Number(loc[EL]);
|
|
1706
|
+
const ecol = Number(loc[EC]);
|
|
1707
|
+
if (lineno < sline || lineno > eline) continue;
|
|
1708
|
+
|
|
1709
|
+
let s;
|
|
1710
|
+
let t;
|
|
1711
|
+
let marker;
|
|
1712
|
+
if (sline === eline) {
|
|
1713
|
+
s = scol;
|
|
1714
|
+
t = ecol;
|
|
1715
|
+
marker = true;
|
|
1716
|
+
} else if (lineno === sline) {
|
|
1717
|
+
s = scol;
|
|
1718
|
+
t = lineLen;
|
|
1719
|
+
marker = false;
|
|
1720
|
+
} else if (lineno === eline) {
|
|
1721
|
+
s = 0;
|
|
1722
|
+
t = ecol;
|
|
1723
|
+
marker = true;
|
|
1724
|
+
} else {
|
|
1725
|
+
s = 0;
|
|
1726
|
+
t = lineLen;
|
|
1727
|
+
marker = false;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (!(t > s)) continue;
|
|
1731
|
+
spans.push({ start_col: s, end_col: t });
|
|
1732
|
+
|
|
1733
|
+
const keyId = `${fileIndex}:${sline}:${scol}:${eline}:${ecol}`;
|
|
1734
|
+
buckets.set(keyId, {
|
|
1735
|
+
key_id: keyId,
|
|
1736
|
+
start_col: s,
|
|
1737
|
+
end_col: t,
|
|
1738
|
+
marker,
|
|
1739
|
+
kind: event.kind || "expr",
|
|
1740
|
+
name: event.name || null,
|
|
1741
|
+
sampled_values: event.sampled_values || [],
|
|
1742
|
+
types: event.types || {},
|
|
1743
|
+
total: Number(event.total) || 0,
|
|
1744
|
+
suppress_miss: false
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
const out = Array.from(buckets.values());
|
|
1749
|
+
for (const b of out) {
|
|
1750
|
+
const depth = spans.filter((sp) => b.start_col >= sp.start_col && b.end_col <= sp.end_col).length;
|
|
1751
|
+
b.depth = Math.min(5, Math.max(1, depth));
|
|
1752
|
+
}
|
|
1753
|
+
out.sort((a, b) => a.start_col - b.start_col);
|
|
1754
|
+
return out;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function renderLineHtml(lineText, events) {
|
|
1758
|
+
const opens = Object.create(null);
|
|
1759
|
+
const closes = Object.create(null);
|
|
1760
|
+
|
|
1761
|
+
for (const e of events) {
|
|
1762
|
+
const s = Number(e.start_col);
|
|
1763
|
+
const t = Number(e.end_col);
|
|
1764
|
+
if (!(t > s)) continue;
|
|
1765
|
+
|
|
1766
|
+
const values = e.sampled_values || [];
|
|
1767
|
+
const allTypes = e.types || {};
|
|
1768
|
+
const total = Number(e.total) || 0;
|
|
1769
|
+
const label = e.kind === "arg" && e.name ? `arg ${e.name}` : null;
|
|
1770
|
+
|
|
1771
|
+
let valueText;
|
|
1772
|
+
if (total === 0) {
|
|
1773
|
+
valueText = label ? `${label}: (not hit)` : "(not hit)";
|
|
1774
|
+
} else {
|
|
1775
|
+
const summary = summarizeValues(values, total, allTypes);
|
|
1776
|
+
if (label) {
|
|
1777
|
+
valueText = summary ? `${label}: ${summary}` : label;
|
|
1778
|
+
} else {
|
|
1779
|
+
valueText = summary;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const tooltipHtml = escHtml(valueText);
|
|
1784
|
+
const depthClass = `depth-${e.depth || 1}`;
|
|
1785
|
+
const missClass = total === 0 && !e.suppress_miss ? " miss" : "";
|
|
1786
|
+
const keyAttr = escHtml(e.key_id || "");
|
|
1787
|
+
const openTag = `<span class=\\"expr hit ${depthClass}${missClass}\\" data-key=\\"${keyAttr}\\">`;
|
|
1788
|
+
|
|
1789
|
+
let closeTag = "</span>";
|
|
1790
|
+
if (e.marker !== false) {
|
|
1791
|
+
let marker = "🔎";
|
|
1792
|
+
if (total === 0) marker = "∅";
|
|
1793
|
+
else if (e.kind === "arg") marker = "🧷";
|
|
1794
|
+
|
|
1795
|
+
let markerClass = "marker";
|
|
1796
|
+
if (total === 0 && !e.suppress_miss) markerClass = "marker miss";
|
|
1797
|
+
if (e.kind === "arg") markerClass += " arg";
|
|
1798
|
+
closeTag = `<span class=\\"${markerClass}\\" data-key=\\"${keyAttr}\\" aria-hidden=\\"true\\">${marker}<span class=\\"tooltip\\">${tooltipHtml}</span></span></span>`;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const len = t - s;
|
|
1802
|
+
(opens[s] ||= []).push({ len, start: s, end: t, tag: openTag });
|
|
1803
|
+
(closes[t] ||= []).push({ len, start: s, end: t, tag: closeTag });
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const positions = Array.from(new Set([
|
|
1807
|
+
...Object.keys(opens).map(Number),
|
|
1808
|
+
...Object.keys(closes).map(Number)
|
|
1809
|
+
])).sort((a, b) => a - b);
|
|
1810
|
+
|
|
1811
|
+
let out = "";
|
|
1812
|
+
let cursor = 0;
|
|
1813
|
+
|
|
1814
|
+
for (const pos of positions) {
|
|
1815
|
+
if (pos > cursor) out += escHtml(lineText.slice(cursor, pos));
|
|
1816
|
+
if (closes[pos]) {
|
|
1817
|
+
closes[pos]
|
|
1818
|
+
.slice()
|
|
1819
|
+
.sort((a, b) => (b.start - a.start) || (a.len - b.len))
|
|
1820
|
+
.forEach((c) => { out += c.tag; });
|
|
1821
|
+
}
|
|
1822
|
+
if (opens[pos]) {
|
|
1823
|
+
opens[pos]
|
|
1824
|
+
.slice()
|
|
1825
|
+
.sort((a, b) => b.end - a.end)
|
|
1826
|
+
.forEach((o) => { out += o.tag; });
|
|
1827
|
+
}
|
|
1828
|
+
cursor = pos;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (cursor < lineText.length) out += escHtml(lineText.slice(cursor));
|
|
1832
|
+
return out;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function buildLineNode(lineno, lineText, lineClass, innerHtml, options) {
|
|
1836
|
+
const opts = options || {};
|
|
1837
|
+
const line = document.createElement("span");
|
|
1838
|
+
line.className = `line${lineClass}`;
|
|
1839
|
+
line.dataset.line = String(lineno);
|
|
1840
|
+
line.id = `line-${lineno}`;
|
|
1841
|
+
|
|
1842
|
+
const ln = opts.lineHref ? document.createElement("a") : document.createElement("span");
|
|
1843
|
+
ln.className = `ln${opts.lineHref ? " ln-link" : ""}`;
|
|
1844
|
+
ln.textContent = String(lineno);
|
|
1845
|
+
if (opts.lineHref) {
|
|
1846
|
+
ln.href = opts.lineHref(lineno);
|
|
1847
|
+
ln.title = `Link to line ${lineno}`;
|
|
1848
|
+
ln.addEventListener("click", (event) => {
|
|
1849
|
+
if (!opts.onLineClick) return;
|
|
1850
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
1851
|
+
event.preventDefault();
|
|
1852
|
+
opts.onLineClick(lineno);
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
line.appendChild(ln);
|
|
1856
|
+
line.appendChild(document.createTextNode(" "));
|
|
1857
|
+
|
|
1858
|
+
if (innerHtml == null) {
|
|
1859
|
+
line.appendChild(document.createTextNode(lineText));
|
|
1860
|
+
} else {
|
|
1861
|
+
const wrapper = document.createElement("span");
|
|
1862
|
+
wrapper.innerHTML = innerHtml;
|
|
1863
|
+
while (wrapper.firstChild) line.appendChild(wrapper.firstChild);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
return line;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function buildEllipsisNode() {
|
|
1870
|
+
const line = document.createElement("span");
|
|
1871
|
+
line.className = "line ellipsis";
|
|
1872
|
+
line.dataset.line = "...";
|
|
1873
|
+
|
|
1874
|
+
const ln = document.createElement("span");
|
|
1875
|
+
ln.className = "ln";
|
|
1876
|
+
ln.textContent = "...";
|
|
1877
|
+
line.appendChild(ln);
|
|
1878
|
+
return line;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function renderFileSection(file, fileIndex, options) {
|
|
1882
|
+
const opts = options || {};
|
|
1883
|
+
const section = document.createElement("section");
|
|
1884
|
+
section.className = "file-section";
|
|
1885
|
+
|
|
1886
|
+
if (opts.includeTitle !== false) {
|
|
1887
|
+
const h2 = document.createElement("h2");
|
|
1888
|
+
h2.className = "file";
|
|
1889
|
+
h2.textContent = file.display_path || file.path || `file-${fileIndex + 1}`;
|
|
1890
|
+
section.appendChild(h2);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const pre = document.createElement("pre");
|
|
1894
|
+
pre.className = "code";
|
|
1895
|
+
const code = document.createElement("code");
|
|
1896
|
+
pre.appendChild(code);
|
|
1897
|
+
|
|
1898
|
+
const lines = sourceLines(file.source || "");
|
|
1899
|
+
const ranges = Array.isArray(file.ranges) ? file.ranges : null;
|
|
1900
|
+
const trace = Array.isArray(file.trace) ? file.trace : [];
|
|
1901
|
+
const { expectedByLine, executedByLine } = lineStatsForFile(trace);
|
|
1902
|
+
|
|
1903
|
+
let prevLineno = null;
|
|
1904
|
+
let firstLineno = null;
|
|
1905
|
+
let lastLineno = null;
|
|
1906
|
+
|
|
1907
|
+
lines.forEach((lineText, idx) => {
|
|
1908
|
+
const lineno = idx + 1;
|
|
1909
|
+
if (!lineInRanges(lineno, ranges)) return;
|
|
1910
|
+
if (firstLineno == null) firstLineno = lineno;
|
|
1911
|
+
if (prevLineno != null && lineno > prevLineno + 1) {
|
|
1912
|
+
code.appendChild(buildEllipsisNode());
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
const evs = aggregateEventsForLine(trace, lineno, lineText.length, fileIndex);
|
|
1916
|
+
const expected = expectedByLine[lineno] || 0;
|
|
1917
|
+
const executed = executedByLine[lineno] || 0;
|
|
1918
|
+
const lineClass = lineClassFor(expected, executed);
|
|
1919
|
+
if (expected > 0 && executed === 0) {
|
|
1920
|
+
evs.forEach((e) => { e.suppress_miss = true; });
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
if (evs.length === 0) {
|
|
1924
|
+
code.appendChild(buildLineNode(lineno, lineText, lineClass, null, opts));
|
|
1925
|
+
} else {
|
|
1926
|
+
code.appendChild(buildLineNode(lineno, lineText, lineClass, renderLineHtml(lineText, evs), opts));
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
prevLineno = lineno;
|
|
1930
|
+
lastLineno = lineno;
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
if (firstLineno != null && firstLineno > 1) {
|
|
1934
|
+
code.insertBefore(buildEllipsisNode(), code.firstChild);
|
|
1935
|
+
}
|
|
1936
|
+
if (lastLineno != null && lastLineno < lines.length) {
|
|
1937
|
+
code.appendChild(buildEllipsisNode());
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
section.appendChild(pre);
|
|
1941
|
+
return section;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function bindMarkerHover(root) {
|
|
1945
|
+
root.querySelectorAll(".marker").forEach((marker) => {
|
|
1946
|
+
marker.addEventListener("mouseenter", () => {
|
|
1947
|
+
root.querySelectorAll(".expr.active").forEach((el) => el.classList.remove("active"));
|
|
1948
|
+
const key = marker.dataset.key;
|
|
1949
|
+
if (key) {
|
|
1950
|
+
root.querySelectorAll(`.expr[data-key=\\"${key}\\"]`).forEach((el) => el.classList.add("active"));
|
|
1951
|
+
} else {
|
|
1952
|
+
const expr = marker.closest(".expr");
|
|
1953
|
+
if (expr) expr.classList.add("active");
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
marker.addEventListener("mouseleave", () => {
|
|
1957
|
+
const key = marker.dataset.key;
|
|
1958
|
+
if (key) {
|
|
1959
|
+
root.querySelectorAll(`.expr[data-key=\\"${key}\\"]`).forEach((el) => el.classList.remove("active"));
|
|
1960
|
+
} else {
|
|
1961
|
+
const expr = marker.closest(".expr");
|
|
1962
|
+
if (expr) expr.classList.remove("active");
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function fileDisplayPath(file, idx) {
|
|
1969
|
+
return String((file && (file.display_path || file.path)) || `file-${idx + 1}`);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function fileUrlKey(file, idx) {
|
|
1973
|
+
return fileDisplayPath(file, idx).replace(/\\\\/g, "/");
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function encodeHashFileKey(key) {
|
|
1977
|
+
return encodeURIComponent(String(key || "")).replace(/%2F/g, "/");
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function decodeHashFileKey(value) {
|
|
1981
|
+
try {
|
|
1982
|
+
return decodeURIComponent(String(value || ""));
|
|
1983
|
+
} catch (_e) {
|
|
1984
|
+
return String(value || "");
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function parseHashStateFromLocation() {
|
|
1989
|
+
const raw = String(window.location.hash || "").replace(/^#/, "");
|
|
1990
|
+
if (!raw) return { fileKey: null, line: null };
|
|
1991
|
+
|
|
1992
|
+
if (!raw.includes("=")) {
|
|
1993
|
+
return { fileKey: decodeHashFileKey(raw), line: null };
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const params = new URLSearchParams(raw);
|
|
1997
|
+
const fileRaw = params.get("file");
|
|
1998
|
+
const lineRaw = params.get("line");
|
|
1999
|
+
const lineNum = lineRaw == null ? null : Number.parseInt(lineRaw, 10);
|
|
2000
|
+
|
|
2001
|
+
return {
|
|
2002
|
+
fileKey: fileRaw ? decodeHashFileKey(fileRaw) : null,
|
|
2003
|
+
line: Number.isFinite(lineNum) && lineNum > 0 ? lineNum : null
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function buildHashForSelection(fileKey, line) {
|
|
2008
|
+
const parts = [];
|
|
2009
|
+
if (fileKey) parts.push(`file=${encodeHashFileKey(fileKey)}`);
|
|
2010
|
+
if (line && Number(line) > 0) parts.push(`line=${Number(line)}`);
|
|
2011
|
+
return `#${parts.join("&")}`;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
function setLocationHashSelection(fileKey, line) {
|
|
2015
|
+
const hash = buildHashForSelection(fileKey, line);
|
|
2016
|
+
if (window.location.hash === hash) return;
|
|
2017
|
+
if (window.history && typeof window.history.replaceState === "function") {
|
|
2018
|
+
try {
|
|
2019
|
+
window.history.replaceState(null, "", hash);
|
|
2020
|
+
return;
|
|
2021
|
+
} catch (_e) {
|
|
2022
|
+
// `about:srcdoc` (runv preview) can reject replaceState with a file:// hash URL.
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
try {
|
|
2026
|
+
window.location.hash = hash;
|
|
2027
|
+
} catch (_e) {
|
|
2028
|
+
// Ignore hash update failures in restricted embedding contexts.
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function buildFileTree(files) {
|
|
2033
|
+
const root = { dirs: new Map(), files: [] };
|
|
2034
|
+
|
|
2035
|
+
files.forEach((file, idx) => {
|
|
2036
|
+
const key = fileUrlKey(file, idx);
|
|
2037
|
+
const display = fileDisplayPath(file, idx).replace(/\\\\/g, "/");
|
|
2038
|
+
const parts = display.split("/").filter(Boolean);
|
|
2039
|
+
const filename = parts.pop() || display || `file-${idx + 1}`;
|
|
2040
|
+
|
|
2041
|
+
let node = root;
|
|
2042
|
+
let currentPath = "";
|
|
2043
|
+
for (const part of parts) {
|
|
2044
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
2045
|
+
if (!node.dirs.has(part)) {
|
|
2046
|
+
node.dirs.set(part, { name: part, path: currentPath, dirs: new Map(), files: [] });
|
|
2047
|
+
}
|
|
2048
|
+
node = node.dirs.get(part);
|
|
2049
|
+
}
|
|
2050
|
+
const coverage = expressionLineCoverageForTrace(Array.isArray(file.trace) ? file.trace : []);
|
|
2051
|
+
const coverageText = coverage.expected > 0 ? ` (${coverage.executed}/${coverage.expected})` : "";
|
|
2052
|
+
node.files.push({ label: filename, key, file, index: idx, path: display, coverageText });
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
return root;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function treeNodeContainsSelection(node, selectedKey) {
|
|
2059
|
+
if (!node) return false;
|
|
2060
|
+
if ((node.files || []).some((f) => f.key === selectedKey)) return true;
|
|
2061
|
+
for (const child of (node.dirs || new Map()).values()) {
|
|
2062
|
+
if (treeNodeContainsSelection(child, selectedKey)) return true;
|
|
2063
|
+
}
|
|
2064
|
+
return false;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function renderFileTreeNode(node, selectedKey, onSelect, level) {
|
|
2068
|
+
const ul = document.createElement("ul");
|
|
2069
|
+
ul.className = "tree-list";
|
|
2070
|
+
ul.dataset.level = String(level || 0);
|
|
2071
|
+
|
|
2072
|
+
const dirs = Array.from((node.dirs || new Map()).values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
2073
|
+
const files = Array.from(node.files || []).sort((a, b) => a.path.localeCompare(b.path));
|
|
2074
|
+
|
|
2075
|
+
dirs.forEach((dirNode) => {
|
|
2076
|
+
const li = document.createElement("li");
|
|
2077
|
+
li.className = "tree-dir";
|
|
2078
|
+
|
|
2079
|
+
const details = document.createElement("details");
|
|
2080
|
+
details.className = "tree-folder";
|
|
2081
|
+
details.open = true;
|
|
2082
|
+
|
|
2083
|
+
const summary = document.createElement("summary");
|
|
2084
|
+
summary.className = "tree-folder-label";
|
|
2085
|
+
summary.textContent = dirNode.name;
|
|
2086
|
+
details.appendChild(summary);
|
|
2087
|
+
|
|
2088
|
+
details.appendChild(renderFileTreeNode(dirNode, selectedKey, onSelect, (level || 0) + 1));
|
|
2089
|
+
li.appendChild(details);
|
|
2090
|
+
ul.appendChild(li);
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
files.forEach((entry) => {
|
|
2094
|
+
const li = document.createElement("li");
|
|
2095
|
+
li.className = "tree-file";
|
|
2096
|
+
|
|
2097
|
+
const btn = document.createElement("button");
|
|
2098
|
+
btn.type = "button";
|
|
2099
|
+
btn.className = "tree-file-btn";
|
|
2100
|
+
const name = document.createElement("span");
|
|
2101
|
+
name.className = "tree-file-name";
|
|
2102
|
+
name.textContent = entry.label;
|
|
2103
|
+
btn.appendChild(name);
|
|
2104
|
+
|
|
2105
|
+
if (entry.coverageText) {
|
|
2106
|
+
const meta = document.createElement("span");
|
|
2107
|
+
meta.className = "tree-file-meta";
|
|
2108
|
+
meta.textContent = entry.coverageText;
|
|
2109
|
+
btn.appendChild(meta);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
btn.dataset.fileKey = entry.key;
|
|
2113
|
+
if (entry.key === selectedKey) {
|
|
2114
|
+
btn.classList.add("active");
|
|
2115
|
+
btn.setAttribute("aria-current", "page");
|
|
2116
|
+
}
|
|
2117
|
+
btn.addEventListener("click", () => onSelect(entry.key));
|
|
2118
|
+
li.appendChild(btn);
|
|
2119
|
+
ul.appendChild(li);
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
return ul;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
function preferredInitialFileKey(files) {
|
|
2126
|
+
if (!files || files.length === 0) return null;
|
|
2127
|
+
const hashKey = parseHashStateFromLocation().fileKey;
|
|
2128
|
+
if (hashKey) {
|
|
2129
|
+
const byDisplay = files.find((file, idx) => fileUrlKey(file, idx) === hashKey);
|
|
2130
|
+
if (byDisplay) return fileUrlKey(byDisplay, files.indexOf(byDisplay));
|
|
2131
|
+
|
|
2132
|
+
const byPath = files.find((file) => String(file.path || "") === hashKey);
|
|
2133
|
+
if (byPath) return fileUrlKey(byPath, files.indexOf(byPath));
|
|
2134
|
+
}
|
|
2135
|
+
return fileUrlKey(files[0], 0);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function preferredInitialLineNumber() {
|
|
2139
|
+
return parseHashStateFromLocation().line;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function focusLineInViewer(viewer, lineno) {
|
|
2143
|
+
viewer.querySelectorAll(".line.line-target").forEach((el) => el.classList.remove("line-target"));
|
|
2144
|
+
if (!(lineno > 0)) return;
|
|
2145
|
+
const target = viewer.querySelector(`.line[data-line="${lineno}"]`);
|
|
2146
|
+
if (!target) return;
|
|
2147
|
+
target.classList.add("line-target");
|
|
2148
|
+
if (typeof target.scrollIntoView === "function") {
|
|
2149
|
+
target.scrollIntoView({ block: "center", inline: "nearest" });
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function render(payload) {
|
|
2154
|
+
app.textContent = "";
|
|
2155
|
+
|
|
2156
|
+
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
|
2157
|
+
if (files.length === 0) {
|
|
2158
|
+
const empty = document.createElement("p");
|
|
2159
|
+
empty.className = "hint";
|
|
2160
|
+
empty.textContent = "No files to render.";
|
|
2161
|
+
app.appendChild(empty);
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const hint = document.createElement("div");
|
|
2166
|
+
hint.className = "hint";
|
|
2167
|
+
hint.textContent = "Hover highlighted text to see recorded values.";
|
|
2168
|
+
app.appendChild(hint);
|
|
2169
|
+
|
|
2170
|
+
const mode = document.createElement("div");
|
|
2171
|
+
mode.className = "mode";
|
|
2172
|
+
mode.textContent = payload && payload.meta && payload.meta.mode_text ? payload.meta.mode_text : "";
|
|
2173
|
+
|
|
2174
|
+
const commandText = payload && payload.meta && payload.meta.command ? String(payload.meta.command) : "";
|
|
2175
|
+
if (commandText) {
|
|
2176
|
+
const command = document.createElement("div");
|
|
2177
|
+
command.className = "command";
|
|
2178
|
+
command.textContent = `Command: ${commandText}`;
|
|
2179
|
+
app.appendChild(command);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
app.appendChild(mode);
|
|
2183
|
+
|
|
2184
|
+
const shell = document.createElement("div");
|
|
2185
|
+
shell.className = `report-layout${files.length <= 1 ? " single-file" : ""}`;
|
|
2186
|
+
app.appendChild(shell);
|
|
1347
2187
|
|
|
2188
|
+
const sidebar = document.createElement("aside");
|
|
2189
|
+
sidebar.className = "report-sidebar";
|
|
2190
|
+
shell.appendChild(sidebar);
|
|
2191
|
+
|
|
2192
|
+
const treeTitle = document.createElement("div");
|
|
2193
|
+
treeTitle.className = "tree-title";
|
|
2194
|
+
treeTitle.textContent = `Files (${files.length})`;
|
|
2195
|
+
sidebar.appendChild(treeTitle);
|
|
2196
|
+
|
|
2197
|
+
const treeMount = document.createElement("div");
|
|
2198
|
+
treeMount.className = "tree-scroll";
|
|
2199
|
+
sidebar.appendChild(treeMount);
|
|
2200
|
+
|
|
2201
|
+
const main = document.createElement("section");
|
|
2202
|
+
main.className = "report-main";
|
|
2203
|
+
shell.appendChild(main);
|
|
2204
|
+
|
|
2205
|
+
const mainHead = document.createElement("div");
|
|
2206
|
+
mainHead.className = "report-main-head";
|
|
2207
|
+
main.appendChild(mainHead);
|
|
2208
|
+
|
|
2209
|
+
const currentPath = document.createElement("div");
|
|
2210
|
+
currentPath.className = "current-file";
|
|
2211
|
+
mainHead.appendChild(currentPath);
|
|
2212
|
+
|
|
2213
|
+
const viewer = document.createElement("div");
|
|
2214
|
+
viewer.className = "report-viewer";
|
|
2215
|
+
main.appendChild(viewer);
|
|
2216
|
+
|
|
2217
|
+
const fileKeyToEntry = new Map();
|
|
2218
|
+
files.forEach((file, idx) => fileKeyToEntry.set(fileUrlKey(file, idx), { file, idx }));
|
|
2219
|
+
|
|
2220
|
+
let selectedKey = preferredInitialFileKey(files);
|
|
2221
|
+
let selectedLine = preferredInitialLineNumber();
|
|
2222
|
+
|
|
2223
|
+
function renderTree() {
|
|
2224
|
+
treeMount.textContent = "";
|
|
2225
|
+
const treeRoot = buildFileTree(files);
|
|
2226
|
+
treeMount.appendChild(renderFileTreeNode(treeRoot, selectedKey, (key) => selectFile(key, true, null), 0));
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function renderSelectedFile() {
|
|
2230
|
+
viewer.textContent = "";
|
|
2231
|
+
const entry = fileKeyToEntry.get(selectedKey);
|
|
2232
|
+
if (!entry) return;
|
|
2233
|
+
|
|
2234
|
+
currentPath.textContent = fileDisplayPath(entry.file, entry.idx);
|
|
2235
|
+
|
|
2236
|
+
viewer.appendChild(renderFileSection(entry.file, entry.idx, {
|
|
2237
|
+
includeTitle: false,
|
|
2238
|
+
lineHref: (lineno) => buildHashForSelection(selectedKey, lineno),
|
|
2239
|
+
onLineClick: (lineno) => selectLine(lineno, true)
|
|
2240
|
+
}));
|
|
2241
|
+
bindMarkerHover(viewer);
|
|
2242
|
+
focusLineInViewer(viewer, selectedLine);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function selectLine(lineno, updateHash) {
|
|
2246
|
+
const n = Number(lineno);
|
|
2247
|
+
selectedLine = Number.isFinite(n) && n > 0 ? n : null;
|
|
2248
|
+
renderSelectedFile();
|
|
2249
|
+
if (updateHash) setLocationHashSelection(selectedKey, selectedLine);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function selectFile(key, updateHash, nextLine) {
|
|
2253
|
+
if (!fileKeyToEntry.has(key)) return;
|
|
2254
|
+
if (arguments.length >= 3) {
|
|
2255
|
+
selectedLine = nextLine && Number(nextLine) > 0 ? Number(nextLine) : null;
|
|
2256
|
+
} else if (key !== selectedKey) {
|
|
2257
|
+
selectedLine = null;
|
|
2258
|
+
}
|
|
2259
|
+
selectedKey = key;
|
|
2260
|
+
renderTree();
|
|
2261
|
+
renderSelectedFile();
|
|
2262
|
+
if (updateHash) setLocationHashSelection(key, selectedLine);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
window.addEventListener("hashchange", () => {
|
|
2266
|
+
const state = parseHashStateFromLocation();
|
|
2267
|
+
if (state.fileKey && state.fileKey !== selectedKey && fileKeyToEntry.has(state.fileKey)) {
|
|
2268
|
+
selectFile(state.fileKey, false, state.line);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (state.fileKey === selectedKey || (!state.fileKey && selectedKey)) {
|
|
2272
|
+
selectedLine = state.line;
|
|
2273
|
+
renderSelectedFile();
|
|
2274
|
+
}
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
selectFile(selectedKey, true);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
try {
|
|
2281
|
+
const payload = JSON.parse(payloadEl.textContent || "{}");
|
|
2282
|
+
render(payload);
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
app.textContent = `Failed to render Lumitrace HTML: ${error}`;
|
|
2285
|
+
}
|
|
2286
|
+
})();'
|
|
2287
|
+
end
|
|
2288
|
+
|
|
2289
|
+
def self.html_report_styles
|
|
2290
|
+
<<~CSS
|
|
2291
|
+
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
|
|
2292
|
+
.report-layout { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
|
2293
|
+
.report-layout.single-file { grid-template-columns: minmax(0, 1fr); }
|
|
2294
|
+
.report-sidebar { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 12px; position: sticky; top: 16px; max-height: calc(100vh - 48px); overflow: hidden; }
|
|
2295
|
+
.report-layout.single-file .report-sidebar { display: none; }
|
|
2296
|
+
.tree-title { color: #444; font-size: 12px; margin-bottom: 8px; }
|
|
2297
|
+
.tree-scroll { overflow: auto; max-height: calc(100vh - 96px); }
|
|
2298
|
+
.tree-list { list-style: none; margin: 0; padding: 0; }
|
|
2299
|
+
.tree-list[data-level]:not([data-level="0"]) { margin-left: 14px; border-left: 1px dashed #e5dfd0; padding-left: 8px; }
|
|
2300
|
+
.tree-dir, .tree-file { margin: 2px 0; }
|
|
2301
|
+
.tree-folder { }
|
|
2302
|
+
.tree-folder-label { cursor: pointer; color: #4d473f; user-select: none; }
|
|
2303
|
+
.tree-folder-label::marker { color: #999; }
|
|
2304
|
+
.tree-file-btn { width: 100%; text-align: left; border: 0; background: transparent; color: #2a2a2a; padding: 4px 6px; border-radius: 6px; cursor: pointer; font: inherit; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
|
2305
|
+
.tree-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2306
|
+
.tree-file-meta { color: #6f6a62; font-size: 12px; white-space: nowrap; }
|
|
2307
|
+
.tree-file-btn:hover { background: #fff2c6; }
|
|
2308
|
+
.tree-file-btn.active { background: #f0ffe7; color: #1b5e3d; }
|
|
2309
|
+
.tree-file-btn.active .tree-file-meta { color: #1b5e3d; }
|
|
2310
|
+
.report-main { min-width: 0; }
|
|
2311
|
+
.report-main-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
2312
|
+
.current-file { color: #333; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2313
|
+
.report-viewer { min-width: 0; }
|
|
2314
|
+
.file-section { min-width: 0; }
|
|
2315
|
+
.code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
|
|
2316
|
+
.line { display: block; box-sizing: border-box; padding: 2px 8px; }
|
|
2317
|
+
.line:hover { background: #fff2c6; }
|
|
2318
|
+
.line.hit { background: #f0ffe7; }
|
|
2319
|
+
.line.miss { background: #ffecec; }
|
|
2320
|
+
.line.line-target { box-shadow: inset 3px 0 #2f6f8e; background: #e9f4fb; }
|
|
2321
|
+
.line.ellipsis { color: #999; }
|
|
2322
|
+
.ln { display: inline-block; width: 3em; color: #888; user-select: none; }
|
|
2323
|
+
.ln-link { color: inherit; text-decoration: none; }
|
|
2324
|
+
.ln-link:hover { text-decoration: underline; color: #2f6f8e; }
|
|
2325
|
+
.hint { color: #666; margin-bottom: 4px; }
|
|
2326
|
+
.command { color: #555; margin-bottom: 4px; font-size: 12px; overflow-wrap: anywhere; }
|
|
2327
|
+
.mode { color: #444; margin-bottom: 8px; }
|
|
2328
|
+
.report-footer { margin-top: 16px; color: #666; font-size: 12px; }
|
|
2329
|
+
.report-footer a { color: #2f6f8e; text-decoration: none; }
|
|
2330
|
+
.report-footer a:hover { text-decoration: underline; }
|
|
2331
|
+
.file { margin: 24px 0 8px; font-size: 16px; color: #333; }
|
|
2332
|
+
.expr { position: relative; display: inline-block; padding-bottom: 1px; }
|
|
2333
|
+
.expr.hit { }
|
|
2334
|
+
.expr.depth-1 { --hl: #7fbf7f; }
|
|
2335
|
+
.expr.depth-2 { --hl: #6fa8ff; }
|
|
2336
|
+
.expr.depth-3 { --hl: #ffb347; }
|
|
2337
|
+
.expr.depth-4 { --hl: #d78bff; }
|
|
2338
|
+
.expr.depth-5 { --hl: #ff6f91; }
|
|
2339
|
+
.expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
|
|
2340
|
+
.expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
|
|
2341
|
+
.marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
|
|
2342
|
+
.marker.miss { color: #c07070; }
|
|
2343
|
+
.marker.arg { color: #2f6f8e; }
|
|
2344
|
+
.marker .tooltip {
|
|
2345
|
+
display: none;
|
|
2346
|
+
position: absolute;
|
|
2347
|
+
left: 0;
|
|
2348
|
+
bottom: 100%;
|
|
2349
|
+
margin-bottom: 6px;
|
|
2350
|
+
background: #2b2b2b;
|
|
2351
|
+
color: #fff;
|
|
2352
|
+
padding: 4px 6px;
|
|
2353
|
+
border-radius: 4px;
|
|
2354
|
+
font-size: 12px;
|
|
2355
|
+
white-space: pre;
|
|
2356
|
+
min-width: 16ch;
|
|
2357
|
+
max-width: 90vw;
|
|
2358
|
+
overflow-x: auto;
|
|
2359
|
+
overflow-y: hidden;
|
|
2360
|
+
z-index: 10;
|
|
2361
|
+
pointer-events: auto;
|
|
2362
|
+
}
|
|
2363
|
+
.marker:hover .tooltip,
|
|
2364
|
+
.marker:focus-within .tooltip,
|
|
2365
|
+
.marker .tooltip:hover { display: block; }
|
|
2366
|
+
.noscript { color: #666; }
|
|
2367
|
+
@media (max-width: 900px) {
|
|
2368
|
+
body { padding: 16px; }
|
|
2369
|
+
.report-layout { grid-template-columns: 1fr; }
|
|
2370
|
+
.report-sidebar { position: static; max-height: none; }
|
|
2371
|
+
.tree-scroll { max-height: 220px; }
|
|
2372
|
+
.report-main-head { flex-direction: column; align-items: flex-start; }
|
|
2373
|
+
}
|
|
2374
|
+
CSS
|
|
2375
|
+
end
|
|
2376
|
+
|
|
2377
|
+
def self.footer_version_suffix
|
|
2378
|
+
return "" unless defined?(Lumitrace::VERSION) && Lumitrace::VERSION
|
|
2379
|
+
" v#{Lumitrace::VERSION}"
|
|
2380
|
+
end
|
|
2381
|
+
|
|
2382
|
+
def self.render_payload_html(payload)
|
|
1348
2383
|
<<~HTML
|
|
1349
2384
|
<!doctype html>
|
|
1350
2385
|
<html>
|
|
@@ -1352,71 +2387,22 @@ module GenerateResultedHtml
|
|
|
1352
2387
|
<meta charset="utf-8">
|
|
1353
2388
|
<title>Recorded Result View</title>
|
|
1354
2389
|
<style>
|
|
1355
|
-
|
|
1356
|
-
.code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
|
|
1357
|
-
.line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
|
|
1358
|
-
.line:hover { background: #fff2c6; }
|
|
1359
|
-
.line.hit { background: #f0ffe7; }
|
|
1360
|
-
.line.miss { background: #ffecec; }
|
|
1361
|
-
.line.ellipsis { color: #999; }
|
|
1362
|
-
.ln { display: inline-block; width: 3em; color: #888; user-select: none; }
|
|
1363
|
-
.hint { color: #666; margin-bottom: 4px; }
|
|
1364
|
-
.mode { color: #444; margin-bottom: 8px; }
|
|
1365
|
-
.expr { position: relative; display: inline-block; padding-bottom: 1px; }
|
|
1366
|
-
.expr.hit { }
|
|
1367
|
-
.expr.depth-1 { --hl: #7fbf7f; }
|
|
1368
|
-
.expr.depth-2 { --hl: #6fa8ff; }
|
|
1369
|
-
.expr.depth-3 { --hl: #ffb347; }
|
|
1370
|
-
.expr.depth-4 { --hl: #d78bff; }
|
|
1371
|
-
.expr.depth-5 { --hl: #ff6f91; }
|
|
1372
|
-
.expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
|
|
1373
|
-
.expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
|
|
1374
|
-
.marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
|
|
1375
|
-
.marker.miss { color: #c07070; }
|
|
1376
|
-
.marker.arg { color: #2f6f8e; }
|
|
1377
|
-
.marker .tooltip {
|
|
1378
|
-
display: none;
|
|
1379
|
-
position: absolute;
|
|
1380
|
-
left: 0;
|
|
1381
|
-
top: 100%;
|
|
1382
|
-
margin-top: 4px;
|
|
1383
|
-
background: #2b2b2b;
|
|
1384
|
-
color: #fff;
|
|
1385
|
-
padding: 4px 6px;
|
|
1386
|
-
border-radius: 4px;
|
|
1387
|
-
font-size: 12px;
|
|
1388
|
-
white-space: pre;
|
|
1389
|
-
min-width: 16ch;
|
|
1390
|
-
max-width: 90vw;
|
|
1391
|
-
overflow-x: auto;
|
|
1392
|
-
overflow-y: hidden;
|
|
1393
|
-
z-index: 10;
|
|
1394
|
-
pointer-events: auto;
|
|
1395
|
-
}
|
|
1396
|
-
.marker:hover .tooltip,
|
|
1397
|
-
.marker:focus-within .tooltip,
|
|
1398
|
-
.marker .tooltip:hover { display: block; }
|
|
2390
|
+
#{html_report_styles}
|
|
1399
2391
|
</style>
|
|
1400
2392
|
</head>
|
|
1401
2393
|
<body>
|
|
1402
|
-
<div
|
|
1403
|
-
<div class="
|
|
1404
|
-
<
|
|
1405
|
-
|
|
1406
|
-
|
|
2394
|
+
<div id="lumitrace-app"></div>
|
|
2395
|
+
<div class="report-footer">Generated by <a href="https://ko1.github.io/lumitrace/" target="_blank" rel="noopener noreferrer">lumitrace</a>#{footer_version_suffix}.</div>
|
|
2396
|
+
<noscript><p class="noscript">Lumitrace HTML report requires JavaScript to render the source and trace view.</p></noscript>
|
|
2397
|
+
<script id="lumitrace-payload" type="application/json">#{payload_json_for_script(payload)}#{'</scr' + 'ipt>'}
|
|
2398
|
+
<script>
|
|
2399
|
+
#{html_renderer_js}
|
|
2400
|
+
#{'</scr' + 'ipt>'}
|
|
1407
2401
|
</body>
|
|
1408
2402
|
</html>
|
|
1409
2403
|
HTML
|
|
1410
2404
|
end
|
|
1411
2405
|
|
|
1412
|
-
def self.esc(s)
|
|
1413
|
-
s.to_s
|
|
1414
|
-
.gsub("&", "&")
|
|
1415
|
-
.gsub("<", "<")
|
|
1416
|
-
.gsub(">", ">")
|
|
1417
|
-
.gsub('"', """)
|
|
1418
|
-
end
|
|
1419
|
-
|
|
1420
2406
|
def self.detect_collect_mode(events)
|
|
1421
2407
|
arr = Array(events)
|
|
1422
2408
|
return "history" if arr.any? { |e| e.key?(:sampled_values) || e.key?("sampled_values") }
|
|
@@ -1566,6 +2552,7 @@ module GenerateResultedHtml
|
|
|
1566
2552
|
def self.comment_value_with_total_for_line(events)
|
|
1567
2553
|
best = best_event_for_line(events)
|
|
1568
2554
|
return nil unless best
|
|
2555
|
+
return nil if best[:total].to_i <= 0
|
|
1569
2556
|
|
|
1570
2557
|
sampled_last = best[:sampled_values]&.last
|
|
1571
2558
|
v, t = last_value_to_pair(sampled_last)
|
|
@@ -1669,9 +2656,16 @@ module GenerateResultedHtml
|
|
|
1669
2656
|
|
|
1670
2657
|
next if t <= s
|
|
1671
2658
|
spans << { start_col: s, end_col: t }
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
2659
|
+
event_key = e[:key] || [
|
|
2660
|
+
e[:file],
|
|
2661
|
+
e[:start_line].to_i,
|
|
2662
|
+
e[:start_col].to_i,
|
|
2663
|
+
e[:end_line].to_i,
|
|
2664
|
+
e[:end_col].to_i
|
|
2665
|
+
]
|
|
2666
|
+
key_id = event_key.join(":")
|
|
2667
|
+
buckets[event_key] = {
|
|
2668
|
+
key: event_key,
|
|
1675
2669
|
key_id: key_id,
|
|
1676
2670
|
start_col: s,
|
|
1677
2671
|
end_col: t,
|
|
@@ -1799,34 +2793,6 @@ module GenerateResultedHtml
|
|
|
1799
2793
|
ranges.any? { |(s, e)| line >= s && line <= e }
|
|
1800
2794
|
end
|
|
1801
2795
|
|
|
1802
|
-
def self.add_missing_events(events, source, filename, ranges)
|
|
1803
|
-
expected = RecordInstrument.collect_locations_from_source(source, ranges || [])
|
|
1804
|
-
existing = {}
|
|
1805
|
-
events.each do |e|
|
|
1806
|
-
key = [e[:file], e[:start_line], e[:start_col], e[:end_line], e[:end_col]]
|
|
1807
|
-
existing[key] = true
|
|
1808
|
-
end
|
|
1809
|
-
expected.each do |loc|
|
|
1810
|
-
key = [filename, loc[:start_line], loc[:start_col], loc[:end_line], loc[:end_col]]
|
|
1811
|
-
next if existing[key]
|
|
1812
|
-
events << {
|
|
1813
|
-
key: key,
|
|
1814
|
-
file: key[0],
|
|
1815
|
-
start_line: key[1],
|
|
1816
|
-
start_col: key[2],
|
|
1817
|
-
end_line: key[3],
|
|
1818
|
-
end_col: key[4],
|
|
1819
|
-
kind: loc[:kind],
|
|
1820
|
-
name: loc[:name],
|
|
1821
|
-
sampled_values: [],
|
|
1822
|
-
types: {},
|
|
1823
|
-
total: 0
|
|
1824
|
-
}
|
|
1825
|
-
existing[key] = true
|
|
1826
|
-
end
|
|
1827
|
-
events
|
|
1828
|
-
end
|
|
1829
|
-
|
|
1830
2796
|
def self.line_stats(source, ranges, events, filename)
|
|
1831
2797
|
expected_by_line = Hash.new(0)
|
|
1832
2798
|
RecordInstrument.collect_locations_from_source(source, ranges || []).each do |loc|
|
|
@@ -1850,293 +2816,131 @@ module GenerateResultedHtml
|
|
|
1850
2816
|
[expected_by_line, executed_by_line]
|
|
1851
2817
|
end
|
|
1852
2818
|
|
|
1853
|
-
def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil)
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
2819
|
+
def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
|
|
2820
|
+
raw = JSON.parse(File.read(events_path))
|
|
2821
|
+
raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
|
|
2822
|
+
render_all_from_events(
|
|
2823
|
+
raw_events,
|
|
2824
|
+
root: root,
|
|
2825
|
+
ranges_by_file: ranges_by_file,
|
|
2826
|
+
collect_mode: collect_mode,
|
|
2827
|
+
max_samples: max_samples,
|
|
2828
|
+
logger: logger,
|
|
2829
|
+
command_text: command_text
|
|
2830
|
+
)
|
|
2831
|
+
end
|
|
2832
|
+
|
|
2833
|
+
def self.render_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
|
|
2834
|
+
normalized = time_step("normalize_events", logger) { normalize_events(events) }
|
|
2835
|
+
render_all_from_normalized_events(
|
|
2836
|
+
normalized,
|
|
2837
|
+
root: root,
|
|
2838
|
+
ranges_by_file: ranges_by_file,
|
|
2839
|
+
collect_mode: collect_mode,
|
|
2840
|
+
max_samples: max_samples,
|
|
2841
|
+
logger: logger,
|
|
2842
|
+
command_text: command_text
|
|
2843
|
+
)
|
|
2844
|
+
end
|
|
2845
|
+
|
|
2846
|
+
def self.render_all_from_normalized_events(events, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
|
|
2847
|
+
total_start = monotonic_now
|
|
2848
|
+
|
|
2849
|
+
mode_info = time_step("resolve_mode", logger) do
|
|
2850
|
+
resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
|
|
2851
|
+
end
|
|
2852
|
+
by_file = time_step("group_by_file", logger) { events.group_by { |e| e[:file] } }
|
|
2853
|
+
ranges_by_file = time_step("normalize_ranges_by_file", logger) { normalize_ranges_by_file(ranges_by_file) }
|
|
2854
|
+
|
|
2855
|
+
files = []
|
|
2856
|
+
by_file.keys.sort.each do |path|
|
|
1867
2857
|
next unless File.exist?(path)
|
|
2858
|
+
file_start = monotonic_now
|
|
2859
|
+
read_start = logger ? monotonic_now : nil
|
|
1868
2860
|
src = File.read(path)
|
|
2861
|
+
read_ms = read_start ? (monotonic_now - read_start) * 1000.0 : nil
|
|
1869
2862
|
if ranges_by_file
|
|
1870
2863
|
next unless ranges_by_file.key?(path)
|
|
1871
2864
|
ranges = ranges_by_file[path] || []
|
|
1872
2865
|
else
|
|
1873
2866
|
ranges = nil
|
|
1874
2867
|
end
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
line_class = line_class_for(expected, executed)
|
|
1893
|
-
if expected > 0 && executed == 0
|
|
1894
|
-
evs.each { |e| e[:suppress_miss] = true }
|
|
1895
|
-
end
|
|
1896
|
-
if evs.empty?
|
|
1897
|
-
html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
|
|
1898
|
-
else
|
|
1899
|
-
rendered = render_line_with_events(line_text, evs)
|
|
1900
|
-
html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
|
|
1901
|
-
end
|
|
1902
|
-
prev_lineno = lineno
|
|
1903
|
-
last_lineno = lineno
|
|
1904
|
-
end
|
|
1905
|
-
if first_lineno && first_lineno > 1
|
|
1906
|
-
html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
|
|
1907
|
-
end
|
|
1908
|
-
if last_lineno && last_lineno < src.lines.length
|
|
1909
|
-
html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
|
|
2868
|
+
rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
|
|
2869
|
+
select_start = logger ? monotonic_now : nil
|
|
2870
|
+
file_events = by_file[path] || []
|
|
2871
|
+
select_ms = select_start ? (monotonic_now - select_start) * 1000.0 : nil
|
|
2872
|
+
payload_file_start = logger ? monotonic_now : nil
|
|
2873
|
+
files << build_html_payload_file(
|
|
2874
|
+
path: path,
|
|
2875
|
+
display_path: rel,
|
|
2876
|
+
source: src,
|
|
2877
|
+
ranges: ranges,
|
|
2878
|
+
trace_events: file_events,
|
|
2879
|
+
logger: logger
|
|
2880
|
+
)
|
|
2881
|
+
payload_file_ms = payload_file_start ? (monotonic_now - payload_file_start) * 1000.0 : nil
|
|
2882
|
+
if logger
|
|
2883
|
+
elapsed_ms = (monotonic_now - file_start) * 1000.0
|
|
2884
|
+
logger.call(format("html render: file %s read=%.1fms select=%.1fms payload=%.1fms events=%d bytes=%d total=%.1fms", rel, read_ms, select_ms, payload_file_ms, file_events.length, src.bytesize, elapsed_ms))
|
|
1910
2885
|
end
|
|
2886
|
+
end
|
|
1911
2887
|
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2888
|
+
payload = time_step("build_payload", logger) { build_html_payload(mode_info: mode_info, files: files, command_text: command_text) }
|
|
2889
|
+
html = time_step("render_payload_html", logger) { render_payload_html(payload) }
|
|
2890
|
+
if logger
|
|
2891
|
+
total_ms = (monotonic_now - total_start) * 1000.0
|
|
2892
|
+
logger.call(format("html render: total files=%d events=%d html_bytes=%d %.1fms", files.length, events.length, html.bytesize, total_ms))
|
|
2893
|
+
end
|
|
2894
|
+
html
|
|
2895
|
+
end
|
|
1920
2896
|
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
.line:hover { background: #fff2c6; }
|
|
1932
|
-
.line.hit { background: #f0ffe7; }
|
|
1933
|
-
.line.miss { background: #ffecec; }
|
|
1934
|
-
.line.ellipsis { color: #999; }
|
|
1935
|
-
.ln { display: inline-block; width: 3em; color: #888; user-select: none; }
|
|
1936
|
-
.hint { color: #666; margin-bottom: 4px; }
|
|
1937
|
-
.mode { color: #444; margin-bottom: 8px; }
|
|
1938
|
-
.file { margin: 24px 0 8px; font-size: 16px; color: #333; }
|
|
1939
|
-
.expr { position: relative; display: inline-block; padding-bottom: 1px; }
|
|
1940
|
-
.expr.hit { }
|
|
1941
|
-
.expr.depth-1 { --hl: #7fbf7f; }
|
|
1942
|
-
.expr.depth-2 { --hl: #6fa8ff; }
|
|
1943
|
-
.expr.depth-3 { --hl: #ffb347; }
|
|
1944
|
-
.expr.depth-4 { --hl: #d78bff; }
|
|
1945
|
-
.expr.depth-5 { --hl: #ff6f91; }
|
|
1946
|
-
.expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
|
|
1947
|
-
.expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
|
|
1948
|
-
.marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
|
|
1949
|
-
.marker.miss { color: #c07070; }
|
|
1950
|
-
.marker.arg { color: #2f6f8e; }
|
|
1951
|
-
.marker .tooltip {
|
|
1952
|
-
display: none;
|
|
1953
|
-
position: absolute;
|
|
1954
|
-
left: 0;
|
|
1955
|
-
top: 100%;
|
|
1956
|
-
margin-top: 4px;
|
|
1957
|
-
background: #2b2b2b;
|
|
1958
|
-
color: #fff;
|
|
1959
|
-
padding: 4px 6px;
|
|
1960
|
-
border-radius: 4px;
|
|
1961
|
-
font-size: 12px;
|
|
1962
|
-
white-space: pre;
|
|
1963
|
-
min-width: 16ch;
|
|
1964
|
-
max-width: 90vw;
|
|
1965
|
-
overflow-x: auto;
|
|
1966
|
-
overflow-y: hidden;
|
|
1967
|
-
z-index: 10;
|
|
1968
|
-
pointer-events: auto;
|
|
1969
|
-
}
|
|
1970
|
-
.marker:hover .tooltip,
|
|
1971
|
-
.marker:focus-within .tooltip,
|
|
1972
|
-
.marker .tooltip:hover { display: block; }
|
|
1973
|
-
</style>
|
|
1974
|
-
</head>
|
|
1975
|
-
<body>
|
|
1976
|
-
<div class="hint">Hover highlighted text to see recorded values.</div>
|
|
1977
|
-
<div class="mode">#{esc(mode_info[:text])}</div>
|
|
1978
|
-
#{sections}
|
|
1979
|
-
<script>
|
|
1980
|
-
(function() {
|
|
1981
|
-
document.querySelectorAll('.marker').forEach(marker => {
|
|
1982
|
-
marker.addEventListener('mouseenter', () => {
|
|
1983
|
-
document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
|
|
1984
|
-
const key = marker.dataset.key;
|
|
1985
|
-
if (key) {
|
|
1986
|
-
document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.add('active'));
|
|
1987
|
-
} else {
|
|
1988
|
-
marker.closest('.expr')?.classList.add('active');
|
|
1989
|
-
}
|
|
1990
|
-
});
|
|
1991
|
-
marker.addEventListener('mouseleave', () => {
|
|
1992
|
-
const key = marker.dataset.key;
|
|
1993
|
-
if (key) {
|
|
1994
|
-
document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.remove('active'));
|
|
1995
|
-
} else {
|
|
1996
|
-
marker.closest('.expr')?.classList.remove('active');
|
|
1997
|
-
}
|
|
1998
|
-
});
|
|
1999
|
-
});
|
|
2000
|
-
})();
|
|
2001
|
-
#{'</scr' + 'ipt>'}
|
|
2002
|
-
</body>
|
|
2003
|
-
</html>
|
|
2004
|
-
HTML
|
|
2897
|
+
def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
|
|
2898
|
+
render_source_from_normalized_events(
|
|
2899
|
+
source,
|
|
2900
|
+
normalize_events(events),
|
|
2901
|
+
filename: filename,
|
|
2902
|
+
ranges: ranges,
|
|
2903
|
+
collect_mode: collect_mode,
|
|
2904
|
+
max_samples: max_samples,
|
|
2905
|
+
command_text: command_text
|
|
2906
|
+
)
|
|
2005
2907
|
end
|
|
2006
2908
|
|
|
2007
|
-
def self.
|
|
2909
|
+
def self.render_source_from_normalized_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
|
|
2008
2910
|
mode_info = resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
|
|
2009
|
-
events = normalize_events(events)
|
|
2010
2911
|
ranges = normalize_ranges(ranges)
|
|
2011
|
-
target_events =
|
|
2012
|
-
expected_by_line, executed_by_line = line_stats(source, ranges, target_events, filename)
|
|
2912
|
+
target_events = events.select { |e| e[:file] == filename }
|
|
2013
2913
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
expected = expected_by_line[lineno]
|
|
2028
|
-
executed = executed_by_line[lineno]
|
|
2029
|
-
line_class = line_class_for(expected, executed)
|
|
2030
|
-
if expected > 0 && executed == 0
|
|
2031
|
-
evs.each { |e| e[:suppress_miss] = true }
|
|
2032
|
-
end
|
|
2033
|
-
if evs.empty?
|
|
2034
|
-
html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
|
|
2035
|
-
else
|
|
2036
|
-
rendered = render_line_with_events(line_text, evs)
|
|
2037
|
-
html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
|
|
2038
|
-
end
|
|
2039
|
-
prev_lineno = lineno
|
|
2040
|
-
last_lineno = lineno
|
|
2041
|
-
end
|
|
2042
|
-
if first_lineno && first_lineno > 1
|
|
2043
|
-
html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
|
|
2044
|
-
end
|
|
2045
|
-
if last_lineno && last_lineno < source.lines.length
|
|
2046
|
-
html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
|
|
2047
|
-
end
|
|
2914
|
+
payload = build_html_payload(
|
|
2915
|
+
mode_info: mode_info,
|
|
2916
|
+
command_text: command_text,
|
|
2917
|
+
files: [
|
|
2918
|
+
build_html_payload_file(
|
|
2919
|
+
path: filename,
|
|
2920
|
+
display_path: filename,
|
|
2921
|
+
source: source,
|
|
2922
|
+
ranges: ranges,
|
|
2923
|
+
trace_events: target_events
|
|
2924
|
+
)
|
|
2925
|
+
]
|
|
2926
|
+
)
|
|
2048
2927
|
|
|
2049
|
-
|
|
2050
|
-
<!doctype html>
|
|
2051
|
-
<html>
|
|
2052
|
-
<head>
|
|
2053
|
-
<meta charset="utf-8">
|
|
2054
|
-
<title>Recorded Result View</title>
|
|
2055
|
-
<style>
|
|
2056
|
-
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
|
|
2057
|
-
.code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
|
|
2058
|
-
.line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
|
|
2059
|
-
.line:hover { background: #fff2c6; }
|
|
2060
|
-
.line.hit { background: #f0ffe7; }
|
|
2061
|
-
.line.miss { background: #ffecec; }
|
|
2062
|
-
.line.ellipsis { color: #999; }
|
|
2063
|
-
.ln { display: inline-block; width: 3em; color: #888; user-select: none; }
|
|
2064
|
-
.hint { color: #666; margin-bottom: 4px; }
|
|
2065
|
-
.mode { color: #444; margin-bottom: 8px; }
|
|
2066
|
-
.file { margin: 24px 0 8px; font-size: 16px; color: #333; }
|
|
2067
|
-
.expr { position: relative; display: inline-block; padding-bottom: 1px; }
|
|
2068
|
-
.expr.hit { }
|
|
2069
|
-
.expr.depth-1 { --hl: #7fbf7f; }
|
|
2070
|
-
.expr.depth-2 { --hl: #6fa8ff; }
|
|
2071
|
-
.expr.depth-3 { --hl: #ffb347; }
|
|
2072
|
-
.expr.depth-4 { --hl: #d78bff; }
|
|
2073
|
-
.expr.depth-5 { --hl: #ff6f91; }
|
|
2074
|
-
.expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
|
|
2075
|
-
.expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
|
|
2076
|
-
.marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
|
|
2077
|
-
.marker.miss { color: #c07070; }
|
|
2078
|
-
.marker.arg { color: #2f6f8e; }
|
|
2079
|
-
.marker .tooltip {
|
|
2080
|
-
display: none;
|
|
2081
|
-
position: absolute;
|
|
2082
|
-
left: 0;
|
|
2083
|
-
top: 100%;
|
|
2084
|
-
margin-top: 4px;
|
|
2085
|
-
background: #2b2b2b;
|
|
2086
|
-
color: #fff;
|
|
2087
|
-
padding: 4px 6px;
|
|
2088
|
-
border-radius: 4px;
|
|
2089
|
-
font-size: 12px;
|
|
2090
|
-
white-space: pre;
|
|
2091
|
-
min-width: 16ch;
|
|
2092
|
-
max-width: 90vw;
|
|
2093
|
-
overflow-x: auto;
|
|
2094
|
-
overflow-y: hidden;
|
|
2095
|
-
z-index: 10;
|
|
2096
|
-
pointer-events: auto;
|
|
2097
|
-
}
|
|
2098
|
-
.marker:hover .tooltip,
|
|
2099
|
-
.marker:focus-within .tooltip,
|
|
2100
|
-
.marker .tooltip:hover { display: block; }
|
|
2101
|
-
</style>
|
|
2102
|
-
</head>
|
|
2103
|
-
<body>
|
|
2104
|
-
<div class="hint">Hover highlighted text to see recorded values.</div>
|
|
2105
|
-
<div class="mode">#{esc(mode_info[:text])}</div>
|
|
2106
|
-
<h2 class="file">#{esc(filename)}</h2>
|
|
2107
|
-
<pre class="code"><code>
|
|
2108
|
-
#{html_lines.join("")}
|
|
2109
|
-
</code></pre>
|
|
2110
|
-
<script>
|
|
2111
|
-
(function() {
|
|
2112
|
-
document.querySelectorAll('.marker').forEach(marker => {
|
|
2113
|
-
marker.addEventListener('mouseenter', () => {
|
|
2114
|
-
document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
|
|
2115
|
-
const key = marker.dataset.key;
|
|
2116
|
-
if (key) {
|
|
2117
|
-
document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.add('active'));
|
|
2118
|
-
} else {
|
|
2119
|
-
marker.closest('.expr')?.classList.add('active');
|
|
2120
|
-
}
|
|
2121
|
-
});
|
|
2122
|
-
marker.addEventListener('mouseleave', () => {
|
|
2123
|
-
const key = marker.dataset.key;
|
|
2124
|
-
if (key) {
|
|
2125
|
-
document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.remove('active'));
|
|
2126
|
-
} else {
|
|
2127
|
-
marker.closest('.expr')?.classList.remove('active');
|
|
2128
|
-
}
|
|
2129
|
-
});
|
|
2130
|
-
});
|
|
2131
|
-
})();
|
|
2132
|
-
#{'</scr' + 'ipt>'}
|
|
2133
|
-
</body>
|
|
2134
|
-
</html>
|
|
2135
|
-
HTML
|
|
2928
|
+
render_payload_html(payload)
|
|
2136
2929
|
end
|
|
2137
2930
|
|
|
2138
2931
|
def self.render_text_from_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
|
|
2139
|
-
|
|
2932
|
+
render_text_from_normalized_events(
|
|
2933
|
+
source,
|
|
2934
|
+
normalize_events(events),
|
|
2935
|
+
filename: filename,
|
|
2936
|
+
ranges: ranges,
|
|
2937
|
+
with_header: with_header,
|
|
2938
|
+
header_label: header_label,
|
|
2939
|
+
tty: tty
|
|
2940
|
+
)
|
|
2941
|
+
end
|
|
2942
|
+
|
|
2943
|
+
def self.render_text_from_normalized_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
|
|
2140
2944
|
ranges = normalize_ranges(ranges)
|
|
2141
2945
|
target_events = events.select { |e| e[:file] == filename }
|
|
2142
2946
|
term_width = tty ? terminal_width : nil
|
|
@@ -2158,7 +2962,7 @@ module GenerateResultedHtml
|
|
|
2158
2962
|
]
|
|
2159
2963
|
next if seen[key]
|
|
2160
2964
|
seen[key] = true
|
|
2161
|
-
executed_by_line[line] += 1 if line
|
|
2965
|
+
executed_by_line[line] += 1 if line && (e[:total] || e["total"]).to_i > 0
|
|
2162
2966
|
end
|
|
2163
2967
|
|
|
2164
2968
|
out = +""
|
|
@@ -2259,7 +3063,10 @@ module GenerateResultedHtml
|
|
|
2259
3063
|
end
|
|
2260
3064
|
|
|
2261
3065
|
def self.render_text_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
|
|
2262
|
-
|
|
3066
|
+
render_text_all_from_normalized_events(normalize_events(events), root: root, ranges_by_file: ranges_by_file, tty: tty)
|
|
3067
|
+
end
|
|
3068
|
+
|
|
3069
|
+
def self.render_text_all_from_normalized_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
|
|
2263
3070
|
by_file = events.group_by { |e| e[:file] }
|
|
2264
3071
|
ranges_by_file = normalize_ranges_by_file(ranges_by_file)
|
|
2265
3072
|
|
|
@@ -2273,7 +3080,7 @@ module GenerateResultedHtml
|
|
|
2273
3080
|
ranges = nil
|
|
2274
3081
|
end
|
|
2275
3082
|
rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
|
|
2276
|
-
|
|
3083
|
+
render_text_from_normalized_events(src, events, filename: path, ranges: ranges, with_header: true, header_label: rel, tty: tty)
|
|
2277
3084
|
end.compact
|
|
2278
3085
|
|
|
2279
3086
|
header = "\n=== Lumitrace Results (text) ===\n\n"
|