shellfie 1.0.0 → 1.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 +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
require_relative "terminal_screen"
|
|
6
|
+
|
|
7
|
+
module Shellfie
|
|
8
|
+
class Session
|
|
9
|
+
attr_reader :events, :captures, :exit_status, :screen
|
|
10
|
+
|
|
11
|
+
def initialize(columns:, rows:, title: "Terminal Session", events: [], captures: {}, exit_status: nil)
|
|
12
|
+
unless columns.is_a?(Integer) && columns.between?(1, 500) && rows.is_a?(Integer) && rows.between?(1, 200)
|
|
13
|
+
raise ParseError, "Invalid session dimensions"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@title = title
|
|
17
|
+
@events = events
|
|
18
|
+
@captures = captures
|
|
19
|
+
@exit_status = exit_status
|
|
20
|
+
@screen = TerminalScreen.new(columns: columns, rows: rows)
|
|
21
|
+
events.each { |event| @screen.feed(event[:text].to_s) if event.fetch(:visible, true) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def record(text, delay: 0, visible: true, status: nil)
|
|
25
|
+
screen.feed(text) if visible
|
|
26
|
+
event = { text: visible ? text : "", delay: delay, visible: visible, status: status }
|
|
27
|
+
events << event
|
|
28
|
+
@exit_status = status unless status.nil?
|
|
29
|
+
event
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def capture(name)
|
|
33
|
+
captures[name] = screen.render_lines
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_config(theme:, options: {}, animated: false, lines: nil)
|
|
37
|
+
base = {
|
|
38
|
+
theme: theme,
|
|
39
|
+
title: @title,
|
|
40
|
+
window: options.fetch(:window, {}),
|
|
41
|
+
font: options.fetch(:font, {}),
|
|
42
|
+
animation: options.fetch(:animation, {}),
|
|
43
|
+
headless: options.fetch(:headless, false)
|
|
44
|
+
}
|
|
45
|
+
if animated
|
|
46
|
+
raise ValidationError, "Captured screens cannot be rendered as animations" if lines
|
|
47
|
+
|
|
48
|
+
frames = []
|
|
49
|
+
each_snapshot do |event, snapshot|
|
|
50
|
+
frame = Frame.new(screen: snapshot, delay: [(event[:delay].to_f * 1_000).round, 1].max)
|
|
51
|
+
frames << frame
|
|
52
|
+
end
|
|
53
|
+
Config.new(**base, frames: frames)
|
|
54
|
+
else
|
|
55
|
+
Config.new(**base, lines: (lines || screen.render_lines).map { |line| Line.new(output: line) })
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def compose_hash
|
|
60
|
+
frames = []
|
|
61
|
+
each_snapshot do |event, snapshot|
|
|
62
|
+
frames << {
|
|
63
|
+
"screen" => snapshot,
|
|
64
|
+
"delay" => [(event[:delay].to_f * 1_000).round, 1].max
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
{
|
|
68
|
+
"version" => 1,
|
|
69
|
+
"title" => @title,
|
|
70
|
+
"window" => { "width" => screen.columns * 8, "visible_lines" => screen.rows },
|
|
71
|
+
"frames" => frames
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
version: 1,
|
|
78
|
+
title: @title,
|
|
79
|
+
columns: screen.columns,
|
|
80
|
+
rows: screen.rows,
|
|
81
|
+
events: events,
|
|
82
|
+
captures: captures,
|
|
83
|
+
exit_status: exit_status
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def each_snapshot
|
|
90
|
+
replay = TerminalScreen.new(columns: screen.columns, rows: screen.rows)
|
|
91
|
+
count = characters = 0
|
|
92
|
+
events.each do |event|
|
|
93
|
+
next unless event[:visible]
|
|
94
|
+
|
|
95
|
+
text = event[:text].to_s
|
|
96
|
+
next if text.empty? && !event[:delay].to_f.positive?
|
|
97
|
+
|
|
98
|
+
count += 1
|
|
99
|
+
raise ResourceLimitError, "Too many session frames (max #{Config::DEFAULTS[:limits][:max_frames]})" if count > Config::DEFAULTS[:limits][:max_frames]
|
|
100
|
+
|
|
101
|
+
replay.feed(text) unless text.empty?
|
|
102
|
+
snapshot = event[:screen] || replay.render_lines
|
|
103
|
+
characters += snapshot.sum(&:length)
|
|
104
|
+
if characters > Config::DEFAULTS[:limits][:max_characters]
|
|
105
|
+
raise ResourceLimitError, "Session snapshots are too large (max #{Config::DEFAULTS[:limits][:max_characters]} characters)"
|
|
106
|
+
end
|
|
107
|
+
yield event, snapshot
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "did_you_mean"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "rubygems/requirement"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "parser_validation"
|
|
10
|
+
require_relative "yaml_safety"
|
|
11
|
+
|
|
12
|
+
module Shellfie
|
|
13
|
+
class SessionConfig
|
|
14
|
+
MAX_BYTES = 1_048_576
|
|
15
|
+
MAX_DURATION = 86_400
|
|
16
|
+
MAX_COUNT = 10_000
|
|
17
|
+
MAX_CAPTURES = 100
|
|
18
|
+
MAX_PATTERN_LENGTH = 512
|
|
19
|
+
MAX_INCLUDE_FILES = 100
|
|
20
|
+
MAX_TOTAL_BYTES = 10 * MAX_BYTES
|
|
21
|
+
ACTIONS = %i[run type key sleep wait expect capture hide show].freeze
|
|
22
|
+
STEP_OPTION_KEYS = {
|
|
23
|
+
run: %i[visibility timeout async cwd], type: %i[speed], key: %i[count async timeout delay],
|
|
24
|
+
sleep: [], wait: %i[timeout], expect: [], capture: [], hide: [], show: []
|
|
25
|
+
}.freeze
|
|
26
|
+
TOP_LEVEL_KEYS = %i[version mode title theme terminal requires steps outputs render redact vars step_sets].freeze
|
|
27
|
+
TERMINAL_KEYS = %i[shell columns rows cwd cwd_policy env env_allowlist timeout total_timeout prompt].freeze
|
|
28
|
+
OUTPUT_KEYS = %i[path format animate scale shadow transparent capture].freeze
|
|
29
|
+
OUTPUT_FORMATS = %w[png gif svg svg-raster webp apng mp4 webm png-sequence html txt ansi json asciicast cast].freeze
|
|
30
|
+
RENDER_KEYS = %i[window font animation headless].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :path, :source_paths, :mode, :title, :theme, :terminal, :requires, :steps, :outputs, :render, :redactions
|
|
33
|
+
|
|
34
|
+
def self.parse(path)
|
|
35
|
+
source_path = File.realpath(path)
|
|
36
|
+
state = { files: 0, bytes: 0 }
|
|
37
|
+
raw, documents, provenance = load_included(source_path, root: File.dirname(source_path), stack: [], state: state)
|
|
38
|
+
new(raw, path: source_path, source_paths: documents.map(&:first).uniq)
|
|
39
|
+
rescue ValidationError => e
|
|
40
|
+
raise YamlSafety.annotate_validation_error(e, documents || [], provenance: provenance || {})
|
|
41
|
+
rescue Psych::Exception => e
|
|
42
|
+
raise ParseError, "Invalid session YAML syntax: #{e.message}"
|
|
43
|
+
rescue Errno::ENOENT
|
|
44
|
+
raise ParseError, "Session file not found: #{path}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.load_included(path, root:, stack:, state:, policy: nil)
|
|
48
|
+
raise ParseError, "Circular session include: #{(stack + [path]).map { |item| File.basename(item) }.join(' -> ')}" if stack.include?(path)
|
|
49
|
+
|
|
50
|
+
content = YamlSafety.read_file(path, max_bytes: MAX_BYTES, label: "Session")
|
|
51
|
+
state[:files] += 1
|
|
52
|
+
state[:bytes] += content.bytesize
|
|
53
|
+
raise ParseError, "Too many session includes (max #{MAX_INCLUDE_FILES})" if state[:files] > MAX_INCLUDE_FILES
|
|
54
|
+
raise ParseError, "Included sessions are too large in total (max #{MAX_TOTAL_BYTES} bytes)" if state[:bytes] > MAX_TOTAL_BYTES
|
|
55
|
+
|
|
56
|
+
raw = YAML.safe_load(content, symbolize_names: true, aliases: true)
|
|
57
|
+
YamlSafety.validate_tree!(raw)
|
|
58
|
+
raise ParseError, "Included session must be a YAML mapping: #{path}" unless raw.is_a?(Hash)
|
|
59
|
+
own_provenance = provenance_for(raw, path)
|
|
60
|
+
return [raw, [[path, content]], own_provenance] unless raw[:include]
|
|
61
|
+
|
|
62
|
+
declared_policy = raw[:include_policy]
|
|
63
|
+
if raw.key?(:include_policy) && !%w[allow root].include?(declared_policy)
|
|
64
|
+
raise ParseError, "include_policy must be allow or root"
|
|
65
|
+
end
|
|
66
|
+
policy = "root" if policy == "root" || declared_policy == "root"
|
|
67
|
+
policy ||= declared_policy || "allow"
|
|
68
|
+
|
|
69
|
+
merged = {}
|
|
70
|
+
provenance = {}
|
|
71
|
+
documents = [[path, content]]
|
|
72
|
+
Array(raw[:include]).each do |included|
|
|
73
|
+
raise ParseError, "Included session path must be a string" unless included.is_a?(String)
|
|
74
|
+
|
|
75
|
+
included_path = File.realpath(File.expand_path(included, File.dirname(path)))
|
|
76
|
+
if policy == "root" && included_path != root && !included_path.start_with?("#{root}#{File::SEPARATOR}")
|
|
77
|
+
raise ParseError, "Included session escapes the session root: #{included}"
|
|
78
|
+
end
|
|
79
|
+
value, nested_documents, nested_provenance = load_included(
|
|
80
|
+
included_path, root: root, stack: stack + [path], state: state, policy: policy
|
|
81
|
+
)
|
|
82
|
+
merged, provenance = merge_included(merged, value, provenance, nested_provenance)
|
|
83
|
+
documents.concat(nested_documents)
|
|
84
|
+
end
|
|
85
|
+
own = raw.reject { |key, _value| %i[include include_policy].include?(key) }
|
|
86
|
+
own_provenance.reject! { |key, _value| %i[include include_policy].include?(key.first) }
|
|
87
|
+
merged, provenance = merge_included(merged, own, provenance, own_provenance)
|
|
88
|
+
[merged, documents, provenance]
|
|
89
|
+
rescue Errno::ENOENT
|
|
90
|
+
raise ParseError, "Included session file not found from #{path}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.merge_included(base, overrides, base_provenance = {}, override_provenance = {}, prefix = [])
|
|
94
|
+
merged = base.dup
|
|
95
|
+
provenance = base_provenance.dup
|
|
96
|
+
overrides.each do |key, right|
|
|
97
|
+
left = base[key]
|
|
98
|
+
target = prefix + [key]
|
|
99
|
+
if %i[steps requires outputs redact].include?(key)
|
|
100
|
+
offset = Array(left).size
|
|
101
|
+
merged[key] = Array(left) + Array(right)
|
|
102
|
+
copy_provenance!(provenance, override_provenance, target) do |path|
|
|
103
|
+
path.size > target.size && path[target.size].is_a?(Integer) ? target + [path[target.size] + offset] + path[(target.size + 1)..] : path
|
|
104
|
+
end
|
|
105
|
+
elsif left.is_a?(Hash) && right.is_a?(Hash)
|
|
106
|
+
merged[key], provenance = merge_included(left, right, provenance, override_provenance, target)
|
|
107
|
+
else
|
|
108
|
+
merged[key] = right
|
|
109
|
+
provenance.delete_if { |path, _value| path[0, target.size] == target }
|
|
110
|
+
copy_provenance!(provenance, override_provenance, target)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
[merged, provenance]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.provenance_for(value, source, path = [], result = {})
|
|
117
|
+
result[path] = [source, path]
|
|
118
|
+
case value
|
|
119
|
+
when Hash
|
|
120
|
+
value.each { |key, nested| provenance_for(nested, source, path + [key], result) }
|
|
121
|
+
when Array
|
|
122
|
+
value.each_with_index { |nested, index| provenance_for(nested, source, path + [index], result) }
|
|
123
|
+
end
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.copy_provenance!(target_map, source_map, prefix)
|
|
128
|
+
source_map.each do |path, source|
|
|
129
|
+
next unless path[0, prefix.size] == prefix
|
|
130
|
+
|
|
131
|
+
target_map[block_given? ? yield(path) : path] = source
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private_class_method :load_included, :merge_included, :provenance_for, :copy_provenance!
|
|
136
|
+
|
|
137
|
+
def initialize(raw, path: nil, source_paths: nil)
|
|
138
|
+
raise ValidationError, "Session configuration must be a YAML mapping" unless raw.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
unknown = raw.keys - TOP_LEVEL_KEYS
|
|
141
|
+
raise_unknown_keys!(unknown, TOP_LEVEL_KEYS, "session")
|
|
142
|
+
variables = validate_variables(raw[:vars] || {})
|
|
143
|
+
raw = interpolate_variables(raw.reject { |key, _value| key == :vars }, variables)
|
|
144
|
+
raise ValidationError, "Session config version must be 2" unless raw[:version] == 2
|
|
145
|
+
raise ValidationError, "Session config must contain steps" unless raw.key?(:steps)
|
|
146
|
+
raise ValidationError, "Session title must be a string" if raw.key?(:title) && !raw[:title].is_a?(String)
|
|
147
|
+
|
|
148
|
+
@path = path
|
|
149
|
+
@source_paths = Array(source_paths || path).compact.freeze
|
|
150
|
+
@mode = (raw[:mode] || "run").to_s
|
|
151
|
+
@title = (raw[:title] || "Terminal Session").to_s
|
|
152
|
+
@theme = (raw[:theme] || "macos").to_s
|
|
153
|
+
@terminal = defaults.merge(symbolize_hash(raw[:terminal] || {}))
|
|
154
|
+
@requires = Array(raw[:requires])
|
|
155
|
+
raise ValidationError, "steps must be an array" unless raw[:steps].is_a?(Array)
|
|
156
|
+
@steps = expand_steps(raw[:steps], validate_step_sets(raw[:step_sets] || {}))
|
|
157
|
+
@outputs = Array(raw[:outputs]).map { |output| symbolize_hash(output) }
|
|
158
|
+
@render = symbolize_hash(raw[:render] || {})
|
|
159
|
+
%i[window font animation].each do |key|
|
|
160
|
+
@render[key] = symbolize_hash(@render[key]) if @render[key].is_a?(Hash)
|
|
161
|
+
end
|
|
162
|
+
@redactions = Array(raw[:redact])
|
|
163
|
+
validate!
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def base_dir
|
|
167
|
+
path ? File.dirname(path) : Dir.pwd
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def to_h
|
|
171
|
+
{
|
|
172
|
+
version: 2,
|
|
173
|
+
mode: mode,
|
|
174
|
+
title: title,
|
|
175
|
+
theme: theme,
|
|
176
|
+
terminal: terminal,
|
|
177
|
+
requires: requires,
|
|
178
|
+
steps: steps,
|
|
179
|
+
outputs: outputs,
|
|
180
|
+
render: render,
|
|
181
|
+
redact: redactions
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def defaults
|
|
188
|
+
{
|
|
189
|
+
shell: ENV.fetch("SHELL", "/bin/sh"),
|
|
190
|
+
columns: 100,
|
|
191
|
+
rows: 28,
|
|
192
|
+
cwd: ".",
|
|
193
|
+
cwd_policy: "allow",
|
|
194
|
+
env: {},
|
|
195
|
+
env_allowlist: nil,
|
|
196
|
+
timeout: 30,
|
|
197
|
+
total_timeout: nil,
|
|
198
|
+
prompt: "$ "
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def symbolize_hash(value)
|
|
203
|
+
raise ValidationError, "Expected a mapping, got #{value.class}" unless value.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
value.each_with_object({}) do |(key, nested), result|
|
|
206
|
+
raise ValidationError, "Mapping keys must be strings or symbols" unless key.respond_to?(:to_sym)
|
|
207
|
+
|
|
208
|
+
result[key.to_sym] = nested
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_variables(value)
|
|
213
|
+
raise ValidationError, "vars must be a mapping" unless value.is_a?(Hash)
|
|
214
|
+
raise ValidationError, "vars may contain at most 100 entries" if value.size > 100
|
|
215
|
+
|
|
216
|
+
value.to_h do |name, nested|
|
|
217
|
+
key = name.to_s
|
|
218
|
+
raise ValidationError, "Variable names must use letters, numbers, and underscores" unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
219
|
+
unless nested.is_a?(String) || nested.is_a?(Numeric) || [true, false, nil].include?(nested)
|
|
220
|
+
raise ValidationError, "vars.#{key} must be a scalar"
|
|
221
|
+
end
|
|
222
|
+
raise ValidationError, "vars.#{key} is too large" if nested.to_s.bytesize > 4_096
|
|
223
|
+
|
|
224
|
+
[key, nested]
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def interpolate_variables(value, variables)
|
|
229
|
+
case value
|
|
230
|
+
when Hash
|
|
231
|
+
value.to_h { |key, nested| [key, interpolate_variables(nested, variables)] }
|
|
232
|
+
when Array
|
|
233
|
+
value.map { |nested| interpolate_variables(nested, variables) }
|
|
234
|
+
when String
|
|
235
|
+
if (match = /\A\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}\z/.match(value))
|
|
236
|
+
return variable_value(match[1], variables)
|
|
237
|
+
end
|
|
238
|
+
value.gsub(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/) { variable_value(Regexp.last_match(1), variables).to_s }
|
|
239
|
+
else
|
|
240
|
+
value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def variable_value(name, variables)
|
|
245
|
+
raise ValidationError, "Undefined variable: #{name}" unless variables.key?(name)
|
|
246
|
+
|
|
247
|
+
variables[name]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def validate_step_sets(value)
|
|
251
|
+
raise ValidationError, "step_sets must be a mapping" unless value.is_a?(Hash)
|
|
252
|
+
raise ValidationError, "step_sets may contain at most 100 entries" if value.size > 100
|
|
253
|
+
|
|
254
|
+
value.to_h do |name, entries|
|
|
255
|
+
key = name.to_s
|
|
256
|
+
raise ValidationError, "Step set names must use letters, numbers, and underscores" unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
257
|
+
raise ValidationError, "step_sets.#{key} must be an array" unless entries.is_a?(Array)
|
|
258
|
+
|
|
259
|
+
[key, entries]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def expand_steps(entries, sets, stack = [])
|
|
264
|
+
entries.each_with_object([]) do |entry, result|
|
|
265
|
+
step = normalize_step(entry)
|
|
266
|
+
condition = step.is_a?(Hash) && step.delete(:if)
|
|
267
|
+
next unless condition.nil? || condition_matches?(condition)
|
|
268
|
+
|
|
269
|
+
repeat = step.is_a?(Hash) ? step.delete(:repeat) || 1 : 1
|
|
270
|
+
unless repeat.is_a?(Integer) && repeat.between?(1, MAX_COUNT)
|
|
271
|
+
raise ValidationError, "step repeat must be between 1 and #{MAX_COUNT}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
expanded = if step.is_a?(Hash) && step.key?(:use)
|
|
275
|
+
raise ValidationError, "A reusable step may contain only use and repeat" unless step.keys == [:use]
|
|
276
|
+
name = step[:use].to_s
|
|
277
|
+
raise ValidationError, "Unknown step set: #{name}" unless sets.key?(name)
|
|
278
|
+
raise ValidationError, "Circular step set: #{(stack + [name]).join(' -> ')}" if stack.include?(name)
|
|
279
|
+
|
|
280
|
+
expand_steps(sets[name], sets, stack + [name])
|
|
281
|
+
else
|
|
282
|
+
[step]
|
|
283
|
+
end
|
|
284
|
+
repeat.times { result.concat(Config.deep_dup(expanded)) }
|
|
285
|
+
raise ValidationError, "Session has too many expanded steps (max 10,000)" if result.size > 10_000
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def condition_matches?(value)
|
|
290
|
+
condition = symbolize_hash(value)
|
|
291
|
+
validate_mapping_keys!(condition, %i[os shell ruby env], "step.if")
|
|
292
|
+
raise ValidationError, "step.if must contain a condition" if condition.empty?
|
|
293
|
+
|
|
294
|
+
matches = []
|
|
295
|
+
if condition.key?(:os)
|
|
296
|
+
systems = Array(condition[:os]).map(&:to_s)
|
|
297
|
+
raise ValidationError, "step.if.os must be macos, linux, or windows" unless (systems - %w[macos linux windows]).empty?
|
|
298
|
+
matches << systems.include?(host_os)
|
|
299
|
+
end
|
|
300
|
+
if condition.key?(:shell)
|
|
301
|
+
raise ValidationError, "step.if.shell must be a string" unless condition[:shell].is_a?(String)
|
|
302
|
+
matches << File.basename(terminal[:shell]) == condition[:shell]
|
|
303
|
+
end
|
|
304
|
+
if condition.key?(:ruby)
|
|
305
|
+
raise ValidationError, "step.if.ruby must be a requirement string" unless condition[:ruby].is_a?(String)
|
|
306
|
+
matches << Gem::Requirement.new(condition[:ruby]).satisfied_by?(Gem::Version.new(RUBY_VERSION))
|
|
307
|
+
end
|
|
308
|
+
if condition.key?(:env)
|
|
309
|
+
raise ValidationError, "step.if.env must be a mapping" unless condition[:env].is_a?(Hash)
|
|
310
|
+
configured = terminal[:env].transform_keys(&:to_s)
|
|
311
|
+
matches << condition[:env].all? { |name, expected| configured[name.to_s] == expected }
|
|
312
|
+
end
|
|
313
|
+
matches.all?
|
|
314
|
+
rescue Gem::Requirement::BadRequirementError => e
|
|
315
|
+
raise ValidationError, "Invalid step.if.ruby requirement: #{e.message}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def host_os
|
|
319
|
+
value = RbConfig::CONFIG["host_os"]
|
|
320
|
+
return "windows" if value.match?(/mswin|mingw|cygwin/)
|
|
321
|
+
return "macos" if value.include?("darwin")
|
|
322
|
+
|
|
323
|
+
"linux"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def normalize_step(step)
|
|
327
|
+
return { step.to_sym => true } if step.is_a?(String) && %w[hide show].include?(step)
|
|
328
|
+
|
|
329
|
+
symbolize_hash(step)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def validate!
|
|
333
|
+
raise ValidationError, "mode must be run or replay" unless %w[run replay].include?(mode)
|
|
334
|
+
validate_mapping_keys!(terminal, TERMINAL_KEYS, "terminal")
|
|
335
|
+
raise ValidationError, "terminal.shell must be a string" unless terminal[:shell].is_a?(String)
|
|
336
|
+
%i[columns rows].each do |key|
|
|
337
|
+
raise ValidationError, "terminal.#{key} must be a positive integer" unless terminal[key].is_a?(Integer) && terminal[key].positive?
|
|
338
|
+
end
|
|
339
|
+
raise ValidationError, "terminal.columns must be at most 500" if terminal[:columns] > 500
|
|
340
|
+
raise ValidationError, "terminal.rows must be at most 200" if terminal[:rows] > 200
|
|
341
|
+
raise ValidationError, "terminal.cwd must be a string" unless terminal[:cwd].is_a?(String)
|
|
342
|
+
raise ValidationError, "terminal.cwd_policy must be allow or root" unless %w[allow root].include?(terminal[:cwd_policy])
|
|
343
|
+
raise ValidationError, "terminal.prompt must be a string" unless terminal[:prompt].is_a?(String)
|
|
344
|
+
raise ValidationError, "terminal.prompt must not be blank" if terminal[:prompt].strip.empty?
|
|
345
|
+
raise ValidationError, "terminal.env must be a mapping" unless terminal[:env].is_a?(Hash)
|
|
346
|
+
unless terminal[:env].all? { |key, value| key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) && (value.nil? || value.is_a?(String)) }
|
|
347
|
+
raise ValidationError, "terminal.env keys must be names and values must be strings or null"
|
|
348
|
+
end
|
|
349
|
+
raise ValidationError, "terminal.env.PS1 is managed by terminal.prompt" if terminal[:env].keys.any? { |key| key.to_s == "PS1" }
|
|
350
|
+
unless terminal[:env_allowlist].nil?
|
|
351
|
+
unless terminal[:env_allowlist].is_a?(Array) && terminal[:env_allowlist].all? { |name| name.is_a?(String) && name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) }
|
|
352
|
+
raise ValidationError, "terminal.env_allowlist must contain environment variable names"
|
|
353
|
+
end
|
|
354
|
+
raise ValidationError, "terminal.env_allowlist must not contain duplicates" unless terminal[:env_allowlist].uniq.size == terminal[:env_allowlist].size
|
|
355
|
+
disallowed = terminal[:env].keys.map(&:to_s) - terminal[:env_allowlist]
|
|
356
|
+
raise ValidationError, "terminal.env contains variables outside env_allowlist: #{disallowed.join(', ')}" unless disallowed.empty?
|
|
357
|
+
end
|
|
358
|
+
raise ValidationError, "terminal.timeout must be positive" unless duration(terminal[:timeout]).positive?
|
|
359
|
+
duration(terminal[:total_timeout]) unless terminal[:total_timeout].nil?
|
|
360
|
+
raise ValidationError, "requires must contain command names" unless requires.all? { |item| item.is_a?(String) && item.match?(/\A[\w.+-]+\z/) }
|
|
361
|
+
raise ValidationError, "Session has too many steps (max 10,000)" if steps.size > 10_000
|
|
362
|
+
|
|
363
|
+
steps.each_with_index { |step, index| validate_step!(step, index) }
|
|
364
|
+
expanded_events = steps.sum do |step|
|
|
365
|
+
action = (step.keys & ACTIONS).first
|
|
366
|
+
case action
|
|
367
|
+
when :run
|
|
368
|
+
step.fetch(:visibility, step[:async] ? "visible" : "hidden") == "visible" ? 1 : 0
|
|
369
|
+
when :type then 1
|
|
370
|
+
when :key then step[:delay] ? Integer(step[:count] || 1) : 1
|
|
371
|
+
when :wait then step[:wait].is_a?(Hash) && (step[:wait][:exit] || step[:wait]["exit"]) ? 1 : 0
|
|
372
|
+
else 0
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
max_events = Config::DEFAULTS[:limits][:max_frames]
|
|
376
|
+
raise ValidationError, "Session expands to too many events (max #{max_events})" if expanded_events > max_events
|
|
377
|
+
capture_names = steps.filter_map { |step| step[:capture] }
|
|
378
|
+
raise ValidationError, "Too many captures (max #{MAX_CAPTURES})" if capture_names.size > MAX_CAPTURES
|
|
379
|
+
raise ValidationError, "Capture names must be unique" unless capture_names.uniq.size == capture_names.size
|
|
380
|
+
outputs.each_with_index do |output, index|
|
|
381
|
+
validate_mapping_keys!(output, OUTPUT_KEYS, "outputs[#{index}]")
|
|
382
|
+
raise ValidationError, "outputs[#{index}].path must be a string" unless output[:path].is_a?(String)
|
|
383
|
+
if output.key?(:capture) && !output[:capture].is_a?(String)
|
|
384
|
+
raise ValidationError, "outputs[#{index}].capture must be a string"
|
|
385
|
+
end
|
|
386
|
+
if output[:capture] && !capture_names.include?(output[:capture])
|
|
387
|
+
raise ValidationError, "outputs[#{index}] references unknown capture: #{output[:capture]}"
|
|
388
|
+
end
|
|
389
|
+
if output[:format] && !OUTPUT_FORMATS.include?(output[:format].to_s)
|
|
390
|
+
raise ValidationError, "outputs[#{index}].format is unsupported"
|
|
391
|
+
end
|
|
392
|
+
if output[:scale] && (!output[:scale].is_a?(Integer) || !output[:scale].between?(1, 3))
|
|
393
|
+
raise ValidationError, "outputs[#{index}].scale must be between 1 and 3"
|
|
394
|
+
end
|
|
395
|
+
%i[animate shadow transparent].each do |key|
|
|
396
|
+
if output.key?(key) && ![true, false].include?(output[key])
|
|
397
|
+
raise ValidationError, "outputs[#{index}].#{key} must be true or false"
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
raise ValidationError, "Too many redaction patterns (max 100)" if redactions.size > 100
|
|
402
|
+
if redactions.any? { |pattern| pattern.to_s.length > MAX_PATTERN_LENGTH }
|
|
403
|
+
raise ValidationError, "Redaction patterns must be at most #{MAX_PATTERN_LENGTH} characters"
|
|
404
|
+
end
|
|
405
|
+
raise ValidationError, "Redaction patterns must be strings" unless redactions.all?(String)
|
|
406
|
+
redactions.each { |pattern| Regexp.new(pattern) }
|
|
407
|
+
validate_mapping_keys!(render, RENDER_KEYS, "render")
|
|
408
|
+
%i[window font animation].each do |key|
|
|
409
|
+
raise ValidationError, "render.#{key} must be a mapping" if render.key?(key) && !render[key].is_a?(Hash)
|
|
410
|
+
end
|
|
411
|
+
{
|
|
412
|
+
window: ParserValidation::WINDOW_KEYS,
|
|
413
|
+
font: ParserValidation::FONT_KEYS,
|
|
414
|
+
animation: ParserValidation::ANIMATION_KEYS
|
|
415
|
+
}.each do |key, allowed|
|
|
416
|
+
validate_mapping_keys!(render[key], allowed, "render.#{key}") if render[key]
|
|
417
|
+
end
|
|
418
|
+
if render.key?(:headless) && ![true, false].include?(render[:headless])
|
|
419
|
+
raise ValidationError, "render.headless must be true or false"
|
|
420
|
+
end
|
|
421
|
+
Config.new(
|
|
422
|
+
theme: theme,
|
|
423
|
+
window: render[:window] || {},
|
|
424
|
+
font: render[:font] || {},
|
|
425
|
+
animation: render[:animation] || {},
|
|
426
|
+
headless: render.fetch(:headless, false)
|
|
427
|
+
)
|
|
428
|
+
rescue RegexpError => e
|
|
429
|
+
raise ValidationError, "Invalid redaction pattern: #{e.message}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def validate_step!(step, index)
|
|
433
|
+
actions = step.keys & ACTIONS
|
|
434
|
+
raise ValidationError, "steps[#{index}] must contain exactly one action" unless actions.size == 1
|
|
435
|
+
|
|
436
|
+
allowed = actions + STEP_OPTION_KEYS.fetch(actions.first)
|
|
437
|
+
validate_mapping_keys!(step, allowed, "steps[#{index}]")
|
|
438
|
+
action = actions.first
|
|
439
|
+
value = step[action]
|
|
440
|
+
if %i[run type key capture].include?(action) && !value.is_a?(String)
|
|
441
|
+
raise ValidationError, "steps[#{index}].#{action} must be a string"
|
|
442
|
+
end
|
|
443
|
+
if %i[wait expect].include?(action) && !value.is_a?(Hash) && !value.is_a?(String)
|
|
444
|
+
raise ValidationError, "steps[#{index}].#{action} must be a string or mapping"
|
|
445
|
+
end
|
|
446
|
+
duration(step[:timeout]) if step.key?(:timeout)
|
|
447
|
+
duration(step[:delay]) if step.key?(:delay)
|
|
448
|
+
duration(value) if action == :sleep
|
|
449
|
+
if step.key?(:visibility) && !%w[visible hidden].include?(step[:visibility])
|
|
450
|
+
raise ValidationError, "steps[#{index}].visibility must be visible or hidden"
|
|
451
|
+
end
|
|
452
|
+
if step.key?(:count) && (!step[:count].is_a?(Integer) || !step[:count].between?(1, MAX_COUNT))
|
|
453
|
+
raise ValidationError, "steps[#{index}].count must be between 1 and #{MAX_COUNT}"
|
|
454
|
+
end
|
|
455
|
+
if step.key?(:async) && ![true, false].include?(step[:async])
|
|
456
|
+
raise ValidationError, "steps[#{index}].async must be true or false"
|
|
457
|
+
end
|
|
458
|
+
if step.key?(:cwd) && !step[:cwd].is_a?(String)
|
|
459
|
+
raise ValidationError, "steps[#{index}].cwd must be a string"
|
|
460
|
+
end
|
|
461
|
+
if action == :run && step[:async] && step.fetch(:visibility, "visible") == "hidden"
|
|
462
|
+
raise ValidationError, "steps[#{index}] cannot hide an asynchronous run"
|
|
463
|
+
end
|
|
464
|
+
if %i[hide show].include?(action) && value != true
|
|
465
|
+
raise ValidationError, "steps[#{index}].#{action} must be true"
|
|
466
|
+
end
|
|
467
|
+
if step.key?(:speed)
|
|
468
|
+
speed_text = step[:speed].to_s
|
|
469
|
+
speed = /\A(\d+(?:\.\d+)?)cps\z/.match(speed_text)&.[](1)&.to_f if speed_text.bytesize <= 32
|
|
470
|
+
raise ValidationError, "steps[#{index}].speed must be between 1cps and 1000cps" unless speed&.between?(1, 1_000)
|
|
471
|
+
end
|
|
472
|
+
validate_wait!(value, index) if action == :wait && value.is_a?(Hash)
|
|
473
|
+
validate_pattern_size!(value, "steps[#{index}].wait") if action == :wait && value.is_a?(String)
|
|
474
|
+
validate_expect!(value, index) if action == :expect && value.is_a?(Hash)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def validate_wait!(value, index)
|
|
478
|
+
condition = symbolize_hash(value)
|
|
479
|
+
validate_mapping_keys!(condition, %i[screen line prompt stable exit timeout], "steps[#{index}].wait")
|
|
480
|
+
predicates = condition.keys & %i[screen line prompt stable exit]
|
|
481
|
+
raise ValidationError, "steps[#{index}].wait must contain one condition" unless predicates.size == 1
|
|
482
|
+
|
|
483
|
+
duration(condition[:stable]) if condition.key?(:stable)
|
|
484
|
+
duration(condition[:timeout]) if condition.key?(:timeout)
|
|
485
|
+
if condition.key?(:exit) && condition[:exit] != true
|
|
486
|
+
raise ValidationError, "steps[#{index}].wait.exit must be true"
|
|
487
|
+
end
|
|
488
|
+
if condition.key?(:prompt) && condition[:prompt] != true
|
|
489
|
+
raise ValidationError, "steps[#{index}].wait.prompt must be true"
|
|
490
|
+
end
|
|
491
|
+
validate_pattern_size!(condition[:screen] || condition[:line], "steps[#{index}].wait")
|
|
492
|
+
%i[screen line].each do |key|
|
|
493
|
+
if condition.key?(key) && !condition[key].is_a?(String)
|
|
494
|
+
raise ValidationError, "steps[#{index}].wait.#{key} must be a string"
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def validate_expect!(value, index)
|
|
500
|
+
condition = symbolize_hash(value)
|
|
501
|
+
validate_mapping_keys!(condition, %i[screen_contains screen line exit_status cursor_row cursor_column golden elapsed_under elapsed_over], "steps[#{index}].expect")
|
|
502
|
+
raise ValidationError, "steps[#{index}].expect must contain a condition" if condition.empty?
|
|
503
|
+
if condition.key?(:exit_status) && (!condition[:exit_status].is_a?(Integer) || !condition[:exit_status].between?(0, 255))
|
|
504
|
+
raise ValidationError, "steps[#{index}].expect.exit_status must be between 0 and 255"
|
|
505
|
+
end
|
|
506
|
+
%i[cursor_row cursor_column].each do |key|
|
|
507
|
+
next unless condition.key?(key)
|
|
508
|
+
unless condition[key].is_a?(Integer) && condition[key] >= 0
|
|
509
|
+
raise ValidationError, "steps[#{index}].expect.#{key} must be a non-negative integer"
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
validate_pattern_size!(condition[:screen], "steps[#{index}].expect")
|
|
513
|
+
validate_pattern_size!(condition[:line], "steps[#{index}].expect")
|
|
514
|
+
%i[screen line screen_contains].each do |key|
|
|
515
|
+
if condition.key?(key) && !condition[key].is_a?(String)
|
|
516
|
+
raise ValidationError, "steps[#{index}].expect.#{key} must be a string"
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
if condition.key?(:golden) && !condition[:golden].is_a?(String)
|
|
520
|
+
raise ValidationError, "steps[#{index}].expect.golden must be a string"
|
|
521
|
+
end
|
|
522
|
+
%i[elapsed_under elapsed_over].each { |key| duration(condition[key]) if condition.key?(key) }
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def validate_mapping_keys!(hash, allowed, context)
|
|
526
|
+
unknown = hash.keys - allowed
|
|
527
|
+
raise_unknown_keys!(unknown, allowed, context)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def raise_unknown_keys!(unknown, allowed, context)
|
|
531
|
+
return if unknown.empty?
|
|
532
|
+
|
|
533
|
+
suggestions = unknown.filter_map do |key|
|
|
534
|
+
match = DidYouMean::SpellChecker.new(dictionary: allowed.map(&:to_s)).correct(key.to_s).first
|
|
535
|
+
"#{key} -> #{match}" if match
|
|
536
|
+
end
|
|
537
|
+
hint = suggestions.empty? ? "" : " (did you mean #{suggestions.join(", ")}?)"
|
|
538
|
+
raise ValidationError, "Unknown #{context} key(s): #{unknown.join(", ")}#{hint}"
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def duration(value)
|
|
542
|
+
match = /\A(\d+(?:\.\d+)?)(ms|s)?\z/.match(value.to_s)
|
|
543
|
+
raise ValidationError, "Invalid duration: #{value}" unless match
|
|
544
|
+
if match[1].bytesize > 32
|
|
545
|
+
raise ValidationError, "Duration must be finite and at most #{MAX_DURATION}s"
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
seconds = match[1].to_f
|
|
549
|
+
seconds /= 1_000 if match[2] == "ms"
|
|
550
|
+
unless seconds.finite? && seconds.positive? && seconds <= MAX_DURATION
|
|
551
|
+
raise ValidationError, "Duration must be greater than 0 and at most #{MAX_DURATION}s"
|
|
552
|
+
end
|
|
553
|
+
seconds
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def validate_pattern_size!(pattern, context)
|
|
557
|
+
return unless pattern && pattern.to_s.length > MAX_PATTERN_LENGTH
|
|
558
|
+
|
|
559
|
+
raise ValidationError, "#{context} pattern must be at most #{MAX_PATTERN_LENGTH} characters"
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|