brut 0.0.1 → 0.0.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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
@@ -0,0 +1,75 @@
1
+ # Based on OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter, but designed to
2
+ # log spans in a more traditional log-style format.
3
+ class Brut::Instrumentation::LoggerSpanExporter
4
+ def initialize
5
+ @stopped = false
6
+ @child_spans = {}
7
+ end
8
+
9
+ NO_PARENT = "0000000000000000"
10
+
11
+ def export(spans, timeout: nil)
12
+ if @stopped
13
+ SemanticLogger[self.class].warn "Attempt to export spans after exporter was shut down"
14
+ return failure
15
+ end
16
+
17
+ Array(spans).each do |span|
18
+ if span.hex_parent_span_id == NO_PARENT
19
+ log_span(span:,indent: 0)
20
+ elsif span.attributes["http.user_agent"]
21
+ log_span(span:,indent: 0, synthetic_attributes: { browser: true })
22
+ else
23
+ @child_spans[span.hex_parent_span_id] ||= []
24
+ @child_spans[span.hex_parent_span_id] << span
25
+ end
26
+ end
27
+
28
+ success
29
+ end
30
+
31
+ def force_flush(timeout: nil)
32
+ success
33
+ end
34
+
35
+ def shutdown(timeout: nil)
36
+ @stopped = true
37
+ if @child_spans.any?
38
+ SemanticLogger[self.class].warn "There were #{@child_spans.length} spans un-logged"
39
+ end
40
+ success
41
+ end
42
+
43
+ private
44
+
45
+ def failure = OpenTelemetry::SDK::Trace::Export::FAILURE
46
+ def success = OpenTelemetry::SDK::Trace::Export::SUCCESS
47
+
48
+ def log_span(span:,indent:, synthetic_attributes: {})
49
+ SemanticLogger.tagged(trace_id: span.hex_trace_id) do
50
+ message = (" " * indent) + span.name
51
+ params = {
52
+ timing: ((span.end_timestamp - span.start_timestamp)/1_000.0).to_i/1_000.0,
53
+ }.merge(span.attributes).merge(synthetic_attributes)
54
+
55
+ SemanticLogger[self.class].info(message, params)
56
+
57
+ previous_timestamp = span.start_timestamp
58
+ (span.events || []).each do |event|
59
+ event_message = (" " * (indent + 2)) + "event:#{event.name}"
60
+ event_params = {
61
+ timing: ((event.timestamp - previous_timestamp)/1_000.0).to_i/1_000.0
62
+ }.merge(event.attributes).merge(synthetic_attributes)
63
+ SemanticLogger[self.class].info(event_message,event_params)
64
+ previous_timestamp = event.timestamp
65
+ end
66
+
67
+ hex_span_id = span.hex_span_id
68
+ (@child_spans[hex_span_id] || []).each do |child_span|
69
+ log_span(span: child_span, indent: indent + 4)
70
+ end
71
+ @child_spans.delete(hex_span_id)
72
+ end
73
+ end
74
+
75
+ end
@@ -0,0 +1,107 @@
1
+ class Brut::Instrumentation::OpenTelemetry
2
+ # Create a span around the given block of code.
3
+ #
4
+ # @param [String] name the name of the span. Should be specific to the code being wrapped, but not contain dynamic information. For
5
+ # example, you could call this the method name, but should not include parameters in the name.
6
+ # @param [Hash<String|Symbol,Object>] attributes Hash of attributes to include in this span. This is as if you called
7
+ # {Brut::Instrumentation::OpenTelemetry::Span#add_attributes} as the first line of the block. See that method for more details on
8
+ # the contents of this hash.
9
+ #
10
+ # @yield [Brut::Instrumentation::OpenTelemetry::Span] executes this block in the context of a new OpenTelemetry span. yields
11
+ # the span so you can call further methods on it.
12
+ # @yieldparam span [Brut::Instrumentation::OpenTelemetry::Span]
13
+ # @yieldreturn [Object] Whatever is returned from the block is returned by this method
14
+ # @return [Object] Whatever is returned from the block, unless an exception is raised.
15
+ # @raise [Exception] if the block raises an exception, that exception will be raised, however `record_exception` will be called.
16
+ #
17
+ def span(name,**attributes,&block)
18
+ result = nil
19
+ Brut.container.tracer.in_span(name) do |span|
20
+ wrapped_span = Span.new(span)
21
+ wrapped_span.add_attributes(attributes)
22
+ begin
23
+ result = block.(wrapped_span)
24
+ rescue => ex
25
+ span.record_exception(ex)
26
+ raise
27
+ end
28
+ end
29
+ result
30
+ end
31
+
32
+ # Adds an event to the current span
33
+ # @param [String] name the name of the event. Should not contain dynamic information.
34
+ # @param [Hash] attributes any attributes to attach to the event.
35
+ def add_event(name,**attributes)
36
+ explicit_attributes = attributes.delete(:attributes) || {}
37
+ timestamp = attributes.delete(:timestamp)
38
+ current_span = OpenTelemetry::Trace.current_span
39
+ current_span.add_event(name,
40
+ attributes: NormalizedAttributes.new(nil,attributes.merge(explicit_attributes)).to_h,
41
+ timestamp:)
42
+ end
43
+
44
+ def record_exception(ex,attributes=nil)
45
+ current_span = OpenTelemetry::Trace.current_span
46
+ current_span.record_exception(ex,attributes: NormalizedAttributes.new(nil,attributes).to_h)
47
+ end
48
+
49
+ # Adds attributes to the current span
50
+ # @param [Hash] attributes any attributes to attach to the event.
51
+ def add_attributes(attributes)
52
+ current_span = OpenTelemetry::Trace.current_span
53
+ current_span.add_attributes(NormalizedAttributes.new(nil,attributes).to_h)
54
+ end
55
+
56
+ class NormalizedAttributes
57
+ def initialize(prefix,attributes)
58
+ prefix = if prefix
59
+ "#{prefix}."
60
+ else
61
+ ""
62
+ end
63
+ @attributes = (attributes || {}).map { |key,value|
64
+ [ "#{prefix}#{key}", normalize_value(value) ]
65
+ }.to_h
66
+ end
67
+
68
+ def to_h
69
+ @attributes
70
+ end
71
+
72
+ private
73
+
74
+ def normalize_value(value)
75
+ case value
76
+ when String then value
77
+ when Numeric then value
78
+ when true then true
79
+ when false then false
80
+ when Array then value.map { normalize_value(it) }
81
+ else
82
+ value.to_s
83
+ end
84
+ end
85
+ end
86
+
87
+ class Span < SimpleDelegator
88
+
89
+ # Adds attributes to the span, converting the hash or keyword arguments to strings.
90
+ #
91
+ # @param [Hash] attributes a hash of the attributes to add. Keys will be converted to strings via `to_s`.
92
+ # Values will be converted via {Brut::Instrumentation::OpenTelemetry::NormalizedAttributes}, which preserves strings, numbers, and
93
+ # booleans, and converts the rest to strings via `to_s`.
94
+ def add_attributes(attributes)
95
+ add_prefixed_attributes(nil,attributes)
96
+ end
97
+
98
+ # Adds attributes to the span, prefixing each key with the given prefix, then converting the hash or keyword arguments to strings.
99
+ #
100
+ # @see #add_attributes
101
+ def add_prefixed_attributes(prefix,attributes)
102
+ __getobj__.add_attributes(
103
+ NormalizedAttributes.new(prefix,attributes).to_h
104
+ )
105
+ end
106
+ end
107
+ end
@@ -1,11 +1,9 @@
1
1
  module Brut::Instrumentation
