brut 0.0.13 → 0.0.21

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -6
  3. data/brut.gemspec +1 -3
  4. data/lib/brut/back_end/seed_data.rb +19 -2
  5. data/lib/brut/back_end/sidekiq/middlewares/server.rb +2 -1
  6. data/lib/brut/back_end/sidekiq/middlewares.rb +2 -1
  7. data/lib/brut/back_end/sidekiq.rb +2 -1
  8. data/lib/brut/back_end/validator.rb +5 -1
  9. data/lib/brut/back_end.rb +9 -0
  10. data/lib/brut/cli/apps/scaffold.rb +16 -24
  11. data/lib/brut/cli.rb +4 -3
  12. data/lib/brut/factory_bot.rb +0 -5
  13. data/lib/brut/framework/app.rb +70 -5
  14. data/lib/brut/framework/config.rb +9 -46
  15. data/lib/brut/framework/container.rb +3 -2
  16. data/lib/brut/framework/errors.rb +12 -4
  17. data/lib/brut/framework/mcp.rb +63 -2
  18. data/lib/brut/framework/project_environment.rb +6 -2
  19. data/lib/brut/framework.rb +1 -1
  20. data/lib/brut/front_end/asset_path_resolver.rb +15 -0
  21. data/lib/brut/front_end/component.rb +101 -246
  22. data/lib/brut/front_end/components/constraint_violations.rb +10 -10
  23. data/lib/brut/front_end/components/form_tag.rb +17 -29
  24. data/lib/brut/front_end/components/i18n_translations.rb +12 -13
  25. data/lib/brut/front_end/components/input.rb +0 -1
  26. data/lib/brut/front_end/components/inputs/csrf_token.rb +3 -3
  27. data/lib/brut/front_end/components/inputs/radio_button.rb +6 -6
  28. data/lib/brut/front_end/components/inputs/select.rb +13 -20
  29. data/lib/brut/front_end/components/inputs/text_field.rb +18 -33
  30. data/lib/brut/front_end/components/inputs/textarea.rb +11 -26
  31. data/lib/brut/front_end/components/locale_detection.rb +2 -2
  32. data/lib/brut/front_end/components/page_identifier.rb +3 -5
  33. data/lib/brut/front_end/components/{time.rb → time_tag.rb} +14 -11
  34. data/lib/brut/front_end/components/traceparent.rb +5 -6
  35. data/lib/brut/front_end/http_method.rb +4 -0
  36. data/lib/brut/front_end/inline_svg_locator.rb +21 -0
  37. data/lib/brut/front_end/layout.rb +19 -0
  38. data/lib/brut/front_end/page.rb +52 -40
  39. data/lib/brut/front_end/request_context.rb +13 -0
  40. data/lib/brut/front_end/routing.rb +8 -3
  41. data/lib/brut/front_end.rb +32 -0
  42. data/lib/brut/i18n/base_methods.rb +51 -11
  43. data/lib/brut/i18n/for_back_end.rb +8 -0
  44. data/lib/brut/i18n/for_cli.rb +5 -1
  45. data/lib/brut/i18n/for_html.rb +9 -1
  46. data/lib/brut/i18n/http_accept_language.rb +47 -0
  47. data/lib/brut/i18n.rb +1 -0
  48. data/lib/brut/instrumentation/open_telemetry.rb +25 -0
  49. data/lib/brut/instrumentation.rb +3 -5
  50. data/lib/brut/sinatra_helpers.rb +13 -7
  51. data/lib/brut/spec_support/component_support.rb +27 -13
  52. data/lib/brut/spec_support/e2e_support.rb +4 -0
  53. data/lib/brut/spec_support/general_support.rb +3 -0
  54. data/lib/brut/spec_support/handler_support.rb +6 -1
  55. data/lib/brut/spec_support/matcher.rb +1 -0
  56. data/lib/brut/spec_support/matchers/be_page_for.rb +1 -0
  57. data/lib/brut/spec_support/matchers/have_html_attribute.rb +1 -0
  58. data/lib/brut/spec_support/matchers/have_i18n_string.rb +3 -1
  59. data/lib/brut/spec_support/matchers/have_link_to.rb +1 -0
  60. data/lib/brut/spec_support/matchers/have_redirected_to.rb +1 -0
  61. data/lib/brut/spec_support/matchers/have_rendered.rb +1 -0
  62. data/lib/brut/spec_support/matchers/have_returned_rack_response.rb +44 -0
  63. data/lib/brut/spec_support/rspec_setup.rb +1 -0
  64. data/lib/brut/spec_support.rb +5 -4
  65. data/lib/brut/version.rb +1 -1
  66. data/lib/brut.rb +7 -50
  67. metadata +14 -49
  68. data/doc-src/architecture.md +0 -102
  69. data/doc-src/assets.md +0 -98
  70. data/doc-src/forms.md +0 -214
  71. data/doc-src/handlers.md +0 -83
  72. data/doc-src/javascript.md +0 -265
  73. data/doc-src/keyword-injection.md +0 -183
  74. data/doc-src/pages.md +0 -210
  75. data/doc-src/route-hooks.md +0 -59
  76. data/lib/brut/front_end/template.rb +0 -47
  77. data/lib/brut/front_end/templates/block_filter.rb +0 -61
  78. data/lib/brut/front_end/templates/erb_engine.rb +0 -26
  79. data/lib/brut/front_end/templates/erb_parser.rb +0 -84
  80. data/lib/brut/front_end/templates/escapable_filter.rb +0 -20
  81. data/lib/brut/front_end/templates/html_safe_string.rb +0 -68
  82. data/lib/brut/front_end/templates/locator.rb +0 -60
