scarpe-components 0.1.0 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2033509feb830b1bc7fa18f1275c7e1735e7ef7212808d0c4e3618873016b23
4
- data.tar.gz: 1c8f7bf639f4e4b00f578263ea9be8a2be4b1773aa002457f50866e89159ad73
3
+ metadata.gz: b7067a7b6f0e71d041289334dccf5c0edaae3db689a710450c1d8f585888f74d
4
+ data.tar.gz: c73105b84ae8468779ca93c216e7f618213a79086e2c0d7a7470bdece885c2b9
5
5
  SHA512:
6
- metadata.gz: 7afb7e7b1e63029d673d3274f0c7b5cbe00871d2af4178863d2211f375eb222b65572adae625252da985b341c5df7e2c671df4c184b3f05fde2484a567199a72
7
- data.tar.gz: 6b4b76d33119f9a1688d6614e06c07ac63fca5a9741f4a627ced93ed10bf5ff22e8d110a844f6a7a9c6badbcc88d80e709eb9cc5c51534997ff18dccee308825
6
+ metadata.gz: 8d0be001e64c94d3e82dcfc7dca65cbfe373edfd219905716075cfcb3655b0da9a9fec76cf2323cec7d151b83581b3762d7b7c81d51ba2b34dcc10451b33c4db
7
+ data.tar.gz: 78a734af216bf4c7275483258d20508a985782442a1b6700632905d26b6dfccf0a66ca3c752202b41f32893094450681fdfae7e0d832ee08ba43812b300b8427
data/Gemfile ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "lacci", path: "../lacci"
8
+
9
+ gem "rake", "~> 13.0"
10
+
11
+ gem "nokogiri"
12
+
13
+ group :test do
14
+ gem "minitest", "~> 5.0"
15
+ gem "minitest-reporters"
16
+ end
17
+
18
+ group :development do
19
+ gem "debug"
20
+ gem "rubocop", "~> 1.21"
21
+ gem "rubocop-shopify"
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "uri"
5
+
6
+ class Scarpe; end
7
+ module Scarpe::Components; end
8
+ class Scarpe
9
+ module Components::Base64
10
+ def valid_url?(string)
11
+ uri = URI.parse(string)
12
+ uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
13
+ rescue URI::InvalidURIError, URI::BadURIError
14
+ false
15
+ end
16
+
17
+ def encode_file_to_base64(image_filename)
18
+ directory_path = File.dirname(__FILE__, 5)
19
+
20
+ image_path = File.join(directory_path, image_filename)
21
+
22
+ image_data = File.binread(image_path)
23
+
24
+ encoded_data = ::Base64.strict_encode64(image_data)
25
+
26
+ encoded_data
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ # These can be used for unit tests, but also more generally.
6
+
7
+ module Scarpe::Components::FileHelpers
8
+ # Create a temporary file with the given prefix and contents.
9
+ # Execute the block of code with it in place. Make sure
10
+ # it gets cleaned up afterward.
11
+ #
12
+ # @param prefix [String] the prefix passed to Tempfile to identify this file on disk
13
+ # @param contents [String] the file contents that should be written to Tempfile
14
+ # @param dir [String] the directory to create the tempfile in
15
+ # @yield The code to execute with the tempfile present
16
+ # @yieldparam the path of the new tempfile
17
+ def with_tempfile(prefix, contents, dir: Dir.tmpdir)
18
+ t = Tempfile.new(prefix, dir)
19
+ t.write(contents)
20
+ t.flush # Make sure the contents are written out
21
+
22
+ yield(t.path)
23
+ ensure
24
+ t.close
25
+ t.unlink
26
+ end
27
+
28
+ # Create multiple tempfiles, with given contents, in given
29
+ # directories, and execute the block in that context.
30
+ # When the block is finished, make sure all tempfiles are
31
+ # deleted.
32
+ #
33
+ # Pass an array of arrays, where each array is of the form:
34
+ # [prefix, contents, (optional)dir]
35
+ #
36
+ # I don't love inlining with_tempfile's contents into here.
37
+ # But calling it iteratively or recursively was difficult
38
+ # when I tried it the obvious ways.
39
+ #
40
+ # This method should be equivalent to calling with_tempfile
41
+ # once for each entry in the array, in a set of nested
42
+ # blocks.
43
+ #
44
+ # @param tf_specs [Array<Array>] The array of tempfile prefixes, contents and directories
45
+ # @yield The code to execute with those tempfiles present
46
+ # @yieldparam An array of paths to tempfiles, in the same order as tf_specs
47
+ def with_tempfiles(tf_specs, &block)
48
+ tempfiles = []
49
+ tf_specs.each do |prefix, contents, dir|
50
+ dir ||= Dir.tmpdir
51
+ t = Tempfile.new(prefix, dir)
52
+ tempfiles << t
53
+ t.write(contents)
54
+ t.flush # Make sure the contents are written out
55
+ end
56
+
57
+ args = tempfiles.map(&:path)
58
+ yield(args)
59
+ ensure
60
+ tempfiles.each do |t|
61
+ t.close
62
+ t.unlink
63
+ end
64
+ end
65
+ end
@@ -5,10 +5,12 @@ require "json"
5
5
 
6
6
  require "shoes/log"
7
7
 
8
- # Requirements: logging gem
8
+ # Requires the logging gem
9
9
 