2
- autoload(:Basic,"brut/instrumentation/basic")
3
- autoload(:Subscriber,"brut/instrumentation/subscriber")
4
- autoload(:Event,"brut/instrumentation/event")
5
- autoload(:HTTPEvent,"brut/instrumentation/http_event")
2
+ autoload(:OpenTelemetry,"brut/instrumentation/open_telemetry")
3
+ autoload(:LoggerSpanExporter,"brut/instrumentation/logger_span_exporter")
6
4
 
7
- def instrument(**args,&block)
8
- Brut.container.instrumentation.instrument(Brut::Instrumentation::Event.new(**args),&block)
5
+ def span(name,**attributes,&block)
6
+ Brut.container.instrumentation.span(name,**attributes,&block)
9
7
  end
10
8
  end
11
9
 
@@ -1,31 +1,63 @@
1
1
  require "tzinfo"
2
+ # Models a clock, which is a time in the context of a time zone. This theoretically makes it easier to get the time and date at the time zone of the user.
2
3
  class Clock
3
- def initialize(tzinfo_timezone)
4
+ attr_reader :timezone
5
+ # Create a clock in the given timezone. If `tzinfo_timezone` is non-`nil`, that value is the time zone of the clock, and all `Time`
6
+ # instances returned will be in that time zone. If `tzinfo_timezone` is `nil`, then `ENV["TZ"]` is consulted. If the value of that
7
+ # environment variable is a valid timezone, it is used. Otherwise, UTC is used.
8
+ #
9
+ # @param [TZInfo::Timezone] tzinfo_timezone if present, this is the timezone of the clock.
10
+ # @param [Time] now if omitted, uses `Time.now` when asked the current time. Otherwises, uses this value, as a `Time` for
11
+ # now. Don't do this unless you are testing.
12
+ def initialize(tzinfo_timezone, now: nil)
4
13
  if tzinfo_timezone
