brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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)