/
acceptance_helper.rb
260 lines (217 loc) · 8.03 KB
/
acceptance_helper.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# frozen_string_literal: true
{
modification: :modified,
addition: :added,
removal: :removed,
queued_modification: :modified,
queued_addition: :added
}.each do |description, type|
RSpec::Matchers.define "process_#{description}_of".to_sym do |expected|
match do |actual|
# Use cases:
# 1. reset the changes so they don't have leftovers
# 2. keep the queue if we're testing for existing accumulated changes
# if were testing the queue (e.g. after unpause), don't reset
reset_queue = /queued_/ !~ description
actual.listen(reset_queue: reset_queue) do
change_fs(type, expected) if reset_queue
end
actual.changes[type].include? expected
end
failure_message do |actual|
"expected #{actual.changes.inspect} to include #{description} of #{expected}"
end
failure_message_when_negated do |actual|
"expected #{actual.changes.inspect} to not include #{description} of #{expected}"
end
end
end
# rubocop:disable Metrics/MethodLength
def change_fs(type, path)
case type
when :modified
File.exist?(path) or fail "Bad test: cannot modify #{path.inspect} (it doesn't exist)"
# wait until full second, because this might be followed by a modification
# event (which otherwise may not be detected every time)
_sleep_until_next_second(Pathname.pwd)
File.open(path, 'a') { |f| f.write('foo') }
# separate it from upcoming modifications"
_sleep_to_separate_events
when :added
File.exist?(path) and fail "Bad test: cannot add #{path.inspect} (it already exists)"
# wait until full second, because this might be followed by a modification
# event (which otherwise may not be detected every time)
_sleep_until_next_second(Pathname.pwd)
File.write(path, 'foo')
# separate it from upcoming modifications"
_sleep_to_separate_events
when :removed
File.exist?(path) or fail "Bad test: cannot remove #{path.inspect} (it doesn't exist)"
File.unlink(path)
else
fail "bad test: unknown type: #{type.inspect}"
end
end
# rubocop:enable Metrics/MethodLength
# Used by change_fs() above so that the FS change (e.g. file created) happens
# as close to the start of a new second (time) as possible.
#
# E.g. if file is created at 1234567.999 (unix time), it's mtime on some
# filesystems is rounded, so it becomes 1234567.0, but if the change
# notification happens a little while later, e.g. at 1234568.111, now the file
# mtime and the current time in seconds are different (1234567 vs 1234568), and
# so the MD5 test won't kick in (see file.rb) - the file will not be considered
# for content checking (sha), so File.change will consider the file unmodified.
#
# This means, that if a file is added at 1234567.888 (and updated in Record),
# and then its content is modified at 1234567.999, and checking for changes
# happens at 1234568.111, the modification won't be detected.
# (because Record mtime is 1234567.0, current FS mtime from stat() is the
# same, and the checking happens in another second - 1234568).
#
# So basically, adding a file and detecting its later modification should all
# happen within 1 second (which makes testing and debugging difficult).
#
def _sleep_until_next_second(path)
Listen::File.inaccurate_mac_time?(path)
t = Time.now.utc
diff = t.to_f - t.to_i
sleep(1.05 - diff)
end
# Special class to only allow changes within a specific time window
class TimedChanges
attr_reader :changes
def initialize
# Set to non-nil, because changes can immediately come after unpausing
# listener in an Rspec 'before()' block
@changes = { modified: [], added: [], removed: [] }
end
def change_offset
Time.now.to_f - @yield_time
end
def freeze_offset
result = @freeze_time - @yield_time
# Make an "almost zero" value more readable
result < 1e-4 ? 1e-4 : result
end
# Allow changes only during specific time wine
def allow_changes(reset_queue: true)
@freeze_time = nil
if reset_queue
# Clear to prepare for collecting new FS events
@changes = { modified: [], added: [], removed: [] }
else
# Since we're testing the queue and the listener callback is adding
# changes to the same hash (e.g. after a pause), copy the existing data
# to a new, unfrozen hash
@changes = @changes.dup if @changes.frozen?
@changes ||= { modified: [], added: [], removed: [] }
end
@yield_time = Time.now.to_f
yield
# Prevent recording changes after timeout
@changes.freeze
@freeze_time = Time.now.to_f
end
end
# Conveniently wrap a Listener instance for testing
class ListenerWrapper
attr_reader :listener
attr_accessor :lag
def initialize(callback, paths, *args)
# Lag depends mostly on wait_for_delay On Linux desktop, it's 0.06 - 0.11
#
# On Travis it used to be > 0.5, but that was before broadcaster sent
# changes immediately, so 0.2-0.4 might be enough for Travis, but we set it
# to 0.8 (because 0.75 wasn't enough recently)
#
# The value should be 2-3 x wait_for_delay + time between fs operation and
# notification, which for polling and FSEvent means the configured latency
@lag = Float(ENV['LISTEN_TESTS_DEFAULT_LAG'] || 1.0)
@paths = paths
# Isolate collected changes between tests/listener instances
@timed_changes = TimedChanges.new
@listener = if callback
Listen.send(*args) do |modified, added, removed|
# Add changes to trigger frozen Hash error, making sure lag is enough
_add_changes(:modified, modified, changes)
_add_changes(:added, added, changes)
_add_changes(:removed, removed, changes)
callback.call(modified, added, removed) unless callback == :track_changes
end
else
Listen.send(*args)
end
end
def changes
@timed_changes.changes
end
def listen(reset_queue: true)
# Give previous events time to be received, queued and processed
# so they complete and don't interfere
sleep(lag)
@timed_changes.allow_changes(reset_queue: reset_queue) do
yield
# Polling sleep (default: 1s)
backend = @listener.instance_variable_get(:@backend)
adapter = backend.instance_variable_get(:@adapter)
sleep(1.0) if adapter.is_a?(Listen::Adapter::Polling)
# Lag should include:
# 0.1s - 0.2s if the test needs Listener queue to be processed
# 0.1s in case the system is busy
sleep(lag)
end
# Keep this to detect a lag too small (changes during this sleep
# will trigger "frozen hash" error caught below (and displaying timeout
# details)
sleep(1)
changes
end
private
def _add_changes(type, changes, dst)
dst[type] += _relative_path(changes)
dst[type].uniq!
dst[type].sort!
rescue RuntimeError => e
raise unless e.message == "can't modify frozen Hash"
# Show how by much the changes missed the timeout
change_offset = @timed_changes.change_offset
freeze_offset = @timed_changes.freeze_offset
raise "Changes took #{change_offset}s (allowed lag: #{freeze_offset})s"
end
def _relative_path(changes)
changes.map do |change|
unfrozen_copy = change.dup
[@paths].flatten.each do |path|
sub = path.sub(%r{/$}, '').to_s
unfrozen_copy.gsub!(%r{^#{sub}/}, '')
end
unfrozen_copy
end
end
end
def setup_listener(options, callback = nil)
ListenerWrapper.new(callback, paths, :to, paths, options)
end
def setup_recipient(port, callback = nil)
ListenerWrapper.new(callback, paths, :on, port)
end
def _sleep_to_separate_events
# separate the events or Darwin and Polling
# will detect only the :added event
#
# (This is because both use directory scanning which may not kick in time
# before the next filesystem change)
#
# The minimum for this is the time it takes between a syscall
# changing the filesystem ... and ... an async
# Listen::File.scan to finish comparing the file with the
# Record
#
# This necessary for:
# - Darwin Adapter
# - Polling Adapter
# - Linux Adapter in FSEvent emulation mode
# - maybe Windows adapter (probably not)
sleep(0.4)
end