5
14
  @timezone = tzinfo_timezone
6
15
  elsif ENV["TZ"]
7
16
  @timezone = begin
8
17
  TZInfo::Timezone.get(ENV["TZ"])
9
18
  rescue TZInfo::InvalidTimezoneIdentifier => ex
10
- SemanticLogger[self.class.name].warn("#{ex} from ENV['TZ'] value '#{ENV['TZ']}'")
19
+ Brut.container.instrumentation.record_exception(ex, class: self.class, invalid_env_tz: ENV['TZ'])
11
20
  nil
12
21
  end
13
22
  end
14
23
  if @timezone.nil?
15
24
  @timezone = TZInfo::Timezone.get("UTC")
16
25
  end
26
+ @now = now
17
27
  end
18
28
 
29
+ # Get the current time in the configured timezone, unless `now:` was used in the constructor, in which case *that* timestamp is
30
+ # returned in the configured time zone.
31
+ #
32
+ # @return [Time] the time now in the time zone of this clock
19
33
  def now
20
- Time.now(in: @timezone)
34
+ if @now
35
+ self.in_time_zone(@now)
36
+ else
37
+ Time.now(in: @timezone)
38
+ end
21
39
  end
22
40
 
41
+ def today
42
+ self.now.to_date
43
+ end
44
+
45
+ # Convert the given time to this clock's time zone
46
+ # @param [Time] time a timestamp you wish to conver to this clock's time zone
47
+ # @return [Time] a new `Time` in the timezone of this clock.
23
48
  def in_time_zone(time)
24
49
  @timezone.to_local(time)
25
50
  end
26
51
  end
27
52
 
53
+ # A wrapper around a string to avoid adding a ton of methods to `String`.
28
54
  class RichString
55
+ def self.from_string(string,blank_is_nil:true)
56
+ if string.to_s.strip == "" && blank_is_nil
57
+ return nil
58
+ end
59
+ self.new(string)
60
+ end
29
61
  def initialize(string)
30
62
  @string = string.to_s
31
63
  end
@@ -41,16 +73,34 @@ class RichString
41
73
 
42
74
  def camelize
43
75
  @string.to_s.split(/[_-]/).map { |part|
44
- part.capitalize
76
+ RichString.new(part).capitalize(:first_only).to_s
45
77
  }.join("")
46
78
  end
47
79
 
80
+ # Capitalizes the string, with the ability to only capitalize the first letter.
81
+ #
82
+ # If `options` includes `:first_only`, then only the first letter of the string is capitalized. The remaining letters are left
83
+ # alone. If `option` does not include `:first_only`, this capitalizes like Ruby's standard library, which is to lower case all
84
+ # letters save for the first.
85
+ #
86
+ # @param [Array] options options suitable for Ruby's built-in `String#capitalize` method
87
+ # @return [RichString] a new string where the wrapped string has been capitalized
88
+ def capitalize(*options)
89
+ if options.include?(:first_only)
90
+ options.delete(:first_only)
91
+ self.class.new(@string[0].capitalize(*options) + @string[1..-1])
92
+ else
93
+ self.class.new(@string.capitalize(*options))
94
+ end
95
+ end
96
+
48
97
  def humanized
