brut 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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)
|