rsmp-validator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/config/cross_rs4s.yaml +55 -0
- data/config/gem_supervisor.yaml +56 -0
- data/config/gem_tlc.yaml +56 -0
- data/config/gem_tlc_secrets.yaml +3 -0
- data/config/kapsch_etx.yaml +54 -0
- data/config/lightmotion_satellite.yaml +56 -0
- data/config/secrets.yaml +3 -0
- data/config/secrets_example.yaml +6 -0
- data/config/semaforica_cartesio.yaml +56 -0
- data/config/simulator/node_log.yaml +17 -0
- data/config/simulator/supervisor.yaml +11 -0
- data/config/simulator/tlc.yaml +56 -0
- data/config/sus.rb +13 -0
- data/config/swarco_itc3.yaml +55 -0
- data/config/tecsen_tmacs_supervisor.yaml +57 -0
- data/config/validator.rb +37 -0
- data/config/validator.yaml +5 -0
- data/config/validator_example.yaml +23 -0
- data/config/validator_log.yaml +19 -0
- data/exe/rsmp-validator +121 -0
- data/lib/doc_gen/parser.rb +276 -0
- data/lib/doc_gen/renderer.rb +153 -0
- data/lib/rsmp/validator/async_context.rb +15 -0
- data/lib/rsmp/validator/auto_node.rb +82 -0
- data/lib/rsmp/validator/auto_site.rb +30 -0
- data/lib/rsmp/validator/auto_supervisor.rb +23 -0
- data/lib/rsmp/validator/config_normalizer.rb +103 -0
- data/lib/rsmp/validator/configuration/loader.rb +79 -0
- data/lib/rsmp/validator/configuration/secrets.rb +54 -0
- data/lib/rsmp/validator/configuration/validation.rb +115 -0
- data/lib/rsmp/validator/configuration.rb +129 -0
- data/lib/rsmp/validator/helpers/alarms.rb +66 -0
- data/lib/rsmp/validator/helpers/clock.rb +16 -0
- data/lib/rsmp/validator/helpers/connection.rb +73 -0
- data/lib/rsmp/validator/helpers/handshake.rb +110 -0
- data/lib/rsmp/validator/helpers/input.rb +42 -0
- data/lib/rsmp/validator/helpers/security.rb +26 -0
- data/lib/rsmp/validator/helpers/signal_plans.rb +37 -0
- data/lib/rsmp/validator/helpers/signal_priority.rb +130 -0
- data/lib/rsmp/validator/helpers/startup.rb +157 -0
- data/lib/rsmp/validator/helpers/status.rb +22 -0
- data/lib/rsmp/validator/lifecycle.rb +99 -0
- data/lib/rsmp/validator/log.rb +11 -0
- data/lib/rsmp/validator/mode_detection.rb +84 -0
- data/lib/rsmp/validator/options/site_test_options.rb +58 -0
- data/lib/rsmp/validator/options/supervisor_test_options.rb +51 -0
- data/lib/rsmp/validator/site_tester.rb +113 -0
- data/lib/rsmp/validator/supervisor_tester.rb +76 -0
- data/lib/rsmp/validator/tester.rb +101 -0
- data/lib/rsmp/validator/version.rb +5 -0
- data/lib/rsmp/validator/version_filter.rb +44 -0
- data/lib/rsmp/validator.rb +50 -0
- data/schemas/site_test.json +36 -0
- data/schemas/supervisor_test.json +28 -0
- data/test/site/core/aggregated_status_spec.rb +43 -0
- data/test/site/core/connect_spec.rb +104 -0
- data/test/site/core/core_spec.rb +9 -0
- data/test/site/core/disconnect_spec.rb +54 -0
- data/test/site/site_spec.rb +5 -0
- data/test/site/tlc/alarm_spec.rb +134 -0
- data/test/site/tlc/clock_spec.rb +252 -0
- data/test/site/tlc/detector_logics_spec.rb +76 -0
- data/test/site/tlc/emergency_routes_spec.rb +106 -0
- data/test/site/tlc/input_spec.rb +102 -0
- data/test/site/tlc/invalid_command_spec.rb +103 -0
- data/test/site/tlc/invalid_status_spec.rb +70 -0
- data/test/site/tlc/modes_spec.rb +260 -0
- data/test/site/tlc/output_spec.rb +58 -0
- data/test/site/tlc/signal_groups_spec.rb +96 -0
- data/test/site/tlc/signal_plans_spec.rb +287 -0
- data/test/site/tlc/signal_priority_spec.rb +144 -0
- data/test/site/tlc/subscribe_spec.rb +71 -0
- data/test/site/tlc/system_spec.rb +76 -0
- data/test/site/tlc/tlc_spec.rb +7 -0
- data/test/site/tlc/traffic_data_spec.rb +151 -0
- data/test/site/tlc/traffic_situations_spec.rb +50 -0
- data/test/supervisor/aggregated_status_spec.rb +18 -0
- data/test/supervisor/connect_spec.rb +219 -0
- data/test/supervisor/supervisor_spec.rb +11 -0
- metadata +190 -0
data/exe/rsmp-validator
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
require 'sus'
|
|
5
|
+
require 'sus/config'
|
|
6
|
+
|
|
7
|
+
def print_help
|
|
8
|
+
puts <<~HELP
|
|
9
|
+
RSMP Validator runs conformance tests against RSMP sites and supervisors.
|
|
10
|
+
Full documentation: https://rsmp-nordic.github.io/rsmp_validator/
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
rsmp-validator [OPTIONS] PATH...
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
rsmp-validator test/site
|
|
17
|
+
rsmp-validator test/site/core
|
|
18
|
+
rsmp-validator test/supervisor/connect_spec.rb
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--verbose Show detailed sus output.
|
|
22
|
+
--log Print RSMP log output to stdout.
|
|
23
|
+
--log PATH Write RSMP log output to PATH.
|
|
24
|
+
-h, --help Show this help.
|
|
25
|
+
|
|
26
|
+
Environment:
|
|
27
|
+
SITE_CONFIG Path to site test configuration.
|
|
28
|
+
SUPERVISOR_CONFIG Path to supervisor test configuration.
|
|
29
|
+
AUTO_SITE_CONFIG Path to local site configuration to start automatically.
|
|
30
|
+
HELP
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if ARGV.first == 'help' || ARGV.include?('--help') || ARGV.include?('-h')
|
|
34
|
+
print_help
|
|
35
|
+
exit 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Parse --log [path] from ARGV before Sus::Config.load consumes remaining args.
|
|
39
|
+
# --log => RSMP logs to $stdout
|
|
40
|
+
# --log <path> => RSMP logs to file, sus output to console (brief)
|
|
41
|
+
# + --verbose => sus output also goes to file (interleaved, perfectly ordered)
|
|
42
|
+
# (no --log) => RSMP logs suppressed
|
|
43
|
+
log_index = ARGV.index('--log')
|
|
44
|
+
log_path = nil
|
|
45
|
+
log_to_stdout = false
|
|
46
|
+
if log_index
|
|
47
|
+
ARGV.delete_at(log_index)
|
|
48
|
+
if ARGV[log_index] && !ARGV[log_index].start_with?('-')
|
|
49
|
+
log_path = ARGV.delete_at(log_index)
|
|
50
|
+
else
|
|
51
|
+
log_to_stdout = true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Writes to two IOs simultaneously through a single object, so both RSMP messages
|
|
56
|
+
# and Sus verbose output share one file descriptor and are perfectly interleaved.
|
|
57
|
+
class TeeIO
|
|
58
|
+
def initialize(primary, secondary)
|
|
59
|
+
@primary = primary
|
|
60
|
+
@secondary = secondary
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def write(*args)
|
|
64
|
+
@primary.write(*args)
|
|
65
|
+
@secondary.write(*args)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def puts(*args)
|
|
69
|
+
@primary.puts(*args)
|
|
70
|
+
@secondary.puts(*args)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def flush
|
|
74
|
+
@primary.flush
|
|
75
|
+
@secondary.flush
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def isatty = @primary.isatty
|
|
79
|
+
def tty? = @primary.tty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Load config/validator.rb instead of config/sus.rb so that conformance tests
|
|
83
|
+
# live in valid/site/ and valid/supervisor/, keeping them separate from the
|
|
84
|
+
# internal tests in test/ that plain `sus` runs.
|
|
85
|
+
validator_config_class = Class.new(Sus::Config) do
|
|
86
|
+
attr_accessor :log_to_stdout, :log_path, :log_file_io
|
|
87
|
+
|
|
88
|
+
def self.path(root)
|
|
89
|
+
path = File.join(root, 'config/validator.rb')
|
|
90
|
+
File.exist?(path) ? path : nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
config = validator_config_class.load
|
|
95
|
+
config.log_to_stdout = log_to_stdout
|
|
96
|
+
config.log_path = log_path
|
|
97
|
+
|
|
98
|
+
registry = config.registry
|
|
99
|
+
|
|
100
|
+
def run_suite(config, registry, output, verbose)
|
|
101
|
+
assertions = Sus::Assertions.default(output: output, verbose: verbose)
|
|
102
|
+
config.before_tests(assertions)
|
|
103
|
+
registry.call(assertions)
|
|
104
|
+
config.after_tests(assertions)
|
|
105
|
+
exit(1) unless assertions.passed?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# When --log <path> and --verbose are both given, open the file once and share
|
|
109
|
+
# the handle: Sus writes via TeeIO (terminal + file), RSMP writes to the same
|
|
110
|
+
# File object directly. One fd => serialized writes => perfect ordering.
|
|
111
|
+
if log_path && config.verbose?
|
|
112
|
+
File.open(log_path, 'w') do |log_file|
|
|
113
|
+
config.log_path = nil
|
|
114
|
+
config.log_file_io = log_file
|
|
115
|
+
output = Sus::Output.default(TeeIO.new($stderr, log_file))
|
|
116
|
+
run_suite(config, registry, output, true)
|
|
117
|
+
end
|
|
118
|
+
else
|
|
119
|
+
output = config.verbose? ? config.output : Sus::Output::Null.new
|
|
120
|
+
run_suite(config, registry, output, config.verbose?)
|
|
121
|
+
end
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
# DocGen: Prism-based parser and Jekyll Markdown renderer for test documentation.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# require_relative 'lib/doc_gen/parser'
|
|
9
|
+
# require_relative 'lib/doc_gen/renderer'
|
|
10
|
+
#
|
|
11
|
+
# contexts = DocGen::Parser.parse_files(Dir['test/**/*_spec.rb'])
|
|
12
|
+
# DocGen::Renderer.render(contexts, output_dir: 'docs/tests')
|
|
13
|
+
module DocGen
|
|
14
|
+
# A context represents a `describe` block. It holds child contexts and specs.
|
|
15
|
+
Context = Struct.new(:name, :docstring, :children, :file, :line, :parent, keyword_init: true) do
|
|
16
|
+
# Direct child specs (it/specify blocks).
|
|
17
|
+
def specs
|
|
18
|
+
children.grep(Spec)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Direct child contexts (nested describe blocks).
|
|
22
|
+
def subcontexts
|
|
23
|
+
children.grep(Context)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Full display name, dropping the root component when nested.
|
|
27
|
+
# Examples:
|
|
28
|
+
# root 'Site::Tlc::Io' => 'Site::Tlc::Io'
|
|
29
|
+
# child 'IO' of above => 'IO'
|
|
30
|
+
# grandchild 'Input' of above => 'IO Input'
|
|
31
|
+
def full_name
|
|
32
|
+
parts = [name]
|
|
33
|
+
ctx = parent
|
|
34
|
+
while ctx.is_a?(Context)
|
|
35
|
+
parts.unshift(ctx.name)
|
|
36
|
+
ctx = ctx.parent
|
|
37
|
+
end
|
|
38
|
+
parts.shift if parts.size > 1
|
|
39
|
+
parts.join(' ')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Hierarchical output path relative to the output directory root.
|
|
43
|
+
# Examples:
|
|
44
|
+
# root 'Site::Tlc::Io' => 'site_tlc_io.md'
|
|
45
|
+
# child 'IO' => 'site_tlc_io/io.md'
|
|
46
|
+
# grandchild 'Input' => 'site_tlc_io/io/input.md'
|
|
47
|
+
def output_path
|
|
48
|
+
parts = []
|
|
49
|
+
ctx = self
|
|
50
|
+
while ctx.is_a?(Context)
|
|
51
|
+
parts.unshift(DocGen.slugify(ctx.name))
|
|
52
|
+
ctx = ctx.parent
|
|
53
|
+
end
|
|
54
|
+
"#{parts.join('/')}.md"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A spec represents an `it` or `specify` block.
|
|
59
|
+
Spec = Struct.new(:name, :docstring, :source, :file, :line, :parent, keyword_init: true) do
|
|
60
|
+
# Full display name including ancestors (root component dropped, same as Context).
|
|
61
|
+
def full_name
|
|
62
|
+
parts = [name]
|
|
63
|
+
ctx = parent
|
|
64
|
+
while ctx.is_a?(Context)
|
|
65
|
+
parts.unshift(ctx.name)
|
|
66
|
+
ctx = ctx.parent
|
|
67
|
+
end
|
|
68
|
+
parts.shift if parts.size > 1
|
|
69
|
+
parts.join(' ')
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Convert a name to a URL/filesystem-friendly slug.
|
|
74
|
+
def self.slugify(name)
|
|
75
|
+
name.gsub('::', '_')
|
|
76
|
+
.gsub(/[^a-zA-Z0-9_]+/, '_')
|
|
77
|
+
.gsub(/_+/, '_')
|
|
78
|
+
.gsub(/\A_+|_+\z/, '')
|
|
79
|
+
.downcase
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Convert a raw describe name to a human-readable title.
|
|
83
|
+
# Takes the last :: segment and splits CamelCase into words.
|
|
84
|
+
# Examples:
|
|
85
|
+
# 'Site::Tlc::DetectorLogics' => 'Detector Logics'
|
|
86
|
+
# 'Site::Core' => 'Core'
|
|
87
|
+
# 'Detector Logic' => 'Detector Logic' (already readable)
|
|
88
|
+
def self.humanize(name)
|
|
89
|
+
segment = name.split('::').last || name
|
|
90
|
+
segment.gsub(/([a-z\d])([A-Z])/, '\1 \2')
|
|
91
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Builds a namespace tree from flat parsed contexts by merging on '::' segments.
|
|
95
|
+
# e.g. 'Site::Tlc::Alarm' becomes Site -> Tlc -> Alarm in the tree.
|
|
96
|
+
class NamespaceBuilder
|
|
97
|
+
def build(raw_contexts)
|
|
98
|
+
@node_map = {}
|
|
99
|
+
sorted = raw_contexts.sort_by { |ctx| ctx.name.include?('::') ? 0 : 1 }
|
|
100
|
+
sorted.each { |raw| process_raw_context(raw) }
|
|
101
|
+
@node_map.values.select { |n| n.parent.nil? }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def process_raw_context(raw)
|
|
107
|
+
segments = raw.name.split('::')
|
|
108
|
+
segments.each_with_index { |_seg, idx| ensure_namespace_node(segments, idx, raw) }
|
|
109
|
+
leaf = @node_map[raw.name]
|
|
110
|
+
leaf.docstring ||= raw.docstring
|
|
111
|
+
raw.children.each { |child| adopt_child(leaf, child) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ensure_namespace_node(segments, idx, raw)
|
|
115
|
+
path = segments[0..idx].join('::')
|
|
116
|
+
return if @node_map.key?(path)
|
|
117
|
+
|
|
118
|
+
parent_node = idx.positive? ? @node_map[segments[0...idx].join('::')] : nil
|
|
119
|
+
node = Context.new(
|
|
120
|
+
name: segments[idx], docstring: nil, children: [],
|
|
121
|
+
file: raw.file, line: raw.line, parent: parent_node
|
|
122
|
+
)
|
|
123
|
+
@node_map[path] = node
|
|
124
|
+
parent_node.children << node if parent_node
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def adopt_child(leaf, child)
|
|
128
|
+
if child.is_a?(Context)
|
|
129
|
+
adopt_context_child(leaf, child)
|
|
130
|
+
else
|
|
131
|
+
child.parent = leaf
|
|
132
|
+
leaf.children << child
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def adopt_context_child(leaf, child)
|
|
137
|
+
existing = leaf.children.find { |c| c.is_a?(Context) && c.name == child.name }
|
|
138
|
+
if existing
|
|
139
|
+
merge_into_existing(existing, child)
|
|
140
|
+
elsif child.children.any?
|
|
141
|
+
child.parent = leaf
|
|
142
|
+
leaf.children << child
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def merge_into_existing(existing, child)
|
|
147
|
+
existing.docstring ||= child.docstring
|
|
148
|
+
child.children.each do |grandchild|
|
|
149
|
+
grandchild.parent = existing
|
|
150
|
+
existing.children << grandchild
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Parses Ruby test files using Prism and builds a tree of Context and Spec objects.
|
|
156
|
+
class Parser
|
|
157
|
+
# Parse an array of file paths and return an array of root Context objects.
|
|
158
|
+
def self.parse_files(paths)
|
|
159
|
+
new.parse_files(paths)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def parse_files(paths)
|
|
163
|
+
raw = Array(paths).flat_map { |path| parse_file(path) }
|
|
164
|
+
NamespaceBuilder.new.build(raw)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def parse_file(path)
|
|
170
|
+
source = File.read(path)
|
|
171
|
+
result = Prism.parse(source)
|
|
172
|
+
lines = source.lines
|
|
173
|
+
comment_map = build_comment_map(result, lines)
|
|
174
|
+
top_nodes = result.value.statements&.body || []
|
|
175
|
+
parse_block(top_nodes, path, comment_map, lines, nil)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Recursively parses AST nodes into Context/Spec objects.
|
|
179
|
+
def parse_block(nodes, file, comment_map, lines, parent_context)
|
|
180
|
+
roots = []
|
|
181
|
+
Array(nodes).each do |node|
|
|
182
|
+
next unless node.is_a?(Prism::CallNode)
|
|
183
|
+
|
|
184
|
+
case node.name
|
|
185
|
+
when :describe
|
|
186
|
+
ctx = handle_describe(node, file, comment_map, lines, parent_context)
|
|
187
|
+
roots << ctx if ctx && parent_context.nil?
|
|
188
|
+
when :it, :specify
|
|
189
|
+
handle_spec(node, file, comment_map, lines, parent_context)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
roots
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_describe(node, file, comment_map, lines, parent_context)
|
|
196
|
+
ctx = make_context(node, file, comment_map, parent_context)
|
|
197
|
+
return unless ctx
|
|
198
|
+
|
|
199
|
+
parent_context.children << ctx if parent_context
|
|
200
|
+
body = block_body(node)
|
|
201
|
+
parse_block(body, file, comment_map, lines, ctx) if body
|
|
202
|
+
ctx
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def handle_spec(node, file, comment_map, lines, parent_context)
|
|
206
|
+
spec = make_spec(node, file, comment_map, lines, parent_context)
|
|
207
|
+
parent_context.children << spec if spec
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def make_context(node, file, comment_map, parent_context)
|
|
211
|
+
name = string_arg(node)
|
|
212
|
+
return unless name
|
|
213
|
+
|
|
214
|
+
Context.new(name: name, docstring: extract_docstring(node.location.start_line, comment_map),
|
|
215
|
+
children: [], file: file, line: node.location.start_line, parent: parent_context)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def make_spec(node, file, comment_map, lines, parent_context)
|
|
219
|
+
name = string_arg(node)
|
|
220
|
+
return unless name
|
|
221
|
+
return unless parent_context.is_a?(Context)
|
|
222
|
+
|
|
223
|
+
Spec.new(name: name, docstring: extract_docstring(node.location.start_line, comment_map),
|
|
224
|
+
source: extract_source(node, lines), file: file,
|
|
225
|
+
line: node.location.start_line, parent: parent_context)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Build a map of line_number => comment_text for standalone comment lines only.
|
|
229
|
+
# Inline comments (e.g. `foo # bar`) are excluded.
|
|
230
|
+
def build_comment_map(result, lines)
|
|
231
|
+
result.comments.each_with_object({}) do |comment, map|
|
|
232
|
+
line_num = comment.location.start_line
|
|
233
|
+
source_line = lines[line_num - 1] || ''
|
|
234
|
+
map[line_num] = comment.slice if source_line.strip.start_with?('#')
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Extract the string value of the first argument to a call node.
|
|
239
|
+
# Returns nil for non-string or missing arguments.
|
|
240
|
+
def string_arg(node)
|
|
241
|
+
return nil unless node.arguments
|
|
242
|
+
|
|
243
|
+
first = node.arguments.arguments.first
|
|
244
|
+
case first
|
|
245
|
+
when Prism::StringNode, Prism::SymbolNode then first.unescaped
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Return the body statement array from a block node, or nil.
|
|
250
|
+
def block_body(node)
|
|
251
|
+
return nil unless node.block&.body
|
|
252
|
+
|
|
253
|
+
body = node.block.body
|
|
254
|
+
body.is_a?(Prism::StatementsNode) ? body.body : nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Walk backwards from the line before start_line collecting contiguous comment lines.
|
|
258
|
+
# A blank line or non-comment line stops the collection.
|
|
259
|
+
def extract_docstring(start_line, comment_map)
|
|
260
|
+
doc_lines = []
|
|
261
|
+
current = start_line - 1
|
|
262
|
+
while comment_map[current]
|
|
263
|
+
doc_lines.unshift(comment_map[current].sub(/^\s*#\s?/, ''))
|
|
264
|
+
current -= 1
|
|
265
|
+
end
|
|
266
|
+
doc_lines.join("\n").strip
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Extract the full source of the `it`/`specify` call (including block body).
|
|
270
|
+
def extract_source(node, lines)
|
|
271
|
+
start = node.location.start_line - 1
|
|
272
|
+
stop = node.location.end_line
|
|
273
|
+
lines[start...stop].join.chomp
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative 'parser'
|
|
5
|
+
|
|
6
|
+
module DocGen
|
|
7
|
+
# Renders a tree of Context objects to Jekyll-compatible Markdown files.
|
|
8
|
+
#
|
|
9
|
+
# Frontmatter fields produced:
|
|
10
|
+
# layout, title, parmalink (sic - preserved from original for compatibility),
|
|
11
|
+
# has_children, has_toc, parent, grand_parent (when applicable)
|
|
12
|
+
#
|
|
13
|
+
# Jekyll nav hierarchy (just-the-docs):
|
|
14
|
+
# depth 0 -> parent: "Test Suite" (no grand_parent)
|
|
15
|
+
# depth 1 -> parent: <root name>, grand_parent: "Test Suite"
|
|
16
|
+
# depth 2+ -> parent: <direct parent name>, grand_parent: <grandparent name or "Test Suite">
|
|
17
|
+
class Renderer
|
|
18
|
+
# Render an array of root Context objects to output_dir.
|
|
19
|
+
def self.render(contexts, output_dir:)
|
|
20
|
+
new(output_dir: output_dir).render(contexts)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(output_dir:)
|
|
24
|
+
@output_dir = output_dir
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render(contexts)
|
|
28
|
+
FileUtils.mkdir_p(@output_dir)
|
|
29
|
+
contexts.each { |ctx| render_context(ctx) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Write a single context page and recurse into subcontexts.
|
|
35
|
+
def render_context(ctx)
|
|
36
|
+
path = File.join(@output_dir, ctx.output_path)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
38
|
+
File.write(path, context_content(ctx))
|
|
39
|
+
ctx.subcontexts.each { |child| render_context(child) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Assemble the full Markdown content for a context page.
|
|
43
|
+
def context_content(ctx)
|
|
44
|
+
parts = [
|
|
45
|
+
frontmatter(ctx),
|
|
46
|
+
page_title(ctx),
|
|
47
|
+
description(ctx)
|
|
48
|
+
]
|
|
49
|
+
parts << context_toc(ctx) if ctx.subcontexts.any?
|
|
50
|
+
parts << specification_toc if ctx.specs.any?
|
|
51
|
+
parts << specifications(ctx) if ctx.specs.any?
|
|
52
|
+
parts.join
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Jekyll frontmatter block.
|
|
56
|
+
def frontmatter(ctx)
|
|
57
|
+
fields = {
|
|
58
|
+
layout: 'page',
|
|
59
|
+
title: DocGen.humanize(ctx.name),
|
|
60
|
+
parmalink: DocGen.slugify(ctx.full_name),
|
|
61
|
+
has_children: ctx.subcontexts.any?,
|
|
62
|
+
has_toc: false,
|
|
63
|
+
parent: parent_title(ctx)
|
|
64
|
+
}
|
|
65
|
+
grand = grand_parent_title(ctx)
|
|
66
|
+
fields[:grand_parent] = grand if grand
|
|
67
|
+
|
|
68
|
+
lines = fields.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
69
|
+
"---\n#{lines}\n---\n\n"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# H1 heading using humanized name.
|
|
73
|
+
def page_title(ctx)
|
|
74
|
+
"# #{DocGen.humanize(ctx.name)}\n{: .no_toc}\n\n"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Context-level docstring (comment above the describe block), if present.
|
|
78
|
+
def description(ctx)
|
|
79
|
+
return '' if ctx.docstring.nil? || ctx.docstring.strip.empty?
|
|
80
|
+
|
|
81
|
+
"#{ctx.docstring.strip}\n\n"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Sorted list of links to child contexts.
|
|
85
|
+
def context_toc(ctx)
|
|
86
|
+
items = ctx.subcontexts.sort_by(&:name).map do |child|
|
|
87
|
+
"- [#{DocGen.humanize(child.name)}]({{ site.baseurl }}{% link #{link_path(child)} %})"
|
|
88
|
+
end.join("\n")
|
|
89
|
+
|
|
90
|
+
"### Categories\n{: .no_toc .text-delta }\n#{items}\n\n"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# just-the-docs inline TOC marker (picks up ## headings from spec sections).
|
|
94
|
+
def specification_toc
|
|
95
|
+
"### Tests\n{: .no_toc .text-delta }\n\n- TOC\n{:toc}\n\n"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# All spec sections for the context, sorted by name.
|
|
99
|
+
def specifications(ctx)
|
|
100
|
+
ctx.specs.sort_by(&:name).map { |spec| spec_section(spec) }.join("\n\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# One spec rendered as a ## section with docstring and collapsible source.
|
|
104
|
+
def spec_section(spec)
|
|
105
|
+
heading = "## #{DocGen.humanize(spec.parent.name)} #{spec.name}"
|
|
106
|
+
docstring = spec.docstring.to_s.strip
|
|
107
|
+
src = indent(spec.source.to_s)
|
|
108
|
+
|
|
109
|
+
parts = [heading]
|
|
110
|
+
parts << "\n\n#{docstring}" unless docstring.empty?
|
|
111
|
+
parts << "\n\n<details markdown=\"block\">\n " \
|
|
112
|
+
"<summary>\n " \
|
|
113
|
+
"View Source\n " \
|
|
114
|
+
"</summary>\n" \
|
|
115
|
+
"```ruby\n#{src}\n```\n" \
|
|
116
|
+
'</details>'
|
|
117
|
+
"#{parts.join}\n"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# De-indent source by the leading whitespace of the last line.
|
|
121
|
+
def indent(source)
|
|
122
|
+
lines = source.lines
|
|
123
|
+
return source if lines.empty?
|
|
124
|
+
|
|
125
|
+
n = /^(\s*)/.match(lines.last)[0].size
|
|
126
|
+
lines.map do |line|
|
|
127
|
+
i = [n, /^(\s*)/.match(line)[0].size].min
|
|
128
|
+
line[i..]
|
|
129
|
+
end.join
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Jekyll {% link %} path for a context (relative to the Jekyll site root).
|
|
133
|
+
# Matches the `tests/` output prefix used by the rake task.
|
|
134
|
+
def link_path(ctx)
|
|
135
|
+
"tests/#{ctx.output_path}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Title of the logical parent for Jekyll frontmatter.
|
|
139
|
+
# Returns "Test Suite" for root contexts (depth 0).
|
|
140
|
+
def parent_title(ctx)
|
|
141
|
+
ctx.parent ? DocGen.humanize(ctx.parent.name) : 'Test Suite'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Title of the logical grandparent for Jekyll frontmatter.
|
|
145
|
+
# Added for all non-root contexts (depth >= 1).
|
|
146
|
+
# Returns nil for root contexts.
|
|
147
|
+
def grand_parent_title(ctx)
|
|
148
|
+
return nil unless ctx.parent
|
|
149
|
+
|
|
150
|
+
parent_title(ctx.parent)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
module Validator
|
|
3
|
+
# Sus fixture module that runs each test inside the shared Async reactor.
|
|
4
|
+
# Include this in the sus base class to ensure all tests run within the reactor context.
|
|
5
|
+
module AsyncContext
|
|
6
|
+
def around
|
|
7
|
+
RSMP::Validator.reactor.run do |_task|
|
|
8
|
+
yield
|
|
9
|
+
ensure
|
|
10
|
+
RSMP::Validator.reactor.interrupt
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require 'rsmp'
|
|
2
|
+
|
|
3
|
+
module RSMP
|
|
4
|
+
module Validator
|
|
5
|
+
# Base class for automatically starting a local RSMP node (site or supervisor)
|
|
6
|
+
# when testing the validator or RSMP gem itself.
|
|
7
|
+
class AutoNode
|
|
8
|
+
include RSMP::Validator::Log
|
|
9
|
+
|
|
10
|
+
attr_reader :node, :task
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@node = nil
|
|
14
|
+
@task = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Start the auto node inside the async reactor
|
|
18
|
+
def start
|
|
19
|
+
return if @node
|
|
20
|
+
|
|
21
|
+
@node = build_node
|
|
22
|
+
|
|
23
|
+
@task = Async do |task|
|
|
24
|
+
task.annotate "auto_#{node_type}"
|
|
25
|
+
log("Starting auto #{node_type}")
|
|
26
|
+
@node.start
|
|
27
|
+
rescue Async::TimeoutError
|
|
28
|
+
raise RSMP::TimeoutError, "Timeout while starting auto #{node_type}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Stop the auto node
|
|
33
|
+
def stop
|
|
34
|
+
if @node
|
|
35
|
+
log("Stopping auto #{node_type}")
|
|
36
|
+
@node.ignore_errors RSMP::DisconnectError do
|
|
37
|
+
@node.stop
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
@task&.stop
|
|
41
|
+
ensure
|
|
42
|
+
@task = nil
|
|
43
|
+
@node = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if the auto node is running
|
|
47
|
+
def running?
|
|
48
|
+
@node && @task
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
protected
|
|
52
|
+
|
|
53
|
+
def config
|
|
54
|
+
RSMP::Validator.auto_node_config
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def node_type
|
|
58
|
+
raise NotImplementedError, 'Subclasses must implement node_type'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_node
|
|
62
|
+
raise NotImplementedError, 'Subclasses must implement build_node'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def create_logger
|
|
66
|
+
logger_settings = RSMP::Validator.node_log_settings.dup
|
|
67
|
+
logger_settings['prefix'] = default_log_prefix
|
|
68
|
+
auto_log_settings = RSMP::Validator.auto_node_log_settings
|
|
69
|
+
logger_settings.merge!(auto_log_settings) if auto_log_settings && !auto_log_settings.empty?
|
|
70
|
+
logger_settings.delete('stream') if auto_log_settings && auto_log_settings['path']
|
|
71
|
+
RSMP::Logger.new(logger_settings)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def default_log_prefix
|
|
75
|
+
case node_type
|
|
76
|
+
when 'supervisor' then '[SUPERVISOR]'
|
|
77
|
+
when 'site' then '[TLC] '
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require_relative 'auto_node'
|
|
2
|
+
|
|
3
|
+
module RSMP
|
|
4
|
+
module Validator
|
|
5
|
+
# Automatically starts a local RSMP site for testing.
|
|
6
|
+
class AutoSite < RSMP::Validator::AutoNode
|
|
7
|
+
protected
|
|
8
|
+
|
|
9
|
+
def node_type
|
|
10
|
+
'site'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build_node
|
|
14
|
+
klass = case config['type']
|
|
15
|
+
when 'tlc'
|
|
16
|
+
RSMP::TLC::TrafficControllerSite
|
|
17
|
+
else
|
|
18
|
+
RSMP::Site
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
site_settings = ConfigNormalizer.normalize_site_settings(config)
|
|
22
|
+
|
|
23
|
+
klass.new(
|
|
24
|
+
site_settings: site_settings,
|
|
25
|
+
logger: create_logger
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative 'auto_node'
|
|
2
|
+
|
|
3
|
+
module RSMP
|
|
4
|
+
module Validator
|
|
5
|
+
# Automatically starts a local RSMP supervisor for testing.
|
|
6
|
+
class AutoSupervisor < RSMP::Validator::AutoNode
|
|
7
|
+
protected
|
|
8
|
+
|
|
9
|
+
def node_type
|
|
10
|
+
'supervisor'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build_node
|
|
14
|
+
supervisor_settings = ConfigNormalizer.normalize_supervisor_settings(config)
|
|
15
|
+
|
|
16
|
+
RSMP::Supervisor.new(
|
|
17
|
+
supervisor_settings: supervisor_settings,
|
|
18
|
+
logger: create_logger
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|