@@ -1,6 +1,11 @@
1
- # Interface for translations. This is prefered over using Ruby's I18n directly.
2
- # This is intended to be mixed-in to any class that requires this, so that you can more
3
- # expediently access the `t` method.
1
+ # Interface for translations, preferred over Ruby's I18n classes. Note that this is a
2
+ # base module and not intended to be directly used in your classes. Include one of
3
+ # the other modules in this namespace:
4
+ #
5
+ # * {Brut::I18n::ForHTML} for components or pages, or anything use Phlex
6
+ # * {Brut::I18n::ForCLI} for CLI apps
7
+ # * {Brut::I18n::ForBackEnd} for back-end classes that aren't generating HTML
8
+ #
4
9
  module Brut::I18n::BaseMethods
5
10
 
6
11
  # Access a translation and insert interpolated elemens as needed. This will use the provided key to determine
@@ -18,12 +23,12 @@ module Brut::I18n::BaseMethods
18
23
  # to `t("email.required", field: "E-mail address")` would generate `"E-mail address is required"`.
19
24
  #
20
25
  # @param [String,Symbol,Array<String>,Array<Symbol>] key used to create one or more keys to be translated.
21
- # This value's behavior is designed to a balance predictabilitiy in what actual key is chosen
26
+ # This value's behavior is designed to balance predictabilitiy in what actual key is chosen
22
27
  # but without needless repetition on a page. If this value is provided, and is an array, the values
23
28
  # are joined with "." to form a key. If the value is not an array, that value is used directly.
24
29
  # Given this key, two values are checked for a translation: the key itself and
25
30
  # the key inside "general.". If this value is *not* provided, it is expected
26
- # taht the `**rest` hash includes page: or component:. See that parameter and the example.
31
+ # that the `**rest` hash includes page: or component:. See that parameter and the example.
27
32
  #
28
33
  # @param [Hash] rest values to use for interpolation of the key's translation. If `key` is omitted,
29
34
  # this hash should have a value for either `page:` or `component:` (not both). If
@@ -34,6 +39,16 @@ module Brut::I18n::BaseMethods
34
39
  # Note that if the page– or component–specific key is not found, this will check
35
40
  # `general.«page: value»`.
36
41
  # @option interpolated_values [Numeric] count Special interpolation to control pluralization.
42
+ # @yield Nothing is yielded if a block is given, however the value returned is used for the `%{block}`
43
+ # interpolation value.
44
+ # @yieldreturn [String] The value to use for the `%{block}` interpolation value. There is some nuance to
45
+ # how this works. The value returned is given to `capture`, and that value
46
+ # is given to `safe`. Outside of an HTML-rendering context, these methods
47
+ # simply pass through the contents of the block. In an HTML-rendering
48
+ # context, however, these methods are assumed to be from
49
+ # [`Phlex::HTML`](https://phlex.fun). `capture` will create a new Phlex
50
+ # context and capture any HTML built inside the block. That HTML is assumed
51
+ # to be safe, thus `safe` is called to communicate this to Phlex.
37
52
  #
38
53
  # @raise [I18n::MissingTranslation] if no translation is found