49
98
  RichString.new(@string.tr("_-"," "))
50
99
  end
51
100
 
52
101
  def to_s = @string
53
102
  def to_str = self.to_s
103
+ def length = to_s.length
54
104
 
55
105
  def to_s_or_nil = @string.empty? ? nil : self.to_s
56
106
 
@@ -7,6 +7,8 @@ module Brut::SinatraHelpers
7
7
  sinatra_app.set :public_folder, Brut.container.public_root_dir
8
8
  sinatra_app.path("/__brut/csp-reporting",method: :post)
9
9
  sinatra_app.path("/__brut/locale_detection",method: :post)
10
+ sinatra_app.path("/__brut/instrumentation",method: :get)
11
+ sinatra_app.set :host_authorization, permitted_hosts: Brut.container.permitted_hosts
10
12
  end
11
13
 
12
14
  # @private
@@ -63,16 +65,19 @@ module Brut::SinatraHelpers
63
65
  Brut.container.routing.register_page(path)
64
66
 
65
67
  get path do
66
- Brut.container.instrumentation.instrument(Brut::Instrumentation::HTTPEvent.new(name: :get_page, http_method: "GET", path: path )) do
67
- route = Brut.container.routing.for(path: path,method: :get)
68
- page_class = route.handler_class
68
+ route = Brut.container.routing.for(path: path,method: :get)
69
+ page_class = route.handler_class
70
+ Brut.container.instrumentation.span(page_class.name, path: path) do |span|
69
71
  request_context = Thread.current.thread_variable_get(:request_context)
70
72
  constructor_args = request_context.as_constructor_args(
71
73
  page_class,
72
74
  request_params: params,
75
+ route: route,
73
76
  )
77
+ span.add_prefixed_attributes("initializer.args", constructor_args.map { |k,v| [k.to_s,v.class.name] }.to_h)
74
78
  page_instance = page_class.new(**constructor_args)
75
79
  result = page_instance.handle!
80
+ span.add_attributes(result_class: result.class)
76
81
  case result
77
82
  in URI => uri
78
83
  redirect to(uri.to_s)
@@ -140,41 +145,40 @@ module Brut::SinatraHelpers
140
145
  path = original_brut_route.path_template
141
146
 
142
147
  route method, path do
143
- Brut.container.instrumentation.instrument(Brut::Instrumentation::HTTPEvent.new(name: type, http_method: method, path: path)) do
144
- brut_route = Brut.container.routing.for(path:,method:)
145
-
146
- handler_class = brut_route.handler_class
147
- form_class = brut_route.respond_to?(:form_class) ? brut_route.form_class : nil
148
-
149
- request_context = Thread.current.thread_variable_get(:request_context)
150
- handler = handler_class.new
151
- form = if form_class.nil?
152
- nil
153
- else
154
- form_class.new(params: params)
155
- end
156
-
157
- process_args = request_context.as_method_args(handler,:handle,request_params: params,form: form)
158
-
159
- result = handler.handle!(**process_args)
160
-
161
- case result
162
- in URI => uri
163
- redirect to(uri.to_s)
164
- in Brut::FrontEnd::Component => component_instance
165
- render_html(component_instance).to_s
166
- in [ Brut::FrontEnd::Component => component_instance, Brut::FrontEnd::HttpStatus => http_status ]
167
- [
168
- http_status.to_i,
169
- render_html(component_instance).to_s,
170
- ]
171
- in Brut::FrontEnd::HttpStatus => http_status
172
- http_status.to_i
173
- in Brut::FrontEnd::Download => download
174
- [ 200, download.headers, download.data ]
175
- else
176
- raise NoMatchingPatternError, "Result from #{handler.class}'s handle! method was a #{result.class}, which cannot be used to understand the response to generate"
177
- end
148
+ # This must be re-looked up per-request do allow reloading to work
149
+ brut_route = Brut.container.routing.for(path:,method:)
150
+
151
+ handler_class = brut_route.handler_class
152
+ form_class = brut_route.respond_to?(:form_class) ? brut_route.form_class : nil
153
+
154
+ request_context = Thread.current.thread_variable_get(:request_context)
155
+ handler = handler_class.new
156
+ form = if form_class.nil?
157
+ nil
158
+ else
159
+ form_class.new(params: params)
160
+ end
161
+
162
+ process_args = request_context.as_method_args(handler,:handle,request_params: params,form: form,route:brut_route)
163
+
164
+ result = handler.handle!(**process_args)
165
+
166
+ case result
167
+ in URI => uri
168
+ redirect to(uri.to_s)
169
+ in Brut::FrontEnd::Component => component_instance
170
+ render_html(component_instance).to_s
171
+ in [ Brut::FrontEnd::Component => component_instance, Brut::FrontEnd::HttpStatus => http_status ]
172
+ [
173
+ http_status.to_i,
174
+ render_html(component_instance).to_s,
175
+ ]
176
+ in Brut::FrontEnd::HttpStatus => http_status
177
+ http_status.to_i
178
+ in Brut::FrontEnd::Download => download
179
+ [ 200, download.headers, download.data ]
180
+ else
181
+ raise NoMatchingPatternError, "Result from #{handler.class}'s handle! method was a #{result.class}, which cannot be used to understand the response to generate"
178
182
  end
