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
@@ -1,7 +1,9 @@
1
1
  require_relative "flash_support"
2
+ require_relative "clock_support"
2
3
  require_relative "session_support"
3
4
  module Brut::SpecSupport::HandlerSupport
4
5
  include Brut::SpecSupport::FlashSupport
6
+ include Brut::SpecSupport::ClockSupport
5
7
  include Brut::SpecSupport::SessionSupport
6
8
  end
7
9
 
@@ -1,9 +1,12 @@
1
1
  module Brut::SpecSupport::Matchers
2
2
  end
3
+ require_relative "matchers/be_a_bug"
4
+ require_relative "matchers/be_page_for"
5
+ require_relative "matchers/be_routing_for"
3
6
  require_relative "matchers/have_constraint_violation"
4
7
  require_relative "matchers/have_html_attribute"
5
- require_relative "matchers/be_routing_for"
6
- require_relative "matchers/be_page_for"
8
+ require_relative "matchers/have_i18n_string"
9
+ require_relative "matchers/have_redirected_to"
7
10
  require_relative "matchers/have_rendered"
8
11
  require_relative "matchers/have_returned_http_status"
9
- require_relative "matchers/be_a_bug"
12
+ require_relative "matchers/have_link_to"
@@ -6,9 +6,10 @@ RSpec::Matchers.define :be_page_for do |klass|
6
6
  failure_message do |page|
7
7
  meta = page.locator("meta[name='class']")
8
8
  if meta.count == 0
9
- "Could not find <meta name='class'> on the page:\n\n#{page.content}"
9
+ "Could not find <meta name='class'> on the page, which is what's needed to know what page we are on:\n\n#{page.content}"
10
10
  else
11
- "Could not find <meta name='class' content='#{klass.name}'>, but found:\n\n#{meta.evaluate('e => e.outerHTML')}"
11
+ page_name = meta.get_attribute("content")
12
+ "Expected to be on page #{klass.name}, but we seem to be on page #{page_name}, based on this meta tag:\n\n#{meta.evaluate('e => e.outerHTML')}"
12
13
  end
13
14
  end
14
15
  end
@@ -1,14 +1,26 @@
1
- RSpec::Matchers.define :have_constraint_violation do |field,key:|
1
+ RSpec::Matchers.define :have_constraint_violation do |field,key:,index:nil|
2
2
  match do |form|
3
- Brut::SpecSupport::Matchers::HaveConstraintViolation.new(form,field,key).matches?
3
+ Brut::SpecSupport::Matchers::HaveConstraintViolation.new(form,field,key,index).matches?
4
4
  end
5
5
 
6
6
  failure_message do |form|
7
- analysis = Brut::SpecSupport::Matchers::HaveConstraintViolation.new(form,field,key)
7
+ analysis = Brut::SpecSupport::Matchers::HaveConstraintViolation.new(form,field,key,index)
8
8
  if analysis.found_field?
9
- "#{field} did not have #{key} as a violation. These keys were found: #{analysis.keys_on_field_found.map(&:to_s).join(", ")}"
9
+ "Field '#{field}' did not have key '#{key}' as a violation. These keys were found: #{analysis.keys_on_field_found.map(&:to_s).join(", ")}"
10
10
  else
11
- "#{field} had no errors. These fields DID: #{analysis.fields_found.map(&:to_s).join(", ")}"
11
+ field_searched_for = if index.nil?
12
+ field
13
+ else
14
+ "#{field}, index #{index}"
15
+ end
16
+ fields_with_errors = analysis.fields_found.map { |(field,index)|
17
+ if index.nil?
18
+ field
19
+ else
20
+ "#{field}, index #{index}"
21
+ end
22
+ }.join(", ")
23
+ "Field '#{field_searched_for}' had no errors. These fields DID: #{fields_with_errors}"
12
24
  end
13
25
  end
14
26
 
@@ -21,21 +33,22 @@ class Brut::SpecSupport::Matchers::HaveConstraintViolation
21
33
  attr_reader :fields_found
22
34
  attr_reader :keys_on_field_found
23
35
 
24
- def initialize(form, field, key)
36
+ def initialize(form, field, key, index)
25
37
  if !form.kind_of?(Brut::FrontEnd::Form)
26
38
  raise "#{self.class} only works with forms, not #{form.class}"
