brut 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +6 -0
- data/Gemfile.lock +66 -1
- data/README.md +36 -0
- data/Rakefile +22 -0
- data/brut.gemspec +7 -0
- data/doc-src/architecture.md +102 -0
- data/doc-src/assets.md +98 -0
- data/doc-src/forms.md +214 -0
- data/doc-src/handlers.md +83 -0
- data/doc-src/javascript.md +265 -0
- data/doc-src/keyword-injection.md +183 -0
- data/doc-src/pages.md +210 -0
- data/doc-src/route-hooks.md +59 -0
- data/docs-todo.md +32 -0
- data/lib/brut/back_end/seed_data.rb +5 -1
- data/lib/brut/back_end/validator.rb +1 -1
- data/lib/brut/back_end/validators/form_validator.rb +31 -6
- data/lib/brut/cli/app.rb +100 -4
- data/lib/brut/cli/app_runner.rb +38 -5
- data/lib/brut/cli/apps/build_assets.rb +4 -6
- data/lib/brut/cli/apps/db.rb +2 -7
- data/lib/brut/cli/apps/scaffold.rb +413 -7
- data/lib/brut/cli/apps/test.rb +14 -1
- data/lib/brut/cli/command.rb +141 -6
- data/lib/brut/cli/error.rb +30 -3
- data/lib/brut/cli/execution_results.rb +44 -6
- data/lib/brut/cli/executor.rb +21 -1
- data/lib/brut/cli/options.rb +29 -2
- data/lib/brut/cli/output.rb +47 -0
- data/lib/brut/cli.rb +26 -0
- data/lib/brut/factory_bot.rb +2 -0
- data/lib/brut/framework/app.rb +38 -5
- data/lib/brut/framework/config.rb +97 -54
- data/lib/brut/framework/container.rb +97 -33
- data/lib/brut/framework/errors/abstract_method.rb +3 -7
- data/lib/brut/framework/errors/missing_parameter.rb +12 -0
- data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
- data/lib/brut/framework/errors/not_found.rb +12 -2
- data/lib/brut/framework/errors/not_implemented.rb +14 -0
- data/lib/brut/framework/errors.rb +18 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
- data/lib/brut/framework/mcp.rb +106 -49
- data/lib/brut/framework/project_environment.rb +9 -0
- data/lib/brut/framework.rb +1 -0
- data/lib/brut/front_end/asset_metadata.rb +7 -1
- data/lib/brut/front_end/component.rb +129 -38
- data/lib/brut/front_end/components/constraint_violations.rb +57 -0
- data/lib/brut/front_end/components/form_tag.rb +23 -32
- data/lib/brut/front_end/components/i18n_translations.rb +34 -1
- data/lib/brut/front_end/components/input.rb +3 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
- data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
- data/lib/brut/front_end/components/inputs/select.rb +26 -2
- data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
- data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
- data/lib/brut/front_end/components/locale_detection.rb +8 -1
- data/lib/brut/front_end/components/page_identifier.rb +2 -0
- data/lib/brut/front_end/components/time.rb +95 -0
- data/lib/brut/front_end/components/traceparent.rb +22 -0
- data/lib/brut/front_end/download.rb +11 -0
- data/lib/brut/front_end/flash.rb +32 -0
- data/lib/brut/front_end/form.rb +109 -106
- data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
- data/lib/brut/front_end/forms/input.rb +30 -42
- data/lib/brut/front_end/forms/input_declarations.rb +90 -0
- data/lib/brut/front_end/forms/input_definition.rb +45 -30
- data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
- data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
- data/lib/brut/front_end/forms/select_input.rb +47 -0
- data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
- data/lib/brut/front_end/forms/validity_state.rb +23 -9
- data/lib/brut/front_end/handler.rb +27 -8
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
- data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
- data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
- data/lib/brut/front_end/handling_results.rb +14 -4
- data/lib/brut/front_end/http_method.rb +13 -1
- data/lib/brut/front_end/http_status.rb +10 -0
- data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
- data/lib/brut/front_end/middleware.rb +5 -0
- data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
- data/lib/brut/front_end/middlewares/favicon.rb +16 -0
- data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
- data/lib/brut/front_end/page.rb +50 -11
- data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
- data/lib/brut/front_end/pages/missing_page.rb +36 -0
- data/lib/brut/front_end/request_context.rb +117 -8
- data/lib/brut/front_end/route_hook.rb +43 -1
- data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
- data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
- data/lib/brut/front_end/routing.rb +138 -31
- data/lib/brut/front_end/session.rb +86 -7
- data/lib/brut/front_end/template.rb +17 -2
- data/lib/brut/front_end/templates/block_filter.rb +4 -3
- data/lib/brut/front_end/templates/erb_parser.rb +1 -1
- data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
- data/lib/brut/front_end/templates/locator.rb +60 -0
- data/lib/brut/i18n/base_methods.rb +4 -0
- data/lib/brut/i18n.rb +1 -0
- data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
- data/lib/brut/instrumentation/open_telemetry.rb +107 -0
- data/lib/brut/instrumentation.rb +4 -6
- data/lib/brut/junk_drawer.rb +54 -4
- data/lib/brut/sinatra_helpers.rb +42 -38
- data/lib/brut/spec_support/clock_support.rb +6 -0
- data/lib/brut/spec_support/component_support.rb +53 -26
- data/lib/brut/spec_support/e2e_test_server.rb +82 -0
- data/lib/brut/spec_support/enhanced_node.rb +45 -0
- data/lib/brut/spec_support/general_support.rb +14 -3
- data/lib/brut/spec_support/handler_support.rb +2 -0
- data/lib/brut/spec_support/matcher.rb +6 -3
- data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
- data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
- data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
- data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
- data/lib/brut/spec_support/rspec_setup.rb +182 -0
- data/lib/brut/spec_support.rb +8 -3
- data/lib/brut/version.rb +2 -1
- data/lib/brut.rb +28 -5
- data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
- data/lib/sequel/extensions/brut_migrations.rb +18 -8
- data/lib/sequel/plugins/created_at.rb +2 -0
- data/lib/sequel/plugins/external_id.rb +39 -1
- data/lib/sequel/plugins/find_bang.rb +4 -1
- metadata +140 -13
- data/lib/brut/back_end/action.rb +0 -3
- data/lib/brut/back_end/result.rb +0 -46
- data/lib/brut/front_end/components/timestamp.rb +0 -33
- data/lib/brut/instrumentation/basic.rb +0 -66
- data/lib/brut/instrumentation/event.rb +0 -19
- data/lib/brut/instrumentation/http_event.rb +0 -5
- 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
|
data/lib/brut/instrumentation.rb
CHANGED
@@ -1,11 +1,9 @@
|
|
1
1
|
module Brut::Instrumentation
|
2
|
-
autoload(:
|
3
|
-
autoload(:
|
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
|
8
|
-
Brut.container.instrumentation.
|
5
|
+
def span(name,**attributes,&block)
|
6
|
+
Brut.container.instrumentation.span(name,**attributes,&block)
|
9
7
|
end
|
10
8
|
end
|
11
9
|
|
data/lib/brut/junk_drawer.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
|
data/lib/brut/sinatra_helpers.rb
CHANGED
@@ -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.
|
67
|
-
|
68
|
-
|
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
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
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
|
@@ -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
|
-
|
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
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
9
|
-
|
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)
|