39
54
  # @raise [I18n::MissingInterpolationArgument] if interpolation arguments are missing, or if the key
@@ -84,7 +99,7 @@ module Brut::I18n::BaseMethods
84
99
  # t(page: :new_widget) # => Create new Widget
85
100
  # # in your code for WidgetsPage
86
101
  # t(page: :new_widget) # => Create New
87
- # # in your code for SomeOtherEPage
102
+ # # in your code for SomeOtherPage
88
103
  # t(page: :new_widget) # => Make a New Widget
89
104
  #
90
105
  # @example Using page: with an array
@@ -101,7 +116,31 @@ module Brut::I18n::BaseMethods
101
116
  # }
102
117
  # # in your code for HomePage
103
118
  # t(page: [ :captions, :new ]) # => New Widgets
104
- def t(key=:look_in_rest,**rest)
119
+ #
120
+ # @example Using a block with Phlex
121
+ # # in your translations file
122
+ # en: {
123
+ # greeting: "Hello there %{name}, you may %{block}",
124
+ # }
125
+ # # Inside a component where
126
+ # # Brut::I18n::ForHTML has been included
127
+ # def view_template
128
+ # h1 do
129
+ # raw(t(:greeting), name: user.name) do
130
+ # a(href: "https://support.example.com") do
131
+ # "contact support"
132
+ # end
133
+ # end
134
+ # end
135
+ # end
136
+ # # This will produce this HTML, assuming user.name is "Pat":
137
+ # <h1>
138
+ # Hell there Pat, you may
139
+ # <a href="https://support.example.com">
140
+ # contact support
141
+ # </a>
142
+ # </h1>
143
+ def t(key=:look_in_rest,**rest,&block)
105
144
  if key == :look_in_rest
106
145
 
107
146
  page = rest.delete(:page)
@@ -126,13 +165,14 @@ module Brut::I18n::BaseMethods
126
165
  key = Array(key).join('.')
127
166
  key = [key,"general.#{key}"]
128
167
  end
129
- if block_given?
168
+ if !block.nil?
130
169
  if rest[:block]
131
170
  raise ArgumentError,"t was given a block and a block: param. You can't do both "
132
171
  end
133
- rest[:block] = html_safe(yield.to_s.strip)
172
+ block_contents = safe(capture(&block))
173
+ rest[:block] = block_contents
134
174
  end
135
- html_safe(t_direct(key,**rest))
175
+ t_direct(key,**rest)
136
176
  rescue I18n::MissingInterpolationArgument => ex
137
177
  if ex.key.to_s == "block"
138
178
  raise ArgumentError,"One of the keys #{key.join(", ")} contained a %{block} interpolation value: '#{ex.string}'. This means you must use t_html *and* yield a block to it"
@@ -168,7 +208,7 @@ module Brut::I18n::BaseMethods
168
208
  }