27
39
  end
28
40
  @form = form
29
41
  @field = field.to_s
30
42
  @key = key.to_s
43
+ @index = index || 0
31
44
 
32
45
  @matches = false
33
46
  @found_field = false
34
47
  @fields_found = Set.new
35
48
  @keys_on_field_found = Set.new
36
49
 
37
- @form.constraint_violations.each do |input_name, constraint_violations|
38
- if input_name.to_s == @field
50
+ @form.constraint_violations.each do |input_name, (constraint_violations, index)|
51
+ if input_name.to_s == @field && index == @index
39
52
  @found_field = true
40
53
  constraint_violations.each do |constraint_violation|
41
54
  if constraint_violation.key.to_s == @key
@@ -45,7 +58,7 @@ class Brut::SpecSupport::Matchers::HaveConstraintViolation
45
58
  end
46
59
  end
47
60
  else
48
- @fields_found << input_name.to_s
61
+ @fields_found << [ input_name.to_s, index ]
49
62
  end
50
63
  end
51
64
  end
@@ -38,8 +38,15 @@ class Brut::SpecSupport::Matchers::HaveHTMLAttribute
38
38
  else
39
39
  result = result.first
40
40
  end
41
- elsif (!result.kind_of?(Nokogiri::XML::Element))
42
- @error = "Received a #{result.class} instead of a NodeSet or Element, as could be returned by `.css(...)`"
41
+ else
42
+ object_to_check = if result.kind_of?(SimpleDelegator)
43
+ result.__getobj__
44
+ else
45
+ result
46
+ end
47
+ if !object_to_check.kind_of?(Nokogiri::XML::Element)
48
+ @error = "Received a #{result.class} instead of a NodeSet or Element, as could be returned by `.css(...)`"
49
+ end
43
50
  end
44
51
  if !@error
45
52
  if attribute.kind_of?(Hash)
@@ -0,0 +1,24 @@
1
+ RSpec::Matchers.define :have_i18n_string do |key,**args|
2
+ include Brut::I18n::ForHTML
3
+ match do |nokogiri_node|
4
+
5
+ text = nokogiri_node.text.strip
6
+ i18n_string = t(key,**args).to_s
7
+
8
+ text == i18n_string
9
+ end
10
+
11
+ failure_message do |nokogiri_node|
12
+ text = nokogiri_node.text.strip
13
+ begin
14
+ "Expected '#{text}' to be '#{t(key,**args)}'\n#{nokogiri_node.to_html}\n"
15
+ rescue => ex
16
+ "I18n key '#{key}' could not be found: #{ex.message}"
17
+ end
18
+ end
19
+
20
+ failure_message_when_negated do |nokogiri_node|
21
+ "Did not expect node's text to be '#{t(key,**args)}'\n#{nokogiri_node.to_html}\n"
22
+ end
23
+ end
24
+
@@ -0,0 +1,14 @@
1
+ RSpec::Matchers.define :have_link_to do |page_klass,**args|
2
+ match do |node|
3
+ node.css("a[href='#{page_klass.routing(**args)}']").any?
4
+ end
5
+
6
+ failure_message do |node|
7
+ links = node.css("a").map(&:to_html)
8
+ "Did not find link to #{page_klass.routing(**args)}. Found these links: #{links.join(',')}"
9
+ end
10
+
11
+ failure_message_when_negated do |result|
12
+ "Did not expect to find link to #{page_klass.routing(**args)}."
13
+ end
14
+ end
@@ -0,0 +1,30 @@
1
+ RSpec::Matchers.define :have_redirected_to do |page_or_uri,**page_params|
2
+ match do |result|
3
+ if page_or_uri.kind_of?(URI)
4
+ if !page_params.empty?
5
+ raise "have_redirected_to, when given a URI, must NOT be given parameters. Got '#{page_params}'"
6
+ end
7
+ result == page_or_uri
8
+ elsif page_or_uri.ancestors.include?(Brut::FrontEnd::Page)
9
+ result == page_or_uri.routing(**page_params)
10
+ else
11
+ raise "have_redirected_to must be given a URI or a Brut::FrontEnd::Page class, got #{page_or_uri.class}"
12
+ end
13
+ rescue Brut::Framework::Errors::MissingParameter => ex
14
+ raise "#{page_or_uri}'s routing requires parameters you must specicfy to `have_redirected_to`: #{ex.message}"
15
+ end
16
+
17
+ failure_message do |result|
18
+ if page_or_uri.kind_of?(URI)
19
+ "Expected #{page_or_uri} but got #{result}"
20
+ elsif page_or_uri.ancestors.include?(Brut::FrontEnd::Page)
21
+ "Expected #{page_or_uri}'s routing (#{page_or_uri.routing(**page_params)}), but got #{result}"
22
+ else
23
+ "Unknown error occured or bug with have_redirected_to"
24
+ end
25
+ end
26
+ failure_message_when_negated do |result|
27
+ "Got a redirect when it wasn't expected"
28
+ end
29
+
30
+ end
@@ -1,20 +1,13 @@
1
- RSpec::Matchers.define :have_rendered do
1
+ RSpec::Matchers.define :have_rendered do |component_or_page|
2
2
  match do |result|