179
183
  end
180
184
  end
@@ -0,0 +1,6 @@
1
+ module Brut::SpecSupport::ClockSupport
2
+ def real_clock = Clock.new(TZInfo::Timezone.get("UTC"))
3
+ def clock_at(now:)
4
+ Clock.new(TZInfo::Timezone.get("UTC"), now: Time.parse(now))
5
+ end
6
+ end
@@ -1,42 +1,69 @@
1
1
  require_relative "flash_support"
2
+ require_relative "session_support"
3
+ require_relative "clock_support"
4
+ require_relative "enhanced_node"
2
5
  module Brut::SpecSupport::ComponentSupport
3
6
  include Brut::SpecSupport::FlashSupport
7
+ include Brut::SpecSupport::SessionSupport
8
+ include Brut::SpecSupport::ClockSupport
4
9
  include Brut::I18n::ForHTML
5
10
 
6
- def render_and_parse(component,&block)
11
+ # Render a component into its text representation. This mimics what happens when a component is used
12
+ # inside a template. You typically don't want this, but should use {#render_and_parse}, since that will
13
+ # parse the HTML.
14
+ def render(component,&block)
7
15
  if component.kind_of?(Brut::FrontEnd::Page)
8
16
  if !block.nil?
9
17
  raise "pages do not accept blocks - do not pass one to render_and_parse"
10
18
  end
11
- result = component.handle!
12
- case result
13
- in String => html
14
- Nokogiri::HTML5(html)
15
- else
16
- result
17
- end
19
+ component.handle!
18
20
  else
19
21
  component.yielded_block = block