169
209
  escaped_interpolated_values = interpolated_values.map { |key,value|
170
210
  if value.kind_of?(String)
171
- [ key, Brut::FrontEnd::Template.escape_html(value) ]
211
+ [ key, CGI.escapeHTML(value) ]
172
212
  else
173
213
  [ key, value ]
174
214
  end
@@ -0,0 +1,8 @@
1
+ # Use this to access translations in any back-end code.
2
+ # This implementation does support blocks yielded to {#t}, however
3
+ # their values are not necessarily HTML-escaped.
4
+ module Brut::I18n::ForBackEnd
5
+ include Brut::I18n::BaseMethods
6
+ def safe(string) = string
7
+ def capture(&block) = block.()
8
+ end
@@ -1,4 +1,8 @@
1
+ # Use this to access translations in any CLI.
2
+ # This implementation does support blocks yielded to {#t}, however
3
+ # their values are not necessarily HTML-escaped.
1
4
  module Brut::I18n::ForCLI
2
5
  include Brut::I18n::BaseMethods
3
- def html_safe(string) = string
6
+ def safe(string) = string
7
+ def capture(&block) = block.()
4
8
  end
@@ -1,4 +1,12 @@
1
+ # I18n for components or pages, which are assumed to be Phlex components.
2
+ # To use this outside of a Phlex context, you must define these two
3
+ # methods to ensure proper HTML escaping happens:
4
+ #
5
+ # * `safe` to accept a string and return a string.
6
+ # * `capture` to accept a block and return its contents as a string.
1
7
  module Brut::I18n::ForHTML
2
8
  include Brut::I18n::BaseMethods
3
- def html_safe(string) = Brut::FrontEnd::Templates::HTMLSafeString.from_string(string)
9
+ def t(...)
10
+ safe(super)
11
+ end
4
12
  end
@@ -1,8 +1,19 @@
1
+ # Manages the value for the HTTP
2
+ # [Accept-Language](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language)
3
+ # header. Generally, you would not interact with this class directly, however it is used
4
+ # by Brut to make a guess as to which Locale a browser is reporting.
1
5
  class Brut::I18n::HTTPAcceptLanguage
6
+ # A locale with the weight (value for q=) it was given in the Accept-Language header
2
7
  WeightedLocale = Data.define(:locale, :q) do
8
+ # Returns the primary locale for whatever locale
9
+ # this is holding. For example, the primary locale
10
+ # of "en-US" is "en".
3
11
  def primary_locale = self.locale.gsub(/\-.*$/,"")
12
+
13
+ # True if this locale is a primary locale
4
14
  def primary? = self.primary_locale == self.locale
5
15
 
16
+ # Return a new WeightedLocale that is the primary locale.
6
17
  def primary_only
7
18
  self.class.new(locale: self.primary_locale, q: self.q)
8
19
  end
@@ -12,6 +23,11 @@ class Brut::I18n::HTTPAcceptLanguage
12
23
  end
13
24
  end
14
25
 
26
+ # Parse the value stored in the session.
27
+ #
28
+ # @param [String] session_value the value stored in the session.
29
+ # @return [Brut::I18n::HTTPAcceptLanguage] a usable object. If the provided value
30
+ # is blank, #{Brut::I18n::HTTPAcceptLanguage::AlwaysEnglish} is returned.
15
31
  def self.from_session(session_value)
16
32
  values = session_value.to_s.split(/,/).map { |value|
17
33
  locale,q = value.split(/;/)
@@ -24,6 +40,19 @@ class Brut::I18n::HTTPAcceptLanguage
24
40
  end
25
41
  end
26
42
 
43
+ # Parse the value provided by the browser via
44
+ # {Brut::FrontEnd::Handlers::LocaleDetectionHandler} via
45
+ # the `brut-locale-detection` custom element (which
46
+ # uses `Intl.DateTimeFormat().resolvedOptions()` to determine
47
+ # the locale).
48
+ #
49
+ # Because this value is not in the same format as the Accept-Language
50
+ # header, it's `q` is assumed to be 1.
51
+ #
52
+ # @param [String] value the value provided by the brower.
53
+ #
54
+ # @return [Brut::I18n::HTTPAcceptLanguage] a usable object. If the provided value
55
+ # is blank, #{Brut::I18n::HTTPAcceptLanguage::AlwaysEnglish} is returned.
27
56
  def self.from_browser(value)
28
57
  value = value.to_s.strip
29
58
  if value == ""
@@ -33,6 +62,10 @@ class Brut::I18n::HTTPAcceptLanguage
33
62
  end
34
63
  end
35
64
 
65
+ # Parse from the HTTP Accept-Language header.
66
+ #
67
+ # @return [Brut::I18n::HTTPAcceptLanguage] a usable object. If the provided value
68
+ # is blank, #{Brut::I18n::HTTPAcceptLanguage::AlwaysEnglish} is returned.
36
69
  def self.from_header(header_value)
37
70
  header_value = header_value.to_s.strip
38
71
  if header_value == "*" || header_value == ""
@@ -50,14 +83,28 @@ class Brut::I18n::HTTPAcceptLanguage
50
83
  end
51
84
  end
52
85
 
86
+ # Ordered list of locales, from highest-weighted to lowest.
53
87
  attr_reader :weighted_locales
88
+ # @param [Array<Brut::I18n::HTTPAcceptLanguage::WeightedLocale>] weighted_locales locales to use. They do not
89
+ # need to be ordered
54
90
  def initialize(weighted_locales)
55
91
  @weighted_locales = weighted_locales.sort_by(&:q).reverse
56
92
  end
93
+
94
+ # True if the values inside this object represent known locales, and not a guess based on missing information.
95
+ # In general, this returns true if the values came from the Accept-Language header, or from the browser.
57
96
  def known? = true
97
+
98
+ # Serialize for storage in the session
99
+ #
100
+ # @return [String] a string that can be stored in the session and later deserialized via {.from_session}.
58
101
  def for_session = @weighted_locales.map { |weighted_locale| "#{weighted_locale.locale};#{weighted_locale.q}" }.join(",")
59
102
  def to_s = self.for_session
60
103
 
104
+ # A subclass that represents the use of English and only English. This is
105
+ # used when attempts to determine the locale fail. Instances of this class
106
+ # are considered "unknown" ({#known?} returns false), which allows Brut
107
+ # to replace this with a known value later on.
61
108
  class AlwaysEnglish < Brut::I18n::HTTPAcceptLanguage
62
109
  def initialize
63
110
  super([ WeightedLocale.new(locale: "en", q: 1) ])
data/lib/brut/i18n.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # I18n holds all the code useful for translating and localizing information. It's based on Ruby's I18n.
2
2
  module Brut::I18n
3
3
  autoload(:BaseMethods, "brut/i18n/base_methods")
4
+ autoload(:ForBackEnd, "brut/i18n/for_back_end")
4
5
  autoload(:ForCLI, "brut/i18n/for_cli")
5
6
  autoload(:ForHTML, "brut/i18n/for_html")
6
7
  autoload(:HTTPAcceptLanguage, "brut/i18n/http_accept_language")
@@ -1,3 +1,7 @@
1
+ # Class to interact with the OpenTelemetry standard in a simpler way than
2
+ # the provided Ruby gem does. In general, you should use this class
3
+ # via `Brut.container.instrumentation`, and you should *not* use the
4
+ # OpenTelemetry ruby library directly. You probably wouldn't want to, anyway.
1
5
  class Brut::Instrumentation::OpenTelemetry
2
6
  # Create a span around the given block of code.
3
7
  #
@@ -39,11 +43,32 @@ class Brut::Instrumentation::OpenTelemetry
39
43
  timestamp:)
40
44
  end
41
45
 
46
+ # Record an exception. In general, use this only if:
47
+ #
48
+ # * You need to have the parent span record this particular exception
49
+ # * You are not going to re-raise the exception.
50
+ #
51
+ # Otherwise, look at {#record_and_reraise_exception!}.
52
+ #
53
+ # @param [Exception] ex the exception to record.
54
+ # @param [Hash] attributes any attributes to attach that will show up in your OTel provider
42
55
  def record_exception(ex,attributes=nil)
43
56
  current_span = OpenTelemetry::Trace.current_span
44
57
  current_span.record_exception(ex,attributes: NormalizedAttributes.new(nil,attributes).to_h)
45
58
  end
46
59
 
60
+ # Record an exception and re-raise it. This is useful if you want
61
+ # the exception recorded as part of the parent span, but still plan
62
+ # to let it raise. Don't do this for every exception you intend to raise.
63
+ # @param [Exception] ex the exception to record.
64
+ # @param [Hash] attributes any attributes to attach that will show up in your OTel provider
65
+ # @raise [Exception] the exception passed in.
66
+ def record_and_reraise_exception(ex,attributes=nil)
67
+ reecord_exception(ex,attributes)
68
+ raise ex
69
+ end
70
+
71
+
47
72
  # Adds attributes to the span, converting the hash or keyword arguments to strings. This will use
48
73
  # the app's Otel prefix for all attributes, so you do not have to prefix them.
49
74
  # If you need to set standard attributes, you should use {Brut::Instrumentation::OpenTelemetry::Span#add_prefixed_attributes} instead.
@@ -1,10 +1,8 @@
1
+ # Namespace for instrumentation setup and support. Brut strives to provide useful
2
+ # instrumentation by default.
3
+ #
1
4
  module Brut::Instrumentation
2
5
  autoload(:OpenTelemetry,"brut/instrumentation/open_telemetry")
3
6
  autoload(:LoggerSpanExporter,"brut/instrumentation/logger_span_exporter")
4
-
5
- # Convenience method to add attributes to create a span without accessing the instrumentation instance directly.
6
- def span(name,**attributes,&block)
7
- Brut.container.instrumentation.span(name,**attributes,&block)
8
- end
9
7
  end
10
8
 
@@ -9,6 +9,7 @@ module Brut::SinatraHelpers
9
9
  sinatra_app.path("/__brut/locale_detection",method: :post)
10
10
  sinatra_app.path("/__brut/instrumentation",method: :get)
11
11
  sinatra_app.set :host_authorization, permitted_hosts: Brut.container.permitted_hosts
12
+ sinatra_app.set :show_exceptions, false
12
13
  end
13
14
 
14
15
  # @private
@@ -77,15 +78,16 @@ module Brut::SinatraHelpers
77
78
 
78
79
  Brut.container.instrumentation.span(page_class.name) do |span|
79
80
  span.add_prefixed_attributes("brut", type: :page, class: page_class)
80
- request_context = Thread.current.thread_variable_get(:request_context)
81
- constructor_args = request_context.as_constructor_args(
81
+ constructor_args = Brut::FrontEnd::RequestContext.current.as_constructor_args(
82
82
  page_class,
83
83
  request_params: params,
84
84
  route: brut_route,
85
85
  )
86
86
  span.add_prefixed_attributes("brut.initializer.args", constructor_args.map { |k,v| [k.to_s,v.class.name] }.to_h)
87
87
  page_instance = page_class.new(**constructor_args)
88
+
88
89
  result = page_instance.handle!
90
+
89
91
  span.add_prefixed_attributes("brut", result_class: result.class)
90
92
  case result
91
93
  in URI => uri
@@ -177,7 +179,6 @@ module Brut::SinatraHelpers
177
179
  form_class: form_class,
178
180
  )
179
181
 
180
- request_context = Thread.current.thread_variable_get(:request_context)
181
182
  handler = handler_class.new
182
183
  form = if form_class.nil?
183
184
  nil
@@ -185,7 +186,7 @@ module Brut::SinatraHelpers
185
186
  form_class.new(params: params)
186
187
  end
187
188
 
188
- process_args = request_context.as_method_args(handler,:handle,request_params: params,form: form,route:brut_route)
189
+ process_args = Brut::FrontEnd::RequestContext.current.as_method_args(handler,:handle,request_params: params,form: form,route:brut_route)
189
190
 
190
191
  result = handler.handle!(**process_args)
191
192
 
@@ -193,12 +194,17 @@ module Brut::SinatraHelpers
193
194
  in URI => uri
194
195
  redirect to(uri.to_s)
195
196
  in Brut::FrontEnd::Component => component_instance
196
- render_html(component_instance).to_s
197
- in [ Brut::FrontEnd::Component => component_instance, Brut::FrontEnd::HttpStatus => http_status ]
197
+ component_instance.call.to_s
198
+ in [
199
+ Brut::FrontEnd::Component => component_instance,
200
+ Brut::FrontEnd::HttpStatus => http_status,
201
+ ]
202
+
198
203
  [
199
204
  http_status.to_i,
200
- render_html(component_instance).to_s,
205
+ component_instance.call.to_s,
201
206
  ]
207
+
202
208
  in Brut::FrontEnd::HttpStatus => http_status
203
209
  http_status.to_i
204
210
  in Brut::FrontEnd::Download => download
@@ -8,11 +8,19 @@ module Brut::SpecSupport::ComponentSupport
8
8
  include Brut::SpecSupport::FlashSupport
9
9
  include Brut::SpecSupport::SessionSupport
10
10
  include Brut::SpecSupport::ClockSupport
11
- include Brut::I18n::ForHTML
11
+ include Brut::I18n::BaseMethods
12
12
 
13
- # Render a component into its text representation. This mimics what happens when a component is used
14
- # inside a template. You typically don't want this, but should use {#render_and_parse}, since that will
15
- # parse the HTML.
13
+ # Render a component or page into its text representation. This mimics what happens when Brut renders
14
+ # the page or component. Note that pages don't always return Strings, for example if `before_render`
15
+ # returns a redirect.
16
+ #
17
+ # When testing a component, call {#render_and_parse} instead of this. When testing a page that will
18
+ # always render HTML, again call {#render_and_parse}.
19
+ #
20
+ # When using this, there are some matchers that can help assert what the page has done:
21
+ #
22
+ # * `have_redirected_to` to check that the page redirected elsewhere, instead of rendering.
23
+ # * `have_returned_http_status` to check that the page returned an HTTP status instead of rendering.
16
24
  def render(component,&block)
17
25
  if component.kind_of?(Brut::FrontEnd::Page)
18
26
  if !block.nil?
@@ -20,12 +28,23 @@ module Brut::SpecSupport::ComponentSupport
20
28
  end
21
29
  component.handle!
22
30
  else
23
- component.yielded_block = block
24
- component.render
31
+ if block.nil?
32
+ component.call
33
+ else
34
+ component.call do
35
+ component.raw(component.safe(block.()))
36
+ end
37
+ end
25
38
  end
26
39
  end
27
40
 
28
- # Render a component and parse it into a Nokogiri Node for examination.
41
+ # Render a component or page and parse it into a Nokogiri Node for examination. There are several matchers
42
+ # you can use with the return value of this method:
43
+ #
44
+ # * `have_html_attribute` to check if a node has a value for an HTML attribute.
45
+ # * `have_i18n_string` to check if the text of a node is exactly an i18n string you have set up.
46
+ # * `have_link_to` to check that a node contains a link to a page or page routing
47
+ #
29
48
  #
30
49
  # @example
31
50
  #
@@ -41,7 +60,7 @@ module Brut::SpecSupport::ComponentSupport
41
60
  # @return [Brut::SpecSupport::EnhancedNode] a wrapper around a Nokogiri node to provide convienience methods.
42
61
  def render_and_parse(component,&block)
43
62
  rendered_text = render(component,&block)
44
- if !rendered_text.kind_of?(String) && !rendered_text.kind_of?(Brut::FrontEnd::Templates::HTMLSafeString)
63
+ if !rendered_text.kind_of?(String)
45
64
  if rendered_text.kind_of?(URI::Generic)
46
65
  raise "#{component.class} redirected to #{rendered_text} instead of rendering"
47
66
  else
@@ -80,9 +99,4 @@ module Brut::SpecSupport::ComponentSupport
80
99
  def routing_for(klass,**args)
81
100
  Brut.container.routing.path(klass,**args)
82
101
  end
83
-
84
- # Escape HTML using the same code Brut uses for rendering templates.
85
- def escape_html(...)
86
- Brut::FrontEnd::Templates::EscapableFilter.escape_html(...)
87
- end
88
102
  end
@@ -0,0 +1,4 @@
1
+ # Convienience methods for writing E2E tests
2
+ module Brut::SpecSupport::E2eSupport
3
+ include Brut::I18n::ForBackEnd
4
+ end
@@ -5,6 +5,9 @@ module Brut::SpecSupport::GeneralSupport
5
5
  end
6
6
 
7
7
  module ClassMethods
8
+ # Used to indicate that a test does need to be written, but that
9
+ # its implementation can wait until a given date before causing
10
+ # `bin/ci` to fail the test suite.
8
11
  def implementation_is_needed(check_again_at:)
9
12
  check_again_at = if check_again_at.kind_of?(Time)
10
13
  check_again_at
@@ -2,7 +2,12 @@ require_relative "flash_support"
2
2
  require_relative "clock_support"
3
3
  require_relative "session_support"
4
4
 
5
- # Convienience methods for testing handlers.
5
+ # Convienience methods for testing handlers. When testing handlers, the following matchers may be useful:
6
+ #
7
+ #
8
+ # * `have_redirected_to` to check that the handler redirected to a give URI
9
+ # * `have_rendered` to check that the handler rendered a specific page
10
+ # * `have_returned_http_status` to check that the handler returned an HTTP status
6
11
  module Brut::SpecSupport::HandlerSupport
7
12
  include Brut::SpecSupport::FlashSupport
8
13
  include Brut::SpecSupport::ClockSupport
@@ -10,4 +10,5 @@ require_relative "matchers/have_i18n_string"
10
10
  require_relative "matchers/have_redirected_to"
11
11
  require_relative "matchers/have_rendered"
12
12
  require_relative "matchers/have_returned_http_status"
13
+ require_relative "matchers/have_returned_rack_response"
13
14
  require_relative "matchers/have_link_to"
@@ -1,3 +1,4 @@
1
+ # E2E
1
2
  RSpec::Matchers.define :be_page_for do |klass|
2
3
  match do |page|
3
4
  meta = page.locator("meta[name='class']")
@@ -1,3 +1,4 @@
1
+ # Component/Page
1
2
  RSpec::Matchers.define :have_html_attribute do |attribute|
2
3
  if attribute.kind_of?(Hash)
3
4
  if attribute.keys.length != 1
@@ -1,5 +1,7 @@
1
+ # Component/Page
1
2
  RSpec::Matchers.define :have_i18n_string do |key,**args|
2
- include Brut::I18n::ForHTML
3
+ include Brut::I18n::ForBackEnd
4
+
3
5
  match do |nokogiri_node|
4
6
 
5
7
  text = nokogiri_node.text.strip
@@ -1,3 +1,4 @@
1
+ # Component/Page
1
2
  RSpec::Matchers.define :have_link_to do |page_klass,**args|
2
3
  match do |node|
3
4
  node.css("a[href='#{page_klass.routing(**args)}']").any?
@@ -1,3 +1,4 @@
1
+ # Page
1
2
  RSpec::Matchers.define :have_redirected_to do |page_or_uri,**page_params|
2
3
  match do |result|
3
4
  if page_or_uri.kind_of?(URI)
@@ -1,3 +1,4 @@
1
+ # Handler
1
2
  RSpec::Matchers.define :have_rendered do |component_or_page|
2
3
  match do |result|
3
4
  result.class.ancestors.include?(component_or_page)
@@ -0,0 +1,44 @@
1
+ # Handler
2
+ RSpec::Matchers.define :have_returned_rack_response do |http_status: :any, headers: :any, body: :any|
3
+ match do |result|
4
+ case result
5
+ in [ response_status, response_headers, response_body ]
6
+ http_status_match = http_status == :any || response_status == http_status
7
+ headers_match = headers == :any || response_headers == headers
8
+ body_match = body == :any || response_body == body
9
+
10
+ http_status_match && headers_match && body_match
11
+ else
12
+ false
13
+ end
14
+ end
15
+
16
+ failure_message do |result|
17
+ case result
18
+ in [ response_status, response_headers, response_body ]
19
+ http_status_match = http_status == :any || response_status == http_status
20
+ headers_match = headers == :any || response_headers == headers
21
+ body_match = body == :any || response_body == body
22
+ errors = [
23
+ http_status_match ? nil : "HTTP status #{response_status} did not match #{http_status}",
24
+ headers_match ? nil : "Headers #{response_headers} did not match #{headers}",
25
+ body_match ? nil : "Body #{response_body} did not match #{body}",
26
+ ].compact.join(", ")
27
+ else
28
+ if result.kind_of?(Array)
29
+ "Response was a #{result.class} of length #{result.length}, which could not be interpreted as a Rack response."
30
+ else
31
+ "Response was a #{result.class}, which could not be interpreted as a Rack response."
32
+ end
33
+ end
34
+ end
35
+ failure_message_when_negated do |result|
36
+ case result
37
+ in [ response_status, response_headers, response_body ]
38
+ "Response was a Rack response and/or array of size 3"
39
+ else
40
+ "failure_message_when_negated encounterd a code-path for a non-Rack response, which should not have happened when have_returned_rack_response was negated"
41
+ end
42
+ end
43
+
44
+ end
@@ -80,6 +80,7 @@ class Brut::SpecSupport::RSpecSetup
80
80
  @config.include Brut::SpecSupport::GeneralSupport
81
81
  @config.include Brut::SpecSupport::ComponentSupport, component: true
82
82
  @config.include Brut::SpecSupport::HandlerSupport, handler: true
83
+ @config.include Brut::SpecSupport::E2eSupport, e2e: true
83
84
  @config.include Playwright::Test::Matchers, e2e: true
84
85
 
85
86
  @config.around do |example|
@@ -1,15 +1,16 @@
1
1
  module Brut
2
2
  # Spec Support holds various matchers and helpers useful when writing tests with RSpec.
3
3
  # Note that this module and it's contents aren't loaded by default when you `require "brut"`.
4
- # Your app's `spec_helper.rb` should require these properly.
4
+ # Your app's `spec_helper.rb` should require these explicitly.
5
5
  module SpecSupport
6
6
  end
7
7
  end
8
- require_relative "spec_support/matcher"
9
8
  require_relative "spec_support/component_support"
10
- require_relative "spec_support/handler_support"
11
- require_relative "spec_support/general_support"
9
+ require_relative "spec_support/e2e_support"
12
10
  require_relative "spec_support/e2e_test_server"
11
+ require_relative "spec_support/general_support"
12
+ require_relative "spec_support/handler_support"
13
+ require_relative "spec_support/matcher"
13
14
  require_relative "spec_support/rspec_setup"
14
15
  require_relative "factory_bot"
15
16
  # Convention here is different. We don't want to autoload
data/lib/brut/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Brut
2
2
  # @!visibility private
3
- VERSION = "0.0.13"
3
+ VERSION = "0.0.21"
4
4
  end