3
- result.kind_of?(String) || result.kind_of?(Brut::FrontEnd::Templates::HTMLSafeString)
3
+ result.class.ancestors.include?(component_or_page)
4
4
  end
5
5
 
6
6
  failure_message do |result|
7
- case result
8
- in URI => uri
9
- "Got a redirect to #{uri} instead of rendering"
10
- in Brut::FrontEnd::HttpStatus => http_status
11
- "Got an HTTP status of #{http_status} instead of rendering"
12
- else
13
- "Got an unexpected result: #{result.class} instead of a String"
14
- end
7
+ "Expected a #{component_or_page} to be rendered, but got #{result}"
15
8
  end
16
9
  failure_message_when_negated do |result|
17
- "Result was rendered HTML instead of something else"
10
+ "Got #{component_or_page} when not expected"
18
11
  end
19
12
 
20
13
  end
@@ -1,12 +1,16 @@
1
- RSpec::Matchers.define :have_returned_http_status do |http_status|
1
+ RSpec::Matchers.define :have_returned_http_status do |http_status=nil|
2
2
  match do |result|
3
- case result
4
- in URI => uri
5
- http_status == 302
6
- in Brut::FrontEnd::HttpStatus => result_http_status
7
- http_status == result_http_status.to_i
3
+ if http_status.nil?
4
+ result.kind_of?(Brut::FrontEnd::HttpStatus)
8
5
  else
9
- http_status == 200
6
+ case result
7
+ in URI => uri
8
+ http_status == 302
9
+ in Brut::FrontEnd::HttpStatus => result_http_status
10
+ http_status == result_http_status.to_i
11
+ else
12
+ http_status == 200
13
+ end
10
14
  end
11
15
  end
12
16
 
@@ -21,7 +25,11 @@ RSpec::Matchers.define :have_returned_http_status do |http_status|
21
25
  end
22
26
  end
23
27
  failure_message_when_negated do |result|
24
- "Result was rendered HTML instead of something else"
28
+ if http_status.nil?
29
+ "#{result} was rendered, but was not expecting an HTTP status"
30
+ else
31
+ "Got #{http_status} when not expected (#{result.class} was returned)"
32
+ end
25
33
  end
26
34
 
27
35
  end