20
- rendered_text = component.render
21
- document = Nokogiri::HTML5(rendered_text)
22
- component_html = document.css("body")
23
- if component_html
24
- non_blank_text_elements = component_html.children.select { |element|
25
- if element.kind_of?(Nokogiri::XML::Text) && element.text.to_s.strip == ""
26
- false
27
- else
28
- true
29
- end
30
- }
31
- if non_blank_text_elements.size == 1
32
- non_blank_text_elements[0]
33
- else
34
- raise "#{component.class} rendered #{non_blank_text_elements.size} elements other than blank text:\n\n#{non_blank_text_elements.map(&:name)}. Components should render a single element:\n#{rendered_text}"
35
- end
36
- else
37
- raise "#{component.class} did not render HTML properly: #{rendered_text}"
22
+ component.render
23
+ end
24
+ end
25
+
26
+ # Render a component and parse it into a Nokogiri Node for examination.
27
+ #
28
+ # @example
29
+ #
30
+ # result = render_and_parse(HeaderComponent.new(title: "Hello!")
31
+ # expect(result.e!("h1").text).to eq("Hello!")
32
+ #
33
+ # @example Using context
34
+ # result = render_and_parse(TableRow.new([ "one", "two" ]), context: "tbody")
35
+ #
36
+ # @param [Brut::FrontEnd::Component] component the component instance you wish to render. This should be set up to simulate the test
37
+ # you are running.
38
+ # @yield if the component requires or accepts a yielded block, this is how you do that in the test.
39
+ # @return [Brut::SpecSupport::EnhancedNode] a wrapper around a Nokogiri node to provide convienience methods.
40
+ def render_and_parse(component,&block)
41
+ rendered_text = render(component,&block)
42
+ if !rendered_text.kind_of?(String) && !rendered_text.kind_of?(Brut::FrontEnd::Templates::HTMLSafeString)
43
+ raise "#{component.class} returned a #{rendered_text.class} - you should not attempt to parse this. Instead, call render(component)"
44
+ end
45
+ nokogiri_node = Nokogiri::HTML5(rendered_text)
46
+ if !component.kind_of?(Brut::FrontEnd::Page)
47
+ nokogiri_node = Nokogiri::HTML5.fragment(rendered_text.to_s.chomp, max_errors: 100, context: "template")
48
+ if nokogiri_node.errors.any?
49
+ raise "#{component.class} render invalid HTML:\n\n#{rendered_text}\n\nErrors: #{nokogiri_node.errors.join(", ")}"
50
+ end
51
+
52
+ non_blank_text_elements = nokogiri_node.children.select { |element|
53
+ is_text = element.kind_of?(Nokogiri::XML::Text)
54
+ is_blank = element.text.to_s.strip == ""
55
+
56
+ is_blank_text = is_text && is_blank
57
+
58
+ !is_blank_text
59
+ }
60
+
61
+ if non_blank_text_elements.size != 1
62
+ raise "#{component.class} rendered #{non_blank_text_elements.size} elements other than blank text:\n\n#{non_blank_text_elements.map(&:name)}. Components should render a single element:\n#{rendered_text}"
38
63
  end
64
+ nokogiri_node = non_blank_text_elements[0]
39
65
  end
66
+ Brut::SpecSupport::EnhancedNode.new(nokogiri_node)
40
67
  end
41
68
 
42
69
  def routing_for(klass,**args)
