bond-spy 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.yardopts +1 -0
- data/Gemfile +4 -0
- data/LICENSE +27 -0
- data/README.rst +65 -0
- data/Rakefile +6 -0
- data/bin/bond_reconcile.py +385 -0
- data/bin/setup +5 -0
- data/bond.gemspec +36 -0
- data/lib/bond.rb +469 -0
- data/lib/bond/spec_helper.rb +32 -0
- data/lib/bond/targetable.rb +210 -0
- data/lib/bond/version.rb +3 -0
- data/spec/bond_spec.rb +158 -0
- data/spec/bond_targetable_spec.rb +202 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/test_observations/.gitignore +2 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_call_doers_before_returning_result.json +14 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_call_the_function_passed_as_result_if_it_is_callable.json +23 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_a_single_doer_if_filter_criteria_are_met.json +10 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_multiple_doers.json +13 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_not_call_doers_of_overriden_agents.json +8 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_override_old_agents_with_newer_agents.json +0 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_an_exception_if_specified_by_agent.json +9 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_the_result_of_the_value_passed_to_exception_if_callable.json +10 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_work_with_multiple_agents_for_different_spy_points.json +23 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_override_old_agents_with_newer_agents_unless_theott8glo1xn.json +22 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_combinations_of_filters.json +37 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_function_filters.json +16 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_single_key_value_filters_of_all_types.json +121 -0
- data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_nested_hashes_and_arrays_with_hash_sorting.json +31 -0
- data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_with_a_spy_point_name.json +12 -0
- data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_without_a_spy_point_name.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_continue_is_returned.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_none_is_returned.json +14 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_passes_through_blocks.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_returns_nil__and_mocks__when_an_agent_returns_nil.json +11 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_private_methods.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_protected_methods.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_class_method.json +7 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_method_with_variabl19nhijeqoo.json +13 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_a6qc3d4el92.json +33 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_aott8glo1xn.json +20 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_required_andbcgjq06had.json +17 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_normal_method.json +7 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_all_optional_keyword_arguments.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_variable_keyword_arguments.json +22 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_changes_the_spy_point_naj4gnwvcu8n.json +5 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_errors_when_mocking_is_r9j7wklng0z.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_ignores_excluded_keys.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_mocks_when_one_is_specified.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_w19nhijeqoo.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_ww8esw1qdxc.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_included_module_methods.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_module_methods.json +7 -0
- data/tutorials/binary_search_tree/bst.rb +120 -0
- data/tutorials/binary_search_tree/bst_spec.rb +82 -0
- data/tutorials/binary_search_tree/run_tests.sh +4 -0
- data/tutorials/binary_search_tree/test_observations/.gitignore +2 -0
- data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_with_Bond.json +20 -0
- data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_without_Bond.json +3 -0
- data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_correctly_delete_nodes_from_the_BST.json +29 -0
- data/tutorials/heat_watcher/heat_watcher.rb +107 -0
- data/tutorials/heat_watcher/heat_watcher_spec.rb +116 -0
- data/tutorials/heat_watcher/run_tests.sh +4 -0
- data/tutorials/heat_watcher/test_observations/.gitignore +2 -0
- data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_critical_errors.json +142 -0
- data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_warnings_and_switch_back_to_OK_status.json +132 -0
- metadata +211 -0
data/bin/setup
ADDED
data/bond.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require_relative 'lib/bond/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = 'bond-spy'
|
5
|
+
spec.version = Bond::VERSION
|
6
|
+
spec.date = '2016-01-28'
|
7
|
+
|
8
|
+
spec.authors = ['George Necula', 'Erik Krogen']
|
9
|
+
spec.email = ['necula@cs.berkeley.edu', 'erikkrogen@gmail.com']
|
10
|
+
spec.license = 'BSD-2-Clause-FreeBSD'
|
11
|
+
|
12
|
+
spec.summary = 'A spy-based testing framework'
|
13
|
+
spec.homepage = 'http://github.com/necula01/bond'
|
14
|
+
spec.description = <<-EOF
|
15
|
+
Bond is a small library that can be used to spy values and mock functions
|
16
|
+
during tests. Spying is a replacement for writing the assertEquals in your
|
17
|
+
test, which are tedious to write and even more tedious to update when your
|
18
|
+
test setup or code inevitably changes. With Bond, you separate what is
|
19
|
+
being verified, e.g., the variable named output, from what value it should
|
20
|
+
have. This way you can quickly spy several variables, even have structured
|
21
|
+
values such as lists or dictionaries, and these values are saved into an
|
22
|
+
observation log that is saved for future reference. If the test observations
|
23
|
+
are different you have the option to interact with a console or visual tool
|
24
|
+
to see what has changed, and whether the reference set of observations need
|
25
|
+
to be updated.
|
26
|
+
EOF
|
27
|
+
|
28
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }
|
29
|
+
spec.test_files = `git ls-files -z -- spec`.split("\x0")
|
30
|
+
spec.bindir = 'bin'
|
31
|
+
|
32
|
+
spec.required_ruby_version = '>= 2.1'
|
33
|
+
spec.add_development_dependency 'bundler', '~> 1.10'
|
34
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
35
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
36
|
+
end
|
data/lib/bond.rb
ADDED
@@ -0,0 +1,469 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
require 'json'
|
3
|
+
require 'fileutils'
|
4
|
+
require 'shellwords'
|
5
|
+
|
6
|
+
# Singleton class providing the core functionality of Bond. You will generally
|
7
|
+
# access this through the {BondTargetable#bond} method exported to you when you
|
8
|
+
# `extend` BondTargetable, but it can also be accessed (e.g. for functions not
|
9
|
+
# contained in a class/module) via `Bond.instance`.
|
10
|
+
class Bond
|
11
|
+
include Singleton
|
12
|
+
# TODO ETK make this able to use other test frameworks as well
|
13
|
+
|
14
|
+
# Maximum number of characters to allow in any test file name.
|
15
|
+
# File names which would be longer than this will be truncated
|
16
|
+
# to 10 fewer characters than the max, and a hash of the full
|
17
|
+
# name will be appended to uniquely identify the file.
|
18
|
+
MAX_FILE_NAME_LENGTH = 100
|
19
|
+
|
20
|
+
# Returns true if Bond is currently active (in testing mode), else false.
|
21
|
+
# If this returns false, you can safely assume that calls to {#spy} will
|
22
|
+
# have no effect.
|
23
|
+
def active?
|
24
|
+
!@current_test.nil?
|
25
|
+
end
|
26
|
+
|
27
|
+
# Change the settings for Bond, overriding anything which was previously set by
|
28
|
+
# {#start_test}. Accepts any of the keyword arguments that {#start_test} does
|
29
|
+
# except for `test_name`, which cannot be changed.
|
30
|
+
# @param (see #start_test)
|
31
|
+
def settings(spy_groups: nil, observation_directory: nil, reconcile: nil)
|
32
|
+
raise 'not yet implemented' unless spy_groups.nil? # TODO spy_groups
|
33
|
+
@observation_directory = observation_directory unless observation_directory.nil?
|
34
|
+
@reconcile = reconcile unless @reconcile.nil?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Enable testing for Bond. When using RSpec, this will be called automatically
|
38
|
+
# for you when you `include_context :bond`, to which you can pass all of the
|
39
|
+
# same keyword arguments as `start_test`.
|
40
|
+
# @param rspec_test The current test that is being run through RSpec.
|
41
|
+
# @param test_name [String] The name of the current test. If not provided,
|
42
|
+
# the default is the test file concatenated with the full description
|
43
|
+
# string of the RSpec test, e.g. for a test within my_class_spec.rb that
|
44
|
+
# appeared as:
|
45
|
+
#
|
46
|
+
# ```
|
47
|
+
# describe MyClass do
|
48
|
+
# include_context :bond, observation_directory: 'spec/observations'
|
49
|
+
#
|
50
|
+
# context 'when nothing is wrong' do
|
51
|
+
# it 'should work!' do
|
52
|
+
# ... test code ...
|
53
|
+
# # `include_context :bond` automatically exports a `bond` variable
|
54
|
+
# # for you to access for e.g. `spy` and `deploy_agent`
|
55
|
+
# bond.spy('spy_point', key: value, ...)
|
56
|
+
# ... test code ...
|
57
|
+
# end
|
58
|
+
# end
|
59
|
+
# end
|
60
|
+
# ```
|
61
|
+
#
|
62
|
+
# `test_name` would be 'my_class_spec.MyClass_when_nothing_is_wrong_should_work_'
|
63
|
+
# (note that all non-alphanumeric characters except periods are replaced with
|
64
|
+
# an underscore).
|
65
|
+
# @param spy_groups NOT YET IMPLEMENTED.
|
66
|
+
# @param observation_directory [String] Path to the directory where
|
67
|
+
# the observations for this test should be stored. The default is
|
68
|
+
# a 'test_observations' directory located in the same directory
|
69
|
+
# as the file containing the current test. Test observations will
|
70
|
+
# be stored within this directory. Any hierarchy (as specified by .)
|
71
|
+
# in the test name becomes a directory hierarchy, e.g. a test name of
|
72
|
+
# 'bond.my_tests.test_name' would be stored at
|
73
|
+
# '`{observation_directory}`/bond/my_tests/test_name.json'
|
74
|
+
# @param reconcile The action to take when there are differences found between
|
75
|
+
# the reference versions of test output and the current test output.
|
76
|
+
# Should be one of:
|
77
|
+
#
|
78
|
+
# - **`:console`** - interactive console prompts to decide what to merge (default)
|
79
|
+
# - `:abort` - don't accept any new changes
|
80
|
+
# - `:accept` - accept all new changes
|
81
|
+
# - `:kdiff3` - use the kdiff3 graphical merge tool to reconcile differences
|
82
|
+
#
|
83
|
+
def start_test(rspec_test, test_name: nil, spy_groups: nil,
|
84
|
+
observation_directory: nil, reconcile: nil)
|
85
|
+
|
86
|
+
@observations = []
|
87
|
+
@spy_agents = Hash.new { |hash, key|
|
88
|
+
hash[key] = []
|
89
|
+
}
|
90
|
+
@observation_directory = nil
|
91
|
+
@current_test = rspec_test
|
92
|
+
@reconcile = reconcile
|
93
|
+
|
94
|
+
if test_name.nil?
|
95
|
+
test_file = @current_test.metadata[:file_path]
|
96
|
+
# TODO ETK allow other characters besides alphanumeric?
|
97
|
+
@test_name = File.basename(test_file, File.extname(test_file)) + '.' +
|
98
|
+
@current_test.metadata[:full_description].gsub(/[^A-z0-9.]/, '_')
|
99
|
+
else
|
100
|
+
@test_name = test_name
|
101
|
+
end
|
102
|
+
|
103
|
+
settings(spy_groups: spy_groups, observation_directory: observation_directory, reconcile: reconcile)
|
104
|
+
end
|
105
|
+
|
106
|
+
# The main entrypoint, used to observe some program state. If Bond is not active,
|
107
|
+
# does nothing. Observes all keyword arguments within `observation`, recording them
|
108
|
+
# to be written out to a file at the end of the current test. A deep copy
|
109
|
+
# of the arguments is made, all hashes (at any level of nesting) are sorted, and
|
110
|
+
# any object that is not an Array or Hash will have its `Object#clone` method called.
|
111
|
+
# If it is not cloneable (`clone` throws an error), the original object will be used.
|
112
|
+
# The arguments are then JSON-serialized. For any object which has a `to_json` method,
|
113
|
+
# this will be called to serialize it. Note that `to_json` takes one argument which
|
114
|
+
# is a JSON::Ext::Generator::State object; you should pass this object into any `to_json`
|
115
|
+
# calls you make within your `to_json` function.
|
116
|
+
# @param spy_point_name [#to_s] The name of this spy point. Will be used to subsequently
|
117
|
+
# refer to this point for, e.g., {#deploy_agent}. This name also gets printed as
|
118
|
+
# part of the observation with the key `__spy_point__`.
|
119
|
+
# @param observation Keyword arguments which should be observed.
|
120
|
+
# @return if an agent has been set for this spy point, this will return whatever value
|
121
|
+
# is specified by that agent. Otherwise, returns `:agent_result_none`.
|
122
|
+
def spy(spy_point_name=nil, **observation)
|
123
|
+
return :agent_result_none unless active? # If we're not testing, don't do anything
|
124
|
+
|
125
|
+
spy_point_name = spy_point_name.nil? ? nil : spy_point_name.to_s
|
126
|
+
|
127
|
+
observation[:__spy_point__] = spy_point_name unless spy_point_name.nil?
|
128
|
+
observation = deep_clone_sort_hashes(observation)
|
129
|
+
active_agent = spy_point_name.nil? ? nil : @spy_agents[spy_point_name].find { |agent| agent.process?(observation) }
|
130
|
+
|
131
|
+
res = :agent_result_none
|
132
|
+
begin
|
133
|
+
unless active_agent.nil?
|
134
|
+
active_agent.do(observation)
|
135
|
+
res = active_agent.result(observation)
|
136
|
+
end
|
137
|
+
ensure
|
138
|
+
formatted = format_observation(observation, active_agent)
|
139
|
+
@observations <<= formatted
|
140
|
+
#TODO ETK printing
|
141
|
+
puts "Observing: #{formatted} #{", returning <#{res.to_s}>" if res != :agent_result_none}"
|
142
|
+
end
|
143
|
+
|
144
|
+
res
|
145
|
+
end
|
146
|
+
|
147
|
+
# Deploy an agent to watch a specific spy point. The most recently deployed agent
|
148
|
+
# will always take precedence over any previous agents for a given spy point.
|
149
|
+
# However, if the most recent agent does not apply to the current observation
|
150
|
+
# (because its filters do not match), the next most recent agent will be used,
|
151
|
+
# and so on. At most one agent will be applied.
|
152
|
+
# @param spy_point_name [#to_s] The name of the spy point for which to deploy this agent
|
153
|
+
# @param (see SpyAgent#initialize)
|
154
|
+
def deploy_agent(spy_point_name, **opts)
|
155
|
+
raise 'You must enable testing before using deploy_agent' unless active?
|
156
|
+
spy_point_name = spy_point_name.to_s
|
157
|
+
@spy_agents[spy_point_name] = @spy_agents[spy_point_name].unshift(SpyAgent.new(**opts))
|
158
|
+
end
|
159
|
+
|
160
|
+
private
|
161
|
+
|
162
|
+
# Clean up from the current test. Marks Bond as no longer active, and saves
|
163
|
+
# all of the current observations to a file specified by {#observation_file_name}.
|
164
|
+
# Creates whatever directory structure necessary for this, and adds a .gitignore
|
165
|
+
# file to ignore Bond's temporary output (if one does not already exist). Then begins
|
166
|
+
# the reconciliation process. If the new observations are different
|
167
|
+
# from the reference files, reconciles them using whatever means specified
|
168
|
+
# by the `reconcile` setting.
|
169
|
+
# @return If the test failed, returns `:test_fail`. If reconciliation fails
|
170
|
+
# (new changes are not accepted), returns `:bond_fail`. Else returns `:pass`
|
171
|
+
def finish_test
|
172
|
+
fname = observation_file_name
|
173
|
+
fdir = File.dirname(fname)
|
174
|
+
unless File.directory?(fdir)
|
175
|
+
FileUtils.mkdir_p(fdir)
|
176
|
+
top_git_ignore = File.join(observation_directory, '.gitignore')
|
177
|
+
unless File.file?(top_git_ignore)
|
178
|
+
# TODO ETK make this configurable in case you don't use git?
|
179
|
+
File.open(top_git_ignore, 'w') do |outfile|
|
180
|
+
outfile.print("*_now.json\n*.diff\n")
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
test_fail = !@current_test.exception.nil?
|
186
|
+
|
187
|
+
ref_file = fname + '.json'
|
188
|
+
cur_file = fname + '_now.json'
|
189
|
+
File.delete(cur_file) if File.exists?(cur_file)
|
190
|
+
save_observations(cur_file)
|
191
|
+
|
192
|
+
reconcile_result = reconcile_observations(ref_file, cur_file,
|
193
|
+
test_fail ? 'Test had failure(s)!' : nil)
|
194
|
+
return :test_fail if test_fail
|
195
|
+
return reconcile_result
|
196
|
+
ensure
|
197
|
+
@current_test = nil
|
198
|
+
end
|
199
|
+
|
200
|
+
# Reconcile observations, for now using an external Python script.
|
201
|
+
# Depending on the `reconcile` setting, will take action to reconcile the differences.
|
202
|
+
# @param ref_file [String] Path to the accepted/reference test output.
|
203
|
+
# If this does not exist, it will be treated as an empty file.
|
204
|
+
# @param cur_file [String] Path to the current test output.
|
205
|
+
# @param no_save [nil, String] If not `nil`, `ref_file` will *not* be overwritten
|
206
|
+
# and the string will be displayed as the reason why saving is not allowed.
|
207
|
+
# @return `:pass` if the reconciliation succeeds, else `:bond_fail`
|
208
|
+
def reconcile_observations(ref_file, cur_file, no_save=nil)
|
209
|
+
bond_reconcile_script = File.absolute_path(File.join(File.dirname(File.dirname(__FILE__)), 'bin', 'bond_reconcile.py'))
|
210
|
+
unless File.exists?(bond_reconcile_script)
|
211
|
+
raise "Cannot find the bond_reconcile script: #{bond_reconcile_script}"
|
212
|
+
end
|
213
|
+
|
214
|
+
cmd = "#{Shellwords.shellescape(bond_reconcile_script)} " +
|
215
|
+
"--reference #{Shellwords.shellescape(ref_file)} " +
|
216
|
+
"--current #{Shellwords.shellescape(cur_file)} " +
|
217
|
+
"--test #{Shellwords.shellescape(@test_name)} " +
|
218
|
+
(@reconcile.nil? ? '' : "--reconcile #{@reconcile.to_s}") +
|
219
|
+
(no_save.nil? ? '' : "--no-save #{Shellwords.shellescape(no_save.to_s)}")
|
220
|
+
puts "Running: #{cmd}"
|
221
|
+
code = system(cmd)
|
222
|
+
code ? :pass : :bond_fail
|
223
|
+
end
|
224
|
+
|
225
|
+
# Save all current observations to a file. Assumes that `@observations`
|
226
|
+
# has already been JSON-serialized and outputs them all as a JSON array.
|
227
|
+
# @param fname [String] Path where the file should be saved.
|
228
|
+
def save_observations(fname)
|
229
|
+
File.open(fname, 'w') do |f|
|
230
|
+
f.print("[\n#{@observations.join(",\n")}\n]\n")
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# Return the file name where observations for the current test should be
|
235
|
+
# stored. Any hierarchy (as specified by .) in the test name becomes
|
236
|
+
# a directory hierarchy, e.g. a test name of 'bond.my_tests.test_name'
|
237
|
+
# would be stored at '`{base_directory}`/bond/my_tests/test_name.json'
|
238
|
+
# If any portion of the file name (i.e. a directory or file name) is
|
239
|
+
# longer than {#MAX_FILE_NAME_LENGTH} - 5 (to account for a possible
|
240
|
+
# `.json` extension), reduce the length to 10 characters less than
|
241
|
+
# this and fill the remaining 10 characters with a hash of the full name.
|
242
|
+
def observation_file_name
|
243
|
+
name_array = @test_name.split('.').map do |name|
|
244
|
+
if name.length <= MAX_FILE_NAME_LENGTH - 5
|
245
|
+
name
|
246
|
+
else
|
247
|
+
# Using djb2 hash algorithm translated from http://www.cse.yorku.ca/~oz/hash.html
|
248
|
+
name_hash = name.chars.inject(5381) { |sum, c| ((sum << 5) + sum) + c.to_i }
|
249
|
+
# Take start of name, up to first 10 chars of the hash as base 36 (alphanumerics)
|
250
|
+
name[0, MAX_FILE_NAME_LENGTH - 15] + name_hash.to_s(36)[0, 10]
|
251
|
+
end
|
252
|
+
end
|
253
|
+
File.join(observation_directory, name_array)
|
254
|
+
end
|
255
|
+
|
256
|
+
# Return the directory where observations should be stored
|
257
|
+
# This can be specified with the `observation_directory` setting.
|
258
|
+
# If not set, it will be a 'test_observations' directory located
|
259
|
+
# in the same directory as the file containing the current test.
|
260
|
+
def observation_directory
|
261
|
+
return @observation_directory unless @observation_directory.nil?
|
262
|
+
test_file = @current_test.metadata[:file_path]
|
263
|
+
File.join(File.dirname(File.absolute_path(test_file)), 'test_observations')
|
264
|
+
end
|
265
|
+
|
266
|
+
# Formats the observation hash. Currently, this just JSON-serializes
|
267
|
+
# the hash. If any objects encountered have a `to_json` method, it will
|
268
|
+
# be called to serialize the object. Note that `to_json` takes one argument
|
269
|
+
# which is a JSON::Ext::Generator::State object; you should pass this object
|
270
|
+
# into any `to_json` calls you make within your `to_json` function.
|
271
|
+
# @param observation [Hash] Observations to be formatted.
|
272
|
+
# @return The formatted hash.
|
273
|
+
def format_observation(observation, agent = nil)
|
274
|
+
# TODO ETK actually have formatters
|
275
|
+
JSON.pretty_generate(observation, indent: ' '*4)
|
276
|
+
end
|
277
|
+
|
278
|
+
# Deep-clones an object while sorting any Hashes at any depth:
|
279
|
+
#
|
280
|
+
# - Hash: Creates a new hash containing all of the old key-value
|
281
|
+
# pairs sorted by key
|
282
|
+
# - Array: Creates a new array with the old contents *not* sorted
|
283
|
+
# - Other: Attempts to call Object#clone. If this fails (results in #TypeError)
|
284
|
+
# then the object is returned as-is (assumes that non-cloneable objects
|
285
|
+
# are immutable and thus don't need cloning)
|
286
|
+
#
|
287
|
+
# @param obj The object to be cloned.
|
288
|
+
# @return The deep-clone with hashes sorted.
|
289
|
+
def deep_clone_sort_hashes(obj)
|
290
|
+
if obj.is_a?(Hash)
|
291
|
+
{}.tap do |new|
|
292
|
+
obj.sort.each do |k, v|
|
293
|
+
new[k] = deep_clone_sort_hashes(v)
|
294
|
+
end
|
295
|
+
end
|
296
|
+
elsif obj.is_a?(Array) # Don't sort arrays, just clone
|
297
|
+
obj.map { |x| deep_clone_sort_hashes(x) }
|
298
|
+
else
|
299
|
+
begin
|
300
|
+
obj.clone
|
301
|
+
rescue TypeError # Some types, e.g. Fixnum and Symbol, can't be cloned
|
302
|
+
obj
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
end
|
308
|
+
|
309
|
+
# Represents an agent deployed by {Bond#deploy_agent}. Takes
|
310
|
+
# action on spy points depending on the options specified upon
|
311
|
+
# initialization.
|
312
|
+
# @api private
|
313
|
+
class SpyAgent
|
314
|
+
|
315
|
+
# Initialize, setting the options for this SpyAgent.
|
316
|
+
# @param opts Key-value pairs that control whether the agent is
|
317
|
+
# active and what it does. The following keys are recognized:
|
318
|
+
#
|
319
|
+
# - Keys that restrict for which invocations of bond.spy this
|
320
|
+
# agent is active. All of these conditions must be true for
|
321
|
+
# the agent to be the active one:
|
322
|
+
#
|
323
|
+
# - `key: val` - only when the observation dictionary contains
|
324
|
+
# the `key` with the given value
|
325
|
+
# - `key__contains: substr` - only when the observation dictionary
|
326
|
+
# contains the `key` with a string value that contains the given substr.
|
327
|
+
# - `key__startswith: substr` - only when the observation dictionary
|
328
|
+
# contains the `key` with a string value that starts with the given substr.
|
329
|
+
# - `key__endswith: substr` - only when the observation dictionary contains
|
330
|
+
# the `key` with a string value that ends with the given substr.
|
331
|
+
# - `filter: func` - only when the given func returns true when passed
|
332
|
+
# observation dictionary. The function should not make changes to
|
333
|
+
# the observation dictionary. Uses the observation before formatting.
|
334
|
+
#
|
335
|
+
# - Keys that control what the observer does when processed:
|
336
|
+
#
|
337
|
+
# - `do: func` - executes the given function with the observation dictionary.
|
338
|
+
# func can also be a list of functions, executed in order.
|
339
|
+
# The function should not make changes to the observation dictionary.
|
340
|
+
# Uses the observation before formatting.
|
341
|
+
#
|
342
|
+
# - Keys that control what the corresponding spy returns (by default `:agent_result_none`):
|
343
|
+
#
|
344
|
+
# - `exception: x` - the call to bond.spy throws the given exception. If `x`
|
345
|
+
# is a function it is invoked on the observation dictionary to compute
|
346
|
+
# the exception to throw. The function should not make changes to the
|
347
|
+
# observation dictionary. Uses the observation before formatting.
|
348
|
+
# - `result: x` - the call to bond.spy returns the given value. If `x` is a
|
349
|
+
# function it is invoked on the observe argument dictionary to compute
|
350
|
+
# the value to return. If the function throws an exception then the
|
351
|
+
# spied function throws an exception. The function should not make
|
352
|
+
# changes to the observation dictionary. Uses the observation before
|
353
|
+
# formatting.
|
354
|
+
#
|
355
|
+
# - Keys that control how the observation is saved. This is processed after all
|
356
|
+
# the above functions. **NOT YET AVAILABLE**
|
357
|
+
#
|
358
|
+
# - `formatter: func` - If specified, a function that is given the observation and
|
359
|
+
# can update it in place. The formatted observation is what gets serialized and saved.
|
360
|
+
#
|
361
|
+
def initialize(**opts)
|
362
|
+
# TODO ETK needs formatters
|
363
|
+
@result_spec = :agent_result_none
|
364
|
+
@exception_spec = nil
|
365
|
+
@doers = []
|
366
|
+
@filters = []
|
367
|
+
|
368
|
+
opts.each do |k, v|
|
369
|
+
case k.to_s # Convert to string in case it was passed as a symbol
|
370
|
+
when 'result'
|
371
|
+
@result_spec = v
|
372
|
+
when 'exception'
|
373
|
+
@exception_spec = v
|
374
|
+
when 'do'
|
375
|
+
@doers = [*v]
|
376
|
+
else # Must be a filter
|
377
|
+
@filters <<= SpyAgentFilter.new(k.to_s, v)
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
# Checks if this agent should process this observation
|
383
|
+
# @return true iff this agent should process this observation
|
384
|
+
def process?(observation)
|
385
|
+
@filters.empty? || @filters.all? do |filter|
|
386
|
+
filter.accept?(observation)
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
# Carries out all of the actions specified by the `do` option
|
391
|
+
def do(observation)
|
392
|
+
@doers.each { |doer| doer.call(observation) }
|
393
|
+
end
|
394
|
+
|
395
|
+
# Gets the result that this agent should return, dependent on the
|
396
|
+
# `result` and `exception` options. If neither is present,
|
397
|
+
# returns `:agent_result_none`.
|
398
|
+
def result(observation)
|
399
|
+
unless @exception_spec.nil?
|
400
|
+
raise @exception_spec.respond_to?(:call) ? @exception_spec.call(observation) : @exception_spec
|
401
|
+
end
|
402
|
+
|
403
|
+
if !@result_spec.nil? && @result_spec.respond_to?(:call)
|
404
|
+
@result_spec.call(observation)
|
405
|
+
else
|
406
|
+
@result_spec
|
407
|
+
end
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
# Filters used to determine whether or not a {SpyAgent} should
|
412
|
+
# be applied to a given observation.
|
413
|
+
# @api private
|
414
|
+
class SpyAgentFilter
|
415
|
+
|
416
|
+
# Initialize this filter.
|
417
|
+
# @see Bond#deploy_agent
|
418
|
+
# @param filter_key [#to_s] The key for the filter.
|
419
|
+
# @param filter_value The value for the filter.
|
420
|
+
def initialize(filter_key, filter_value)
|
421
|
+
@field_name = nil
|
422
|
+
@filter_func = nil
|
423
|
+
|
424
|
+
filter_key = filter_key.to_s
|
425
|
+
if filter_key == 'filter'
|
426
|
+
raise 'When using filter, passed value must be callable' unless filter_value.respond_to?(:call)
|
427
|
+
@filter_func = filter_value
|
428
|
+
return
|
429
|
+
end
|
430
|
+
|
431
|
+
key_parts = filter_key.split('__')
|
432
|
+
if key_parts.length == 1
|
433
|
+
@field_name = key_parts[0]
|
434
|
+
@filter_func = lambda { |val| val == filter_value }
|
435
|
+
elsif key_parts.length == 2
|
436
|
+
@field_name = key_parts[0]
|
437
|
+
case key_parts[1]
|
438
|
+
when 'exact','eq'
|
439
|
+
@filter_func = lambda { |val| val == filter_value }
|
440
|
+
when 'startswith'
|
441
|
+
@filter_func = lambda { |val| val.start_with?(filter_value) }
|
442
|
+
when 'endswith'
|
443
|
+
@filter_func = lambda { |val| val.end_with?(filter_value) }
|
444
|
+
when 'contains'
|
445
|
+
@filter_func = lambda { |val| val.include?(filter_value) }
|
446
|
+
else
|
447
|
+
raise "Unknown operator: #{key_parts[1]}"
|
448
|
+
end
|
449
|
+
else
|
450
|
+
raise "Invalid key passed in: #{filter_key}"
|
451
|
+
end
|
452
|
+
end
|
453
|
+
|
454
|
+
# Return true iff the provided observation meets this filter.
|
455
|
+
def accept?(observation)
|
456
|
+
if @field_name.nil?
|
457
|
+
@filter_func.call(observation)
|
458
|
+
elsif observation.has_key?(@field_name)
|
459
|
+
@filter_func.call(observation[@field_name])
|
460
|
+
elsif observation.has_key?(@field_name.to_sym)
|
461
|
+
@filter_func.call(observation[@field_name.to_sym])
|
462
|
+
else
|
463
|
+
false
|
464
|
+
end
|
465
|
+
end
|
466
|
+
|
467
|
+
end
|
468
|
+
|
469
|
+
require_relative 'bond/targetable'
|