@@ -0,0 +1,182 @@
1
+ # Configures RSpec for Brut. This provides several bits of infrastructure only present when the app is running, as well as
2
+ # quality of life improvements to make testing Brut components a bit easier. Even though Brut classes are all normal classes, it's
3
+ # convenient to have extraneous classes set up for you.
4
+ #
5
+ # * Metadata is added based on the class names under test:
6
+ # - `*Component` -> `:component`
7
+ # - `*Page` -> `:component`
8
+ # - `*Page` -> `:page`
9
+ # - `*Handler` -> `:handler`
10
+ # - If you add the `:page` metadata automatically, `:component` is added as well.
11
+ # - Tests in `specs/e2e` or any subfolder -> `:e2e`
12
+ # - Note that you are free to explicitly add these tags to your test metadata. That will cause the inclusion of modules as described below
13
+ # * Modules are included to provide additional support for writing tests:
14
+ # - {Brut::SpecSupport::GeneralSupport} included in all tests
15
+ # - {Brut::SpecSupport::ComponentSupport} included when `:component` metadata is set (generally, this is for page and component tests)
16
+ # - {Brut::SpecSupport::HandlerSupport} included when `:handler` is set.
17
+ # - {Playwright::Test::Matchers{ included when `:e2e` is set, which allows use of the Ruby binding for Playwright.
18
+ # * Non end-to-end tests will be run inside a database transaction to allow all database changes to be instantly undone. This does
19
+ # mean that any tests that tests database transaction behavior will not work as expected.
20
+ # * In component tests (which generally includes page tests), a {Brut::FrontEnd::RequestContext} is created for you and placed into
21
+ # the thread local storage. This allows any component that is injected with data from the `RequestContext` to access it as it would
22
+ # normally. You can also seed this with data that a component may need.
23
+ # * Handles all infrastructure for end-to-end tests:
24
+ # - starting a test server using {Brut::SpecSupport::E2ETestServer}
25
+ # - launching Chromium via Playwright
26
+ # * If using Sidekiq:
27
+ # - Jobs are cleared before each test
28
+ # - For end-to-end tests, Redis is flushed and actual Sidekiq is used instead of testing mode
29
+ #
30
+ # You can set certain metadata to change behavior:
31
+ #
32
+ # * `e2e_timeout` - number of milliseconds to wait until an end-to-end test gives up on a selector being found. The default is 5,000 (5 seconds). Use this only if there's no other way to keep your test from needing more than 5 seconds.
33
+ #
34
+ #
35
+ # @example
36
+ # RSpec.configure do |config|
37
+ # rspec_setup = Brut::SpecSupport::RSpecSetup.new(rspec_config: config)
38
+ # rspec_setup.setup!
39
+ #
40
+ # # rest of the RSpec configuration
41
+ # end
42
+ class Brut::SpecSupport::RSpecSetup
43
+ # Create the setup with the given RSpec configuration.
44
+ #
45
+ # @param [RSpec::Core::Configuration] rspec_config yielded from `RSpec.configure`
46
+ def initialize(rspec_config:)
47
+ @config = rspec_config
48
+ SemanticLogger.default_level = ENV.fetch("LOGGER_LEVEL_FOR_TESTS","warn")
49
+ end
50
+
51
+ # Sets up RSpec with variouis configurations needed by Brut to run your tests.
52
+ #
53
+ # @param [Proc] inside_db_transaction if given, this is run inside the DB transaction before your example is run. This is useful if you need to set up some reference data for all tests.
54
+ def setup!(inside_db_transaction: ->() {})
55
+
56
+ Brut::FactoryBot.new.setup!
57
+ optional_sidekiq_support = OptionalSidekiqSupport.new
58
+
59
+ @config.define_derived_metadata do |metadata|
60
+ if metadata[:described_class].to_s =~ /[a-z0-9]Component$/ ||
61
+ metadata[:described_class].to_s =~ /[a-z0-9]Page$/ ||
62
+ metadata[:page] == true
63
+ metadata[:component] = true
64
+ end
65
+ if metadata[:described_class].to_s =~ /[a-z0-9]Page$/ ||
66
+ metadata[:page] == true
67
+ metadata[:page] = true
68
+ end
69
+ if metadata[:described_class].to_s =~ /[a-z0-9]Handler$/
70
+ metadata[:handler] = true
71
+ end
72
+
73
+ relative_path = Pathname(metadata[:absolute_file_path]).relative_path_from(Brut.container.app_specs_dir)
74
+
75
+ top_level_directory = relative_path.each_filename.to_a[0].to_s
76
+ if top_level_directory == "e2e"
77
+ metadata[:e2e] = true
78
+ end
79
+ end
80
+ @config.include Brut::SpecSupport::GeneralSupport
81
+ @config.include Brut::SpecSupport::ComponentSupport, component: true
82
+ @config.include Brut::SpecSupport::HandlerSupport, handler: true
83
+ @config.include Playwright::Test::Matchers, e2e: true
84
+
85
+ @config.around do |example|
86
+
87
+ needs_request_context = example.metadata[:component] ||
88
+ example.metadata[:handler] ||
89
+ example.metadata[:page]
90
+
91
+ if needs_request_context
92
+ session = {
93
+ "session_id" => "test-session-id",
94
+ "csrf" => "test-csrf-token"
95
+ }
96
+ env = {
97
+ "rack.session" => session
98
+ }
99
+ app_session = Brut.container.session_class.new(rack_session: session)
100
+ request_context = Brut::FrontEnd::RequestContext.new(
101
+ env: env,
102
+ session: app_session,
103
+ flash: empty_flash,
104
+ body: nil,
105
+ xhr: false,
106
+ )
107
+ Thread.current.thread_variable_set(:request_context, request_context)
108
+ example.example_group.let(:request_context) { request_context }
109
+ end
110
+ if example.metadata[:component]
111
+ example.example_group.let(:component_name) { described_class.component_name }
112
+ end
113
+ if example.metadata[:page]
114
+ example.example_group.let(:page_name) { described_class.page_name }
115
+ end
116
+
117
+ if example.metadata[:e2e]
118
+ e2e_timeout = (ENV["E2E_TIMEOUT_MS"] || example.metadata[:e2e_timeout] || 5_000).to_i
119
+ optional_sidekiq_support.disable_sidekiq_testing do
120
+ Brut::SpecSupport::E2ETestServer.instance.start
121
+ Playwright.create(playwright_cli_executable_path: "./node_modules/.bin/playwright") do |playwright|
122
+ launch_args = {
123
+ headless: true,
124
+ }
125
+ if ENV["E2E_SLOW_MO"]
126
+ launch_args[:slowMo] = ENV["E2E_SLOW_MO"].to_i
127
+ end
128
+ playwright.chromium.launch(**launch_args) do |browser|
129
+ context_options = {
130
+ baseURL: "http://0.0.0.0:6503/",
131
+ }
132
+ if ENV["E2E_RECORD_VIDEOS"]
133
+ context_options[:record_video_dir] = Brut.container.tmp_dir / "e2e-videos"
134
+ end
135
+ browser_context = browser.new_context(**context_options)
136
+ browser_context.default_timeout = e2e_timeout
137
+ example.example_group.let(:page) { browser_context.new_page }
138
+ example.run
139
+ browser_context.close
140
+ browser.close
141
+ end
142
+ end
143
+ end
144
+ else
145
+ optional_sidekiq_support.clear_background_jobs
146
+ Sequel::Model.db.transaction do
147
+ inside_db_transaction.()
148
+ example.run
149
+ raise Sequel::Rollback
150
+ end
151
+ end
152
+ end
153
+ @config.after(:suite) do
154
+ Brut::SpecSupport::E2ETestServer.instance.stop
155
+ end
156
+ end
157
+
158
+ class OptionalSidekiqSupport
159
+ def initialize
160
+ @sidekiq_in_use = defined?(Sidekiq)
161
+ end
162
+
163
+ def disable_sidekiq_testing(&block)
164
+ if @sidekiq_in_use
165
+ Sidekiq::Testing.disable! do
166
+ Sidekiq.redis do |redis|
167
+ redis.flushall
168
+ end
169
+ block.()
170
+ end
171
+ else
172
+ block.()
173
+ end
174
+ end
175
+ def clear_background_jobs
176
+ if @sidekiq_in_use
177
+ Sidekiq::Worker.clear_all
178
+ end
179
+ end
180
+ end
181
+
182
+ end
@@ -1,7 +1,7 @@
1
- # Convention here is different. We don't want to autoload
2
- # a lot of stuff, since RSpec pollutes the Object namespace.
3
- # Instead, we'll require that these files are required explicitly
4
1
  module Brut
