scarpe-components 0.1.0 → 0.2.2

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 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