10
+ class Scarpe; end
11
+ module Scarpe::Components; end
10
12
  class Scarpe
11
- class LogImpl
13
+ class Components::ModularLogImpl
12
14
  include Shoes::Log # for constants
13
15
 
14
16
  def logger_for_component(component)
@@ -106,3 +108,6 @@ class Scarpe
106
108
  end
107
109
  end
108
110
  end
111
+
112
+ #Shoes::Log.instance = Scarpe::Components::ModularLogImpl.new
113
+ #Shoes::Log.configure_logger(Shoes::Log::DEFAULT_LOG_CONFIG)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shoes/log"
4
+ require "json"
5
+
6
+ class Scarpe; end
7
+ module Scarpe::Components; end
8
+ class Scarpe::Components::PrintLogImpl
9
+ include Shoes::Log # for constants
10
+
11
+ class PrintLogger
12
+ def initialize(component_name)
13
+ @comp_name = component_name
14
+ end
15
+
16
+ def error(msg)
17
+ puts "#{@comp_name} error: #{msg}"
18
+ end
19
+
20
+ def warn(msg)
21
+ puts "#{@comp_name} warn: #{msg}"
22
+ end
23
+
24
+ def debug(msg)
25
+ puts "#{@comp_name} debug: #{msg}"
26
+ end
27
+
28
+ def info(msg)
29
+ puts "#{@comp_name} info: #{msg}"
30
+ end
31
+ end
32
+
33
+ def logger_for_component(component)
34
+ PrintLogger.new(component.to_s)
35
+ end
36
+
37
+ def configure_logger(log_config)
38
+ # For now, ignore
39
+ end
40
+ end
41
+
42
+ #Shoes::Log.instance = Scarpe::PrintLogImpl.new
43
+ #Shoes::Log.configure_logger(Shoes::Log::DEFAULT_LOG_CONFIG)
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Scarpe; end
4
+ module Scarpe::Components; end
5
+ class Scarpe
6
+ # Scarpe::Promise is a promises library, but one with no form of built-in
7
+ # concurrency. Instead, promise callbacks are executed synchronously.
8
+ # Even execution is usually synchronous, but can also be handled manually
9
+ # for forms of execution not controlled in Ruby (like Webview.)
10
+ #
11
+ # Funny thing... We need promises as an API concept since we have a JS event
12
+ # loop doing its thing, and we need to respond to actions that it takes.
13
+ # But there's not really a Ruby implementation of Promises *without* an
14
+ # attached form of concurrency. So here we are, writing our own :-/
15
+ #
16
+ # In theory you could probably write some kind of "no-op thread pool"
17
+ # for the ruby-concurrency gem, pass it manually to every promise we
18
+ # created and then raise an exception any time we tried to do something
19
+ # in the background. That's probably more code than writing our own, though,
20
+ # and we'd be fighting it constantly.
21
+ #
22
+ # This class is inspired by concurrent-ruby [Promise](https://ruby-concurrency.github.io/concurrent-ruby/1.1.5/Concurrent/Promise.html)
23
+ # which is inspired by Javascript Promises, which is what we actually need
24
+ # for our use case. We can't easily tell when our WebView begins processing
25
+ # our request, which removes the :processing state. This can be used for
26
+ # executing JS, but also generally waiting on events.
27
+ #
28
+ # We don't fully control ordering here, so it *is* conceivable that a
29
+ # child waiting on a parent can be randomly fulfilled, even if we didn't
30
+ # expect it. We don't consider that an error. Similarly, we'll call
31
+ # on_scheduled callbacks if a promise is fulfilled, even though we
32
+ # never explicitly scheduled it. If a promise is *rejected* without
33
+ # ever being scheduled, we won't call those callbacks.
34
+ class Promise
35
+ include Shoes::Log
36
+
37
+ # The unscheduled promise state means it's waiting on a parent promise that
38
+ # hasn't completed yet. The pending state means it's waiting to execute.
39
+ # Fulfilled means it has completed successfully and returned a value,
40
+ # while rejected means it has failed, normally producing a reason.
41
+ PROMISE_STATES = [:unscheduled, :pending, :fulfilled, :rejected]
42
+
43
+ # The state of the promise, which should be one of PROMISE_STATES
44
+ attr_reader :state
45
+
46
+ # The parent promises of this promise, sometimes an empty array
47
+ attr_reader :parents
48
+
49
+ # If the promise is fulfilled, this is the value returned
50
+ attr_reader :returned_value
51
+
52
+ # If the promise is rejected, this is the reason, sometimes an exception
53
+ attr_reader :reason
54
+
55
+ # Create a promise and then instantly fulfill it.
56
+ def self.fulfilled(return_val = nil, parents: [], &block)
57
+ p = Promise.new(parents: parents, &block)
58
+ p.fulfilled!(return_val)
59
+ p
60
+ end
61
+
62
+ # Create a promise and then instantly reject it.
63
+ def self.rejected(reason = nil, parents: [])
64
+ p = Promise.new(parents: parents)
65
+ p.rejected!(reason)
66
+ p
67
+ end
68
+
69
+ # Fulfill the promise, setting the returned_value to value
70
+ def fulfilled!(value = nil)
71
+ set_state(:fulfilled, value)
72
+ end
73
+
74
+ # Reject the promise, setting the reason to reason
75
+ def rejected!(reason = nil)
76
+ set_state(:rejected, reason)
77
+ end
78
+
79
+ # Create a new promise with this promise as a parent. It runs the
80
+ # specified code in block when scheduled.
81
+ def then(&block)
82
+ Promise.new(parents: [self], &block)
83
+ end
84
+
85
+ # The Promise.new method, along with all the various handlers,
86
+ # are pretty raw. They'll do what promises do, but they're not
87
+ # the prettiest. However, they ensure that guarantees are made
88
+ # and so on, so they're great as plumbing under the syntactic
89
+ # sugar above.
90
+ #
91
+ # Note that the state passed in may not be the actual initial
92
+ # state. If a parent is rejected, the state will become
93
+ # rejected. If no parents are waiting or failed then a state
94
+ # of nil or :unscheduled will become :pending.
95
+ #
96
+ # @param state [Symbol] One of PROMISE_STATES for the initial state
97
+ # @param parents [Array] A list of promises that must be fulfilled before this one is scheduled
98
+ # @yield A block that executes when this promise is scheduled - when its parents, if any, are all fulfilled
99
+ def initialize(state: nil, parents: [], &scheduler)
100
+ log_init("Promise")
101
+
102
+ # These are as initially specified, and effectively immutable
103
+ @state = state
104
+ @parents = parents
105
+
106
+ # These are what we're waiting on, and will be
107
+ # removed as time goes forward.
108
+ @waiting_on = parents.select { |p| !p.complete? }
109
+ @on_fulfilled = []
110
+ @on_rejected = []
111
+ @on_scheduled = []
112
+ @scheduler = scheduler
113
+ @executor = nil
114
+ @returned_value = nil
115
+ @reason = nil
116
+
117
+ if complete?
118
+ # Did we start out already fulfilled or rejected?
119
+ # If so, we can skip a lot of fiddly checking.
120
+ # Don't need a scheduler or to care about parents
121
+ # or anything.
122
+ @waiting_on = []
123
+ @scheduler = nil
124
+ elsif @parents.any? { |p| p.state == :rejected }
125
+ @state = :rejected
126
+ @waiting_on = []
127
+ @scheduler = nil
128
+ elsif @state == :pending
129
+ # Did we get an explicit :pending? Then we don't need
130
+ # to schedule ourselves, or care about the scheduler
131
+ # in general.
132
+ @scheduler = nil
133
+ elsif @state.nil? || @state == :unscheduled
134
+ # If no state was given or we're unscheduled, we'll
135
+ # wait until our parents have all completed to
136
+ # schedule ourselves.
137
+
138
+ if @waiting_on.empty?
139
+ # No further dependencies, we can schedule ourselves
140
+ @state = :pending
141
+
142
+ # We have no on_scheduled handlers yet, but this will
143
+ # call and clear the scheduler.
144
+ call_handlers_for(:pending)
145
+ else
146
+ # We're still waiting on somebody, no scheduling yet
147
+ @state = :unscheduled
148
+ @waiting_on.each do |dep|
149
+ dep.on_fulfilled { parent_fulfilled!(dep) }
150
+ dep.on_rejected { parent_rejected!(dep) }
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ # Return true if the Promise is either fulfilled or rejected.
157
+ #
158
+ # @return [Boolean] true if the promise is fulfilled or rejected
159
+ def complete?
160
+ @state == :fulfilled || @state == :rejected
161
+ end
162
+
163
+ # Return true if the promise is already fulfilled.
164
+ #
165
+ # @return [Boolean] true if the promise is fulfilled
166
+ def fulfilled?
167
+ @state == :fulfilled
168
+ end
169
+
170
+ # Return true if the promise is already rejected.
171
+ #
172
+ # @return [Boolean] true if the promise is rejected
173
+ def rejected?
174
+ @state == :rejected
175
+ end
176
+
177
+ # An inspect method to give slightly smaller output, for ease of reading in irb
178
+ def inspect
179
+ "#<Scarpe::Promise:#{object_id} " +
180
+ "@state=#{@state.inspect} @parents=#{@parents.inspect} " +
181
+ "@waiting_on=#{@waiting_on.inspect} @on_fulfilled=#{@on_fulfilled.size} " +
182
+ "@on_rejected=#{@on_rejected.size} @on_scheduled=#{@on_scheduled.size} " +
183
+ "@scheduler=#{@scheduler ? "Y" : "N"} @executor=#{@executor ? "Y" : "N"} " +
184
+ "@returned_value=#{@returned_value.inspect} @reason=#{@reason.inspect}" +
185
+ ">"
186
+ end
187
+
188
+ # These promises are mostly designed for external execution.
189
+ # You could put together your own thread-pool, or use RPC,
190
+ # a WebView, a database or similar source of external calculation.
191
+ # But in many cases it's reasonable to execute locally.
192
+ # In those cases, you can register an executor which will be
193
+ # called when the promise is ready to execute but has not yet
194
+ # done so. Registering an executor on a promise that is
195
+ # already fulfilled is an error. Registering an executor on
196
+ # a promise that has already rejected is a no-op.
197
+ def to_execute(&block)
198
+ case @state
199
+ when :fulfilled
200
+ # Should this be a no-op instead?
201
+ raise "Registering an executor on an already fulfilled promise means it will never run!"
202
+ when :rejected
203
+ return
204
+ when :unscheduled
205
+ @executor = block # save for later
206
+ when :pending
207
+ @executor = block
208
+ call_executor
209
+ else
210
+ raise "Internal error, illegal state!"
211
+ end
212
+
213
+ self
214
+ end
215
+
216
+ private
217
+
218
+ # set_state looks at the old and new states of the promise. It calls handlers and updates tracking
219
+ # data accordingly.
220
+ def set_state(new_state, value_or_reason = nil)
221
+ old_state = @state
222
+
223
+ # First, filter out illegal input
224
+ unless PROMISE_STATES.include?(old_state)
225
+ raise "Internal Promise error! Internal state was #{old_state.inspect}! Legal states: #{PROMISE_STATES.inspect}"
226
+ end
227
+
228
+ unless PROMISE_STATES.include?(new_state)
229
+ raise "Internal Promise error! Internal state was set to #{new_state.inspect}! " +
230
+ "Legal states: #{PROMISE_STATES.inspect}"
231
+ end
232
+
233
+ if new_state != :fulfilled && new_state != :rejected && !value_or_reason.nil?
234
+ raise "Internal promise error! Non-completed state transitions should not specify a value or reason!"
235
+ end
236
+
237
+ # Here's our state-transition grid for what we're doing here.
238
+ # "From" state is on the left, "to" state is on top.
239
+ #
240
+ # U P F R
241
+ #
242
+ # U - 1 . .
243
+ # P X - . .
244
+ # F X X - X
245
+ # R X X X -
246
+ #
247
+ # - Change from same to same, no effect
248
+ # X Illegal for one reason or another, raise error
249
+ # . Great, no problem, run handlers but not @scheduler or @executor
250
+ # 1 Interesting case - if we have an executor, actually change to a *different* state instead
251
+
252
+ # Transitioning from our state to our same state? No-op.
253
+ return if new_state == old_state
254
+
255
+ # Transitioning to any *different* state after being fulfilled or rejected? Nope. Those states are final.
256
+ if complete?
257
+ raise "Internal Promise error! Trying to change state from #{old_state.inspect} to #{new_state.inspect}!"
258
+ end
259
+
260
+ if old_state == :pending && new_state == :unscheduled
261
+ raise "Can't change state from :pending to :unscheduled! Scheduling is not reversible!"
262
+ end
263
+
264
+ # The next three checks should all be followed by calling handlers for the newly-changed state.
265
+ # See call_handlers_for below.
266
+
267
+ # Okay, we're getting scheduled.
268
+ if old_state == :unscheduled && new_state == :pending
269
+ @state = new_state
270
+ call_handlers_for(new_state)
271
+
272
+ # It's not impossible for the scheduler to do something that fulfills or rejects the promise.
273
+ # In that case it *also* called the appropriate handlers. Let's get out of here.
274
+ return if @state == :fulfilled || @state == :rejected
275
+
276
+ if @executor
277
+ # In this case we're still pending, but we have a synchronous executor. Let's do it.
278
+ call_executor
279
+ end
280
+
281
+ return
282
+ end
283
+
284
+ # Setting to rejected calls the rejected handlers. But no scheduling ever occurs, so on_scheduled handlers
285
+ # will never be called.
286
+ if new_state == :rejected
287
+ @state = :rejected
288
+ @reason = value_or_reason
289
+ call_handlers_for(new_state)
290
+ end
291
+
292
+ # If we go straight from :unscheduled to :fulfilled we *will* run the on_scheduled callbacks,
293
+ # because we pretend the scheduling *did* occur at some point. Normally that'll be no callbacks,
294
+ # of course.
295
+ #
296
+ # Fun-but-unfortunate trivia: you *can* fulfill a promise before all its parents are fulfilled.
297
+ # If you do, the unfinished parents will result in nil arguments to the on_fulfilled handler,
298
+ # because we have no other value to provide. The scheduler callback will never be called, but
299
+ # the on_scheduled callbacks, if any, will be.
300
+ if new_state == :fulfilled
301
+ @state = :fulfilled
302
+ @returned_value = value_or_reason
303
+ call_handlers_for(new_state)
304
+ end
305
+ end
306
+
307
+ # This private method calls handlers for a new state, removing those handlers
308
+ # since they have now been called. This interacts subtly with set_state()
309
+ # above, particularly in the case of fulfilling a promise without it ever being
310
+ # properly scheduled.
311
+ #
312
+ # The rejected handlers will be cleared if the promise is fulfilled and vice-versa.
313
+ # After rejection, no on_fulfilled handler should ever be called and vice-versa.
314
+ #
315
+ # When we go from :unscheduled to :pending, the scheduler, if any, should be
316
+ # called and cleared. That should *not* happen when going from :unscheduled to
317
+ # :fulfilled.
318
+ def call_handlers_for(state)
319
+ case state
320
+ when :fulfilled
321
+ @on_scheduled.each { |h| h.call(*@parents.map(&:returned_value)) }
322
+ @on_fulfilled.each { |h| h.call(*@parents.map(&:returned_value)) }
323
+ @on_scheduled = @on_rejected = @on_fulfilled = []
324
+ @scheduler = @executor = nil
325
+ when :rejected
326
+ @on_rejected.each { |h| h.call(*@parents.map(&:returned_value)) }
327
+ @on_fulfilled = @on_scheduled = @on_rejected = []
328
+ @scheduler = @executor = nil
329
+ when :pending
330
+ # A scheduler can get an exception. If so, treat it as rejection
331
+ # and the exception as the provided reason.
332
+ if @scheduler
333
+ begin
334
+ @scheduler.call(*@parents.map(&:returned_value))
335
+ rescue => e
336
+ @log.error("Error while running scheduler! #{e.full_message}")
337
+ rejected!(e)
338
+ end
339
+ @scheduler = nil
340
+ end
341
+ @on_scheduled.each { |h| h.call(*@parents.map(&:returned_value)) }
342
+ @on_scheduled = []
343
+ else
344
+ raise "Internal error! Trying to call handlers for #{state.inspect}!"
345
+ end
346
+ end
347
+
348
+ def parent_fulfilled!(parent)
349
+ @waiting_on.delete(parent)
350
+
351
+ # Last parent? If so, schedule ourselves.
352
+ if @waiting_on.empty? && !self.complete?
353
+ # This will result in :pending if there's no executor,
354
+ # or fulfilled/rejected if there is an executor.
355
+ set_state(:pending)
356
+ end
357
+ end
358
+
359
+ def parent_rejected!(parent)
360
+ @waiting_on = []
361
+
362
+ unless self.complete?
363
+ # If our parent was rejected and we were waiting on them,
364
+ # now we're rejected too.
365
+ set_state(:rejected)
366
+ end
367
+ end
368
+
369
+ def call_executor
370
+ raise("Internal error! Should not call_executor with no executor!") unless @executor
371
+
372
+ begin
373
+ result = @executor.call(*@parents.map(&:returned_value))
374
+ fulfilled!(result)
375
+ rescue => e
376
+ @log.error("Error running executor! #{e.full_message}")
377
+ rejected!(e)
378
+ end
379
+ ensure
380
+ @executor = nil
381
+ end
382
+
383
+ public
384
+
385
+ # Register a handler to be called when the promise is fulfilled.
386
+ # If called on a fulfilled promise, the handler will be called immediately.
387
+ #
388
+ # @yield Handler to be called on fulfilled
389
+ # @return [Scarpe::Promise] self
390
+ def on_fulfilled(&handler)
391
+ unless handler
392
+ raise "You must pass a block to on_fulfilled!"
393
+ end
394
+
395
+ case @state
396
+ when :fulfilled
397
+ handler.call(*@parents.map(&:returned_value))
398
+ when :pending, :unscheduled
399
+ @on_fulfilled << handler
400
+ when :rejected
401
+ # Do nothing
402
+ end
403
+
404
+ self
405
+ end
406
+
407
+ # Register a handler to be called when the promise is rejected.
408
+ # If called on a rejected promise, the handler will be called immediately.
409
+ #
410
+ # @yield Handler to be called on rejected
411
+ # @return [Scarpe::Promise] self
412
+ def on_rejected(&handler)
413
+ unless handler
414
+ raise "You must pass a block to on_rejected!"
415
+ end
416
+
417
+ case @state
418
+ when :rejected
419
+ handler.call(*@parents.map(&:returned_value))
420
+ when :pending, :unscheduled
421
+ @on_rejected << handler
422
+ when :fulfilled
423
+ # Do nothing
424
+ end
425
+
426
+ self
427
+ end
428
+
429
+ # Register a handler to be called when the promise is scheduled.
430
+ # If called on a promise that was scheduled earlier, the handler
431
+ # will be called immediately.
432
+ #
433
+ # @yield Handler to be called on scheduled
434
+ # @return [Scarpe::Promise] self
435
+ def on_scheduled(&handler)
436
+ unless handler
437
+ raise "You must pass a block to on_scheduled!"
438
+ end
439
+
440
+ # Add a pending handler or call it now
441
+ case @state
442
+ when :fulfilled, :pending
443
+ handler.call(*@parents.map(&:returned_value))
444
+ when :unscheduled
445
+ @on_scheduled << handler
446
+ when :rejected
447
+ # Do nothing
448
+ end
449
+
450
+ self
451
+ end
452
+ end
453
+ Components::Promise = Promise
454
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "scarpe/components/file_helpers"
4
+
5
+ module Scarpe::Components
6
+ class SegmentedFileLoader
7
+ include Scarpe::Components::FileHelpers
8
+
9
+ # Add a new segment type (e.g. "catscradle") with a different
10
+ # file handler.
11
+ #
12
+ # @param type [String] the new name for this segment type
13
+ # @param handler [Object] an object that will be called as obj.call(filename) - often a proc
14
+ # @return <void>
15
+ def add_segment_type(type, handler)
16
+ if segment_type_hash.key?(type)
17
+ raise "Segment type #{type.inspect} already exists!"
18
+ end
19
+
20
+ segment_type_hash[type] = handler
21
+ end
22
+
23
+ # Return an Array of segment type labels, such as "code" and "app_test".
24
+ #
25
+ # @return Array<String> the segment type labels
26
+ def segment_types
27
+ segment_type_hash.keys
28
+ end
29
+
30
+ # Load a .sca file with an optional YAML frontmatter prefix and
31
+ # multiple file sections which can be treated differently.
32
+ #
33
+ # The file loader acts like a proc, being called with .call()
34
+ # and returning true or false for whether it has handled the
35
+ # file load. This allows chaining loaders in order and the
36
+ # first loader to recognise a file will run it.
37
+ #
38
+ # @param path [String] the file or directory to treat as a Scarpe app
39
+ # @return [Boolean] return true if the file is loaded as a segmented Scarpe app file
40
+ def call(path)
41
+ return false unless path.end_with?(".scas")
42
+
43
+ file_load(path)
44
+ true
45
+ end
46
+
47
+ # Segment type handlers can call this to perform an operation after the load
48
+ # has completed. This is important for ordering, and because loading a Shoes
49
+ # app often doesn't return. So to have a later section (e.g. tests, additional
50
+ # data) do something that affects Shoes app loading (e.g. set an env var,
51
+ # affect the display service) it's important that app loading take place later
52
+ # in the sequence.
53
+ def after_load(&block)
54
+ @after_load ||= []
55
+ @after_load << block
56
+ end
57
+
58
+ private
59
+
60
+ def gen_name(segmap)
61
+ ctr = (1..10_000).detect { |i| !segmap.key?("%5d" % i) }
62
+ "%5d" % ctr
63
+ end
64
+
65
+ def tokenize_segments(contents)
66
+ require "yaml" # Only load when needed
67
+ require "English"
68
+
69
+ segments = contents.split(/\n-{5,}/)
70
+ front_matter = {}
71
+
72
+ # The very first segment can start with front matter, or with a divider, or with no divider.
73
+ if segments[0].start_with?("---\n") || segments[0] == "---"
74
+ # We have YAML front matter at the start. All later segments will have a divider.
75
+ front_matter = YAML.load segments[0]
76
+ front_matter ||= {} # If the front matter is just the three dashes it returns nil
77
+ segments = segments[1..-1]
78
+ elsif segments[0].start_with?("-----")
79
+ # We have a divider at the start. Great! We're already well set up for this case.
80
+ elsif segments.size == 1
81
+ # No front matter, no divider, a single unnamed segment. No more parsing needed.
82
+ return [{}, { "" => segments[0] }]
83
+ else
84
+ # No front matter, no divider before the first segment, multiple segments.
85
+ # We'll add an artificial divider to the first segment for uniformity.
86
+ segments = ["-----\n" + segments[0]] + segments[1..-1]
87
+ end
88
+
89
+ segmap = {}
90
+ segments.each do |segment|
91
+ if segment =~ /\A-* +(.*?)\n/
92
+ # named segment with separator
93
+ segmap[::Regexp.last_match(1)] = ::Regexp.last_match.post_match
94
+ elsif segment =~ /\A-* *\n/
95
+ # unnamed segment with separator
96
+ segmap[gen_name(segmap)] = ::Regexp.last_match.post_match
97
+ else
98
+ raise "Internal error when parsing segments in segmented app file! seg: #{segment.inspect}"
99
+ end
100
+ end
101
+
102
+ [front_matter, segmap]
103
+ end
104
+
105
+ def file_load(path)
106
+ contents = File.read(path)
107
+
108
+ front_matter, segmap = tokenize_segments(contents)
109
+
110
+ if segmap.empty?
111
+ raise "Illegal segmented Scarpe file: must have at least one code segment, not just front matter!"
112
+ end
113
+
114
+ if front_matter[:segments]
115
+ if front_matter[:segments].size != segmap.size
116
+ raise "Number of front matter :segments must equal number of file segments!"
117
+ end
118
+ else
119
+ if segmap.size > 2
120
+ raise "Segmented files with more than two segments have to specify what they're for!"
121
+ end
122
+
123
+ # Set to default of shoes code only or shoes code and app test code.
124
+ front_matter[:segments] = segmap.size == 2 ? ["shoes", "app_test"] : ["shoes"]
125
+ end
126
+
127
+ # Match up front_matter[:segments] with the segments, or use the default of shoes and app_test.
128
+
129
+ sth = segment_type_hash
130
+ sv = segmap.values
131
+
132
+ tf_specs = []
133
+ front_matter[:segments].each.with_index do |seg_type, idx|
134
+ unless sth.key?(seg_type)
135
+ raise "Unrecognized segment type #{seg_type.inspect}! No matching segment type available!"
136
+ end
137
+
138
+ tf_specs << ["scarpe_#{seg_type}_segment_contents", sv[idx]]
139
+ end
140
+
141
+ with_tempfiles(tf_specs) do |filenames|
142
+ filenames.each.with_index do |filename, idx|
143
+ seg_name = front_matter[:segments][idx]
144
+ sth[seg_name].call(filename)
145
+ end
146
+
147
+ # Need to call @after_load hooks while tempfiles still exist
148
+ if @after_load && !@after_load.empty?
149
+ @after_load.each(&:call)
150
+ end
151
+ end
152
+ end
153
+
154
+ # The hash of segment type labels mapped to handlers which will be called.
155
+ # Normal client code shouldn't ever call this.
156
+ #
157
+ # @return Hash<String, Object> the name/handler pairs
158
+ def segment_type_hash
159
+ @segment_handlers ||= {
160
+ "shoes" => proc { |seg_file| after_load { load seg_file } },
161
+ "app_test" => proc { |seg_file| ENV["SCARPE_APP_TEST"] = seg_file },
162
+ }
163
+ end
164
+ end
165
+ end
166
+
167
+ # You can add additional segment types to the segmented file loader
168
+ # loader = Scarpe::Components::SegmentedFileLoader.new
169
+ # loader.add_segment_type "capybara", proc { |seg_file| load_file_as_capybara(seg_file) }
170
+ # Shoes.add_file_loader loader
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ require "scarpe/components/file_helpers"
8
+
9
+ module Scarpe::Test; end
10
+
11
+ # We want test failures set up once *total*, not per Minitest::Test. So an instance var
12
+ # doesn't do it.
13
+ ALREADY_SET_UP_LOGGED_TEST_FAILURES = { setup: false }
14
+
15
+ # General helpers for general usage.
16
+ # Helpers here should *not* use Webview-specific functionality.
17
+ # The intention is that these are helpers for various Scarpe display
18
+ # services that do *not* necessarily use Webview.
19
+
20
+ module Scarpe::Test::Helpers
21
+ # Very useful for tests
22
+ include Scarpe::Components::FileHelpers
23
+
24
+ # Temporarily set env vars for the block of code inside. The old environment
25
+ # variable values will be restored after the block finishes.
26
+ #
27
+ # @param envs [Hash<String,String>] A hash of environment variable names and values
28
+ def with_env_vars(envs)
29
+ old_env = {}
30
+ envs.each do |k, v|
31
+ old_env[k] = ENV[k]
32
+ ENV[k] = v
33
+ end
34
+ yield
35
+ ensure
36
+ old_env.each { |k, v| ENV[k] = v }
37
+ end
38
+ end
39
+
40
+ # This test will save extensive logs in case of test failure.
41
+ # Note that it defines setup/teardown methods. If you want
42
+ # multiple setup/teardowns from multiple places to happen you
43
+ # may need to explictly call (e.g. with logged_test_setup/teardown)
44
+ # to ensure everything you want happens.
45
+ module Scarpe::Test::LoggedTest
46
+ def self.included(includer)
47
+ class << includer
48
+ attr_accessor :logger_dir
49
+ end
50
+ end
51
+
52
+ def file_id
53
+ "#{self.class.name}_#{self.name}"
54
+ end
55
+
56
+ # This should be called by the test during setup to make sure that
57
+ # failure logs will be saved if this test fails. It makes sure the
58
+ # log config will save all logs from all sources, but keeps a copy
59
+ # of the old log config to restore after the test is finished.
60
+ #
61
+ # @return [void]
62
+ def logged_test_setup
63
+ # Make sure test failures will be saved at the end of the run.
64
+ # Delete stale test failures and logging only the *first* time this is called.
65
+ set_up_test_failures
66
+
67
+ @normal_log_config = Shoes::Log.current_log_config
68
+ Shoes::Log.configure_logger(log_config_for_test)
69
+
70
+ Shoes::Log.logger("LoggedScarpeTest").info("Test: #{self.class.name}##{self.name}")
71
+ end
72
+
73
+ # If you include this module and don't override setup/teardown, everything will
74
+ # work fine. But if you need more setup/teardown steps, you can do that too.
75
+ #
76
+ # The setup method guarantees that just including this module will do setup
77
+ # automatically. If you override it, be sure to call `super` or `logged_test_setup`.
78
+ #
79
+ # @return [void]
80
+ def setup
81
+ logged_test_setup
82
+ end
83
+
84
+ # After the test has finished, this will restore the old log configuration.
85
+ # It will also save the logfiles, but only if the test failed, not if it
86
+ # succeeded or was skipped.
87
+ #
88
+ # @return [void]
89
+ def logged_test_teardown
90
+ # Restore previous log config
91
+ Shoes::Log.configure_logger(@normal_log_config)
92
+
93
+ if self.failure
94
+ save_failure_logs
95
+ else
96
+ remove_unsaved_logs
97
+ end
98
+ end
99
+
100
+ # Make sure that, by default, #logged_test_teardown will be called for teardown.
101
+ # If a class overrides teardown, it should also call `super` or `logged_test_teardown`
102
+ # to make sure this still happens.
103
+ #
104
+ # @return [void]
105
+ def teardown
106
+ logged_test_teardown
107
+ end
108
+
109
+ # Set additional LoggedTest configuration for specific logs to separate or save.
110
+ # This is normally going to be display-service-specific log components.
111
+ # Note that this only really works with the modular logger or another logger
112
+ # that does something useful with the log config. The simple print logger
113
+ # doesn't do a lot with it.
114
+ def extra_log_config=(additional_log_config)
115
+ @additional_log_config = additional_log_config
116
+ end
117
+
118
+ # This is the log config that LoggedTests use. It makes sure all components keep all
119
+ # logs, but also splits the logs into several different files for later ease of scanning.
120
+ #
121
+ # TODO: this shouldn't directly include any Webview entries like WebviewAPI or
122
+ # CatsCradle. Those should be overridden in Webview.
123
+ #
124
+ # @return [Hash] the log config
125
+ def log_config_for_test
126
+ {
127
+ "default" => ["debug", "logger/test_failure_#{file_id}.log"],
128
+ "DisplayService" => ["debug", "logger/test_failure_events_#{file_id}.log"],
129
+ }.merge(@additional_log_config || {})
130
+ end
131
+
132
+ # The list of logfiles that should be saved. Normally this is called internally by the
133
+ # class, not externally from elsewhere.
134
+ #
135
+ # This could be a lot simpler except I want to only update the file list in one place,
136
+ # log_config_for_test(). Having a single spot should (I hope) make it a lot friendlier to
137
+ # add more logfiles for different components, logged API objects, etc.
138
+ def saved_log_files
139
+ lc = log_config_for_test
140
+ log_outfiles = lc.values.map { |_level, loc| loc }
141
+ log_outfiles.select { |s| s.start_with?("logger/") }.map { |s| s.delete_prefix("logger/") }
142
+ end
143
+
144
+ # Make sure that test failure logs will be noticed, and a message will be printed,
145
+ # if any logged tests fail. This needs to be called at least once in any Minitest-enabled
146
+ # process using logged tests.
147
+ #
148
+ # @return [void]
149
+ def set_up_test_failures
150
+ return if ALREADY_SET_UP_LOGGED_TEST_FAILURES[:setup]
151
+
152
+ log_dir = self.class.logger_dir
153
+ raise("Must set logger directory!") unless log_dir
154
+ raise("Can't find logger directory!") unless File.directory?(log_dir)
155
+
156
+ ALREADY_SET_UP_LOGGED_TEST_FAILURES[:setup] = true
157
+ # Delete stale test failures, if any, before starting the first failure-logged test
158
+ Dir["#{log_dir}/test_failure*.log"].each { |fn| File.unlink(fn) }
159
+
160
+ Minitest.after_run do
161
+ # Print test failure notice to console
162
+ unless Dir["#{log_dir}/test_failure*.out.log"].empty?
163
+ puts "Some tests have failed! See #{log_dir}/test_failure*.out.log for test logs!"
164
+ end
165
+
166
+ # Remove un-saved test logs
167
+ Dir["#{log_dir}/test_failure*.log"].each do |f|
168
+ next if f.include?(".out.log")
169
+
170
+ File.unlink(f) if File.exist?(f)
171
+ end
172
+ end
173
+ end
174
+
175
+ # Failure log output location for a given file path. This is normally used internally to this
176
+ # class, not externally.
177
+ #
178
+ # @return [String] the output path
179
+ def logfail_out_loc(filepath)
180
+ # Add a .out prefix before final .log
181
+ out_loc = filepath.gsub(%r{.log\Z}, ".out.log")
182
+
183
+ if out_loc == filepath
184
+ raise "Something is wrong! Could not figure out failure-log output path for #{filepath.inspect}!"
185
+ end
186
+
187
+ if File.exist?(out_loc)
188
+ raise "Duplicate test file #{out_loc.inspect}? This file should *not* already exist!"
189
+ end
190
+
191
+ out_loc
192
+ end
193
+
194
+ # Save the failure logs in the appropriate place(s). This is normally used internally, not externally.
195
+ #
196
+ # @return [void]
197
+ def save_failure_logs
198
+ saved_log_files.each do |log_file|
199
+ full_loc = File.expand_path("#{self.class.logger_dir}/#{log_file}")
200
+ # TODO: we'd like to skip 0-length logfiles. But also Logging doesn't flush. For now, ignore.
201
+ next unless File.exist?(full_loc)
202
+
203
+ FileUtils.mv full_loc, logfail_out_loc(full_loc)
204
+ end
205
+ end
206
+
207
+ # Remove unsaved failure logs. This is normally used internally, not externally.
208
+ #
209
+ # @return [void]
210
+ def remove_unsaved_logs
211
+ Dir["#{self.class.logger_dir}/test_failure*.log"].each do |f|
212
+ next if f.include?(".out.log") # Don't delete saved logs
213
+
214
+ File.unlink(f)
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Scarpe
4
+ module Components
5
+ VERSION = "0.2.2"
6
+ end
7
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scarpe-components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Concetto Rudilosso
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2023-08-08 00:00:00.000000000 Z
12
+ date: 2023-08-29 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description:
15
15
  email:
@@ -19,9 +19,17 @@ executables: []
19
19
  extensions: []
20
20
  extra_rdoc_files: []
21
21
  files:
22
+ - Gemfile
22
23
  - README.md
23
24
  - Rakefile
24
- - lib/scarpe/logger.rb
25
+ - lib/scarpe/components/base64.rb
26
+ - lib/scarpe/components/file_helpers.rb
27
+ - lib/scarpe/components/modular_logger.rb
28
+ - lib/scarpe/components/print_logger.rb
29
+ - lib/scarpe/components/promises.rb
30
+ - lib/scarpe/components/segmented_file_loader.rb
31
+ - lib/scarpe/components/unit_test_helpers.rb
32
+ - lib/scarpe/components/version.rb
25
33
  homepage: https://github.com/scarpe-team/scarpe
26
34
  licenses:
27
35
  - MIT
@@ -44,7 +52,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
44
52
  - !ruby/object:Gem::Version
45
53
  version: '0'
46
54
  requirements: []
47
- rubygems_version: 3.4.1
55
+ rubygems_version: 3.4.10
48
56
  signing_key:
49
57
  specification_version: 4
50
58
  summary: Reusable components for Scarpe display libraries