2
+ # Spec Support holds various matchers and helpers useful when writing tests with RSpec.
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.
5
5
  module SpecSupport
6
6
  end
7
7
  end
@@ -9,4 +9,9 @@ require_relative "spec_support/matcher"
9
9
  require_relative "spec_support/component_support"
10
10
  require_relative "spec_support/handler_support"
11
11
  require_relative "spec_support/general_support"
12
+ require_relative "spec_support/e2e_test_server"
13
+ require_relative "spec_support/rspec_setup"
12
14
  require_relative "factory_bot"
15
+ # Convention here is different. We don't want to autoload
16
+ # a lot of stuff, since RSpec pollutes the Object namespace.
17
+ # Instead, we'll require that these files are required explicitly
data/lib/brut/version.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  module Brut
2
- VERSION = "0.0.1"
2
+ # @!visibility private
3
+ VERSION = "0.0.2"
3
4
  end
data/lib/brut.rb CHANGED
@@ -1,10 +1,29 @@
1
1
  require_relative "brut/framework"
2
2
 
3
- # Convention is as follows:
3
+ # Brut is a way to make web apps with Ruby. It focuses on web standards, object-orientation, and other fundamentals. Brut seeks to
4
+ # minimize abstractions where possible.
4
5
  #
5
- # * singluar thing is a class, the base class of others being used, e.g. the base page is called Brut::Page
6
- # (e.g. and not Brut::Pages::Base).
6
+ # Brut encourages the use of the browser's technology and encourages you to build a web app based on good practices that are set up by
7
+ # default. Brut may not look easy, but it aims to be simple. It attempts to minimize dependencies and complexity, while leveraging
8
+ # common tested Ruby libraries related to web development.
9
+ #
10
+ # Have fun!
7
11
  module Brut
12
+ # In Brut, the _front end_ is considered anything that interacts directly with a web browser or HTTP. This includes rendering HTML,
13
+ # managing JavaScript and CSS, and processing form submissions. It contrasts to {Brut::BackEnd}, which handles the business logic
14
+ # and database.
15
+ #
16
+ # You {Brut::App} defines pages, forms, and actions. A page is backed by a subclass of {Brut::FrontEnd::Page}, which provides
17
+ # dynamic data for rendering. A page can reference {Brut::FrontEnd::Component} subclasses to allow functional decomposition of front
18
+ # end logic and markup, as well as re-use. Both pages and components have ERB files that describe the HTML to be rendered.
19
+ #
20
+ # A {Brut::FrontEnd::Form} subclass defines a form that a browser will submit to your app. That
21
+ # submission is processed by a {Brut::FrontEnd::Handler} subclass. Handlers can also respond to other HTTP requests.
22
+ #
23
+ # In addition to responding to requests, you can subclass {Brut::FrontEnd::RouteHook} or {Brut::FrontEnd::Middleware} to perform
24
+ # further manipulation of the request.
25
+ #
26
+ # The entire front-end is based on Rack, so you should be able to achieve anything you need to.
8
27
  module FrontEnd
9
28
  autoload(:AssetMetadata, "brut/front_end/asset_metadata")
10
29
  autoload(:Component, "brut/front_end/component")
@@ -20,19 +39,23 @@ module Brut
20
39
  autoload(:Middleware, "brut/front_end/middleware")
21
40
  autoload(:Middlewares, "brut/front_end/middleware")
22
41
  autoload(:Page, "brut/front_end/page")
42
+ autoload(:Pages, "brut/front_end/page")
23
43
  autoload(:RequestContext, "brut/front_end/request_context")
24
44
  autoload(:RouteHook, "brut/front_end/route_hook")
25
45
  autoload(:RouteHooks, "brut/front_end/route_hook")
26
46
  autoload(:Routing, "brut/front_end/routing")
27
47
  autoload(:Session, "brut/front_end/session")
28
48
  end
49
+ # The _back end_ of a Brut app is where your app's business logic and database are managed. While the bulk of your Brut app's code
50
+ # will be in the back end, Brut is far less prescriptive about how to manage that than it is the front end.
29
51
  module BackEnd
30
- autoload(:Result, "brut/back_end/result")
31
52
  autoload(:Validators, "brut/back_end/validator")
53
+ # Do not put SeedData here - it must be loaded only when needed
32
54
  end
33
- # DO NOT autoload(:CLI) - that is intended to be require-able on its own
55
+ # I18n is where internationalization and localization support lives.
34
56
  autoload(:I18n, "brut/i18n")
35
57
  autoload(:Instrumentation,"brut/instrumentation")
36
58
  autoload(:SinatraHelpers, "brut/sinatra_helpers")
59
+ # DO NOT autoload(:CLI) - that is intended to be require-able on its own
37
60
  end
38
61
  require "sequel/plugins"
@@ -1,37 +1,15 @@
1
1
  module Sequel
2
2
  module Extensions
3
+ # Instruments all SQL executions.
3
4
  module BrutInstrumentation