@@ -0,0 +1,82 @@
1
+ # Manages running the app in test mode for the purposes of running End-to-End tests against it.
2
+ class Brut::SpecSupport::E2ETestServer
3
+ include SemanticLogger::Loggable
4
+ def self.instance
5
+ @instance ||= self.new(bin_dir: Brut.container.project_root / "bin")
6
+ end
7
+
8
+ # Create the test server, which will run various Brut dev commands
9
+ # from the given bin dir
10
+ #
11
+ # @param [Pathname] bin_dir path to where the app's Brut-provide CLI apps are installed
12
+ def initialize(bin_dir:)
13
+ @bin_dir = bin_dir
14
+ @pid = nil
15
+ end
16
+
17
+ # Starts the server. Returns when the server has started
18
+ def start
19
+ if !@pid.nil?
20
+ logger.warn "Server is already running on pid '#{@pid}'"
21
+ return
22
+ end
23
+ Bundler.with_unbundled_env do
24
+ command = "#{@bin_dir}/test-server"
25
+ logger.info "Starting test server via '#{command}'"
26
+ @pid = Process.spawn(
27
+ command,
28
+ pgroup: true # We want this in its own process group, so we can
29
+ # more reliably kill it later on
30
+ )
31
+ logger.info "Starting with pid '#{@pid}'"
32
+ end
33
+ if is_port_open?("0.0.0.0",6503)
34
+ logger.info "Server is listening for requests on port 6503"
35
+ else
36
+ raise "Problem: server never started"
37
+ end
38
+ end
39
+
40
+ # Stops the server
41
+ def stop
42
+ if @pid.nil?
43
+ logger.warn "Server is already stopped"
44
+ return
45
+ end
46
+ logger.info "Stopping server nicely with TERM of pid '#{@pid}'"
47
+ Process.kill("-TERM",@pid) # The '-' is to kill the process group, not just the pid
48
+ begin
49
+ Timeout.timeout(4) do
50
+ Process.wait(@pid)
51
+ end
52
+ rescue Timeout::Error
53
+ logger.warn "Server still active after 4 seconds. Trying KILL on pid '#{@pid}'"
54
+ Process.kill("-KILL",@pid)
55
+ end
56
+ @pid = nil
57
+ end
58
+
59
+ private
60
+
61
+ def is_port_open?(ip, port)
62
+ begin
63
+ Timeout::timeout(5) do
64
+ loop do
65
+ begin
66
+ logger.debug "Attemping to conenct to '#{ip}' on port '#{port}'"
67
+ s = TCPSocket.new(ip, port)
68
+ s.close
69
+ logger.debug "Connection accepted - server should be up!"
70
+ return true
71
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
72
+ logger.debug "Connection refused - server must still be starting"
73
+ sleep(0.1)
74
+ end
75
+ end
76
+ rescue Timeout::Error
77
+ end
78
+ false
79
+ end
80
+ end
81
+ end
82
+
@@ -0,0 +1,45 @@
1
+ require "delegate"
2
+
3
+ class Brut::SpecSupport::EnhancedNode < SimpleDelegator
4
+ include RSpec::Matchers
5
+
6
+ # Return the only Nokogiri::XML::Node for the given CSS selector, if it exists.
7
+ # If the selector matches more than one element, the test fails. If the selector
8
+ # matches one element, it is returned, and nil is returned if no elements match.
9
+ def e(css_selector)
10
+ element = css(css_selector)
11
+ if (element.kind_of?(Nokogiri::XML::NodeSet))
12
+ expect(element.length).to be < 2
13
+ return Brut::SpecSupport::EnhancedNode.new(element.first)
14
+ else
15
+ expect([Nokogiri::XML::Node, Nokogiri::XML::Element]).to include(element.class)
16
+ return Brut::SpecSupport::EnhancedNode.new(element)
17
+ end
18
+ end
19
+
20
+ # Assert exactly one Nokogiri::XML::Node exists for the given CSS selector and return it. If there is not
21
+ # exactly one matching node, the test fails.
22
+ def e!(css_selector)
23
+ element = css(css_selector)
24
+ if (element.kind_of?(Nokogiri::XML::NodeSet))
25
+ expect(element.length).to eq(1),"#{css_selector} matched #{element.length} elements, not exactly 1:\n\n#{to_html}"
26
+ return Brut::SpecSupport::EnhancedNode.new(element.first)
27
+ else
28
+ expect([Nokogiri::XML::Node, Nokogiri::XML::Element]).to include(element.class)
29
+ return Brut::SpecSupport::EnhancedNode.new(element)
30
+ end
31
+ end
32
+
33
+ # Return ths first Nokogiri::XML::Node for the given CSS selector. If there are no
34
+ # matching nodes, the test fails.
35
+ def first!(css_selector)
36
+ element = css(css_selector)
37
+ if (element.kind_of?(Nokogiri::XML::NodeSet))
38
+ return Brut::SpecSupport::EnhancedNode.new(element.first)
39
+ else
40
+ expect([Nokogiri::XML::Node, Nokogiri::XML::Element]).to include(element.class)
41
+ return Brut::SpecSupport::EnhancedNode.new(element)
42
+ end
43
+ end
44
+
45
+ end
@@ -4,9 +4,20 @@ module Brut::SpecSupport::GeneralSupport
4
4
  end
5
5
 
6
6
  module ClassMethods
7
- def implementation_is_trivial
8
- it "has no tests because the implementation is trivial" do
9
- expect(true).to eq(true)
7
+ def implementation_is_trivial(check_again_at: nil)
8
+ check_again_at = if check_again_at.nil?
9
+ nil
10
+ elsif check_again_at.kind_of?(Time)
11
+ check_again_at
12
+ else
13
+ check_again_at = Date.parse(check_again_at).to_time
14
+ end
15
+ it "has no tests because the implementation is trivial#{check_again_at.nil? ? '' : ' for now'}" do
16
+ if check_again_at.nil?
17
+ expect(true).to eq(true)
18
+ else
19
+ expect(Time.now < check_again_at).to eq(true),"I'ts after #{check_again_at}. Check that the implementation of the class under test is still trivial. If it is, update or remove check_again_at:"
20
+ end
10
21
  end
11
22
  end
12
23
  def implementation_is_covered_by_other_tests(description)