4
- class Event < Brut::Instrumentation::Event
5
- def initialize(operation:,sql:nil)
6
- super(category: "sequel", name: operation, details: { sql: sql })
7
- end
8
- end
9
- def execute(sql, opts = Sequel::OPTS, &block)
10
- Brut.container.instrumentation.instrument(Event.new(operation: "execute", sql: sql)) do
11
- super
12
- end
13
- end
14
- def execute_dui(sql, opts = Sequel::OPTS, &block)
15
- Brut.container.instrumentation.instrument(Event.new(operation: "execute_dui", sql: sql)) do
16
- super
17
- end
18
- end
19
- def execute_insert(sql, opts = Sequel::OPTS, &block)
20
- Brut.container.instrumentation.instrument(Event.new(operation: "execute_insert", sql: sql)) do
21
- super
22
- end
23
- end
24
- def insert_select(*values)
25
- Brut.container.instrumentation.instrument(Event.new(operation: "insert_select", sql: values)) do
26
- super
27
- end
28
- end
29
- def returning_fetch_rows(sql,&block)
30
- Brut.container.instrumentation.instrument(Event.new(operation: "returning_fetch_rows", sql: sql)) do
5
+ # @!visibility private
6
+ def log_connection_yield(sql,conn,args=nil)
7
+ Brut.container.instrumentation.span("SQL", sql: sql) do |span|
31
8
  super
32
9
  end
33
10
  end
34
11
  end
35
12
  end
36
13
  Sequel::Dataset.register_extension(:brut_instrumentation, Sequel::Extensions::BrutInstrumentation)
14
+ Sequel::Database.register_extension(:brut_instrumentation, Sequel::Extensions::BrutInstrumentation)
37
15
  end
@@ -1,15 +1,20 @@
1
1
  module Sequel
2
2
  module Extensions
3
- # Enhancements to migrations to encourage better practices and reduce boilerplate.
3
+ # Modifies and enhances Sequel's migrations DSL to default to best practices.
4
4
  #
5
- # * If no primary key is specified, a primary key column named :id of type :int will be created
6
- # * If no created_at is specified, a column name created_at of type timestamptz is created
7
- # * create_table requires a comment: field
8
- # * create_table accepts an external_id: true option that will create a unique citext called "external_id"
9
- # * columns are non-null by default
10
- # * foreign keys are non-null and an index is created
11
- # * the `key` method allows specifying keys aka creating a unique constraint
5
+ # * If no primary key is specified, a primary key column named `id` of type `int` will be created.
6
+ # * If no `created_at` is specified, a column name `created_at` of type `timestamptz` is created.
7
+ # * `create_table` requires a `comment:` attribute that explains the purpose of the table.
8
+ # * `create_table` accepts an `external_id: true` attribute that will create a unique `citext` field named `external_id`. This is intended to be used with {Sequel::Plugins::ExternalId}.
9
+ # * Columns are non-null by default. To make a nullable column, use `null: true`.
10
+ # * Foreign keys are non-null by default and an index is created by default.
11
+ # * The `key` method allows specifying additional keys on the table. This effecitvely creates a unique constraint on the fields given to `key`.
12
12
  module BrutMigrations
13
+ # Overrides Sequel's `create_table`
14
+ #
15
+ # @param args [Object] the arguments to pass to Sequel's `create_table`. If the last entry in `*args` is a `Hash`, new options are recognized:
16
+ # @option args [String] :comment String containing the table's description, included in the table definition. Required.
17
+ # @option args [true|false] :external_id If true, adds a `:citext` column named `external_id` that has a unique index on it.
13
18
  def create_table(*args)
14
19
  super
15
20
 
@@ -27,10 +32,15 @@ module Sequel
27
32
  end
28
33
  end
29
34
 
35
+ # Specifies a non-primary key based on the fields given. Effectively creates a unique index on these fields.
36
+ # Inside a `create_table` block, this can be called via `key`
37
+ #
38
+ # @param fields [Array] fields that should form the key.
30
39
  def add_key(fields)
31
40
  add_index fields, unique: true
32
41
  end
33
42
 
43
+ # Overrides Sequel's `add_column` to default `null: false`.
34
44
  def add_column(table,*args)
35
45
  options = args.last
36
46
  if options.is_a?(Hash)
@@ -1,7 +1,9 @@
1
1
  module Sequel
2
2
  module Plugins
3
+ # Automatically sets `created_at` on all models.
3
4
  module CreatedAt
4
5
  module InstanceMethods
6
+ # @!visibility private
5
7
  def before_save
6
8
  if self.created_at.nil?
7
9
  self.created_at = Time.now