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
@@ -8,13 +8,13 @@ class Brut::CLI::Apps::BuildAssets < Brut::CLI::App
8
8
  requires_project_env
9
9
  default_command :all
10
10
  configure_only!
11
+ opts.on("--[no-]clean", "If set, any old files from previous runs are deleted. If omitted, is false in production and true otherwise")
11
12
 
12
13
  class All < Brut::CLI::Command
13
14
  description "Build all assets"
14
- opts.on("--[no-]clean","If set the metadata file used to map the files to their hashed values is deleted before assets are built")
15
15
 
16
16
  def execute
17
- if options.clean?(default: true)
17
+ if global_options.clean?(default: global_options.env != "production")
18
18
  asset_metadata_file = Brut.container.asset_metadata_file
19
19
  out.puts "Removing #{asset_metadata_file}"
20
20
  FileUtils.rm_f(asset_metadata_file)
@@ -40,7 +40,6 @@ This is to ensure that any images your code references will end up in the public
40
40
 
41
41
  class CSS < Brut::CLI::Command
42
42
  description "Builds a single CSS file suitable for sending to the browser"
43
- opts.on("--clean","If set, any .css files hanging around from a prevous build are deleted. Not recommended in production environments")
44
43
 
45
44
  detailed_description %{
46
45
  This produces a hashed file in every environment, in order to keep environments consistent and reduce differences. If your CSS file references images, fonts, or other assets via url() or other CSS functions, those files will be hashed and copied into the output directory where CSS is served.
@@ -54,7 +53,7 @@ This is to ensure that any images your code references will end up in the public
54
53
  esbuild_metafile = Brut.container.tmp_dir / "build-css-meta.json"
55
54
  asset_metadata_file = Brut.container.asset_metadata_file
56
55
 
57
- if options.clean?
56
+ if global_options.clean?(default: global_options.env != "production")
58
57
  out.puts "Cleaning old CSS files from #{Brut.container.css_bundle_output_dir}"
59
58
  Dir[Brut.container.css_bundle_output_dir / "*.*"].each do |file|
60
59
  if File.file?(file)
@@ -80,7 +79,6 @@ This is to ensure that any images your code references will end up in the public
80
79
  end
81
80
  class JS < Brut::CLI::Command
82
81
  description "Builds and bundles JavaScript destined for the browser"
83
- opts.on("--clean","If set, any .js files hanging around from a prevous build are deleted. Not recommended in production environments")
84
82
  opts.on("--output-file=FILE","Bundle to create that will be sent to the browser, relative to the JS public folder. Default is app.js")
85
83
  opts.on("--source-file=FILE","Entry point used to create the bundle, relative to the source JS folder. Default is index.js")
86
84
 
@@ -91,7 +89,7 @@ This is to ensure that any images your code references will end up in the public
91
89
  asset_metadata_file = Brut.container.asset_metadata_file
92
90
 
93
91
  name_with_hash_regexp = /app\/public\/(?<path>.+)\/(?<name>.+)\-(?<hash>.+)\.js/
94
- if options.clean?
92
+ if global_options.clean?(default: global_options.env != "production")
95
93
  out.puts "Cleaning old JS files from #{Brut.container.js_bundle_output_dir}"
96
94
  Dir[Brut.container.js_bundle_output_dir / "*.*"].each do |file|
97
95
  if File.file?(file)
@@ -32,7 +32,7 @@ class Brut::CLI::Apps::DB < Brut::CLI::App
32
32
  Dir["#{seeds_dir}/*.rb"].each do |file|
33
33
  require file
34
34
  end
35
- seed_data = Brut::Backend::SeedData.new
35
+ seed_data = Brut::BackEnd::SeedData.new
36
36
  seed_data.setup!
37
37
  seed_data.load_seeds!
38
38
  0
@@ -43,7 +43,6 @@ class Brut::CLI::Apps::DB < Brut::CLI::App
43
43
 
44
44
  class Rebuild < Brut::CLI::Command
45
45
  description "Drop, re-create, and run migrations, effecitvely rebuilding the entire database"
46
- opts.on("--[no-]seeds","Load seed data after applying migrations")
47
46
 
48
47
  requires_project_env default: "development"
49
48
 
@@ -63,11 +62,7 @@ class Brut::CLI::Apps::DB < Brut::CLI::App
63
62
  end
64
63
 
65
64
  def execute
66
- result = delegate_to_commands(Drop, Create, Migrate)
67
- if result.ok? && options.seeds?
68
- result = delegate_to_command(Seed)
69
- end
70
- result
65
+ delegate_to_commands(Drop, Create, Migrate)
71
66
  end
72
67
  end
73
68
 
@@ -1,3 +1,4 @@
1
+ require "prism"
1
2
  require "brut/cli"
2
3
 
3
4
  class Brut::CLI::Apps::Scaffold < Brut::CLI::App
@@ -5,12 +6,13 @@ class Brut::CLI::Apps::Scaffold < Brut::CLI::App
5
6
  opts.on("--overwrite", "If set, any files that exists already will be overwritten by new scaffolds")
6
7
  opts.on("--dry-run", "If set, no files are changed. You will see output of what would happen without this flag")
7
8
 
9
+
8
10
  def before_execute
9
11
  ENV["RACK_ENV"] = "development"
10
12
  end
11
13
 
12
14
  class Test < Brut::CLI::Command
13
- description "Create a test for a given file in the app"
15
+ description "Create the shell of a unit test based on an existing source file"
14
16
  args "source_file_paths..."
15
17
  def execute
16
18
  if args.empty?
@@ -67,7 +69,7 @@ end}
67
69
  }
68
70
 
69
71
  if global_options.dry_run?
70
- puts code
72
+ out.puts code
71
73
  else
72
74
  FileUtils.mkdir_p destination.dirname
73
75
  File.open(destination,"w") do |file|
@@ -100,17 +102,97 @@ end}
100
102
  end
101
103
  classes
102
104
  end
105
+ end
106
+
107
+ class E2ETest < Brut::CLI::Command
108
+ description "Create the shell of an end-to-end test"
109
+ args "test_name"
110
+ def self.command_name = "test:e2e"
111
+
112
+ opts.on("--path PATH","Path within the e2e tests to create the file")
113
+ def execute
114
+ if args.empty?
115
+ err.puts "'#{self.class.command_name}' requires a name"
116
+ return 1
117
+ end
118
+ test_name = args.join(" ").gsub(/\"/,"'")
119
+ test_file_name = args.join("_").gsub(/\W/,"_").gsub(/__+/,"_").downcase + ".spec.rb"
120
+ test_file_dir = Brut.container.e2e_specs_dir
121
+ if !options.path.nil?
122
+ test_file_dir = test_file_dir / options.path
123
+ end
124
+
125
+ path_to_test_file = test_file_dir / test_file_name
126
+
127
+ verb = "Created"
128
+ dry_run_verb = "create"
129
+
130
+ if path_to_test_file.exist?
131
+ if global_options.overwrite?
132
+ verb = "Overwrote"
133
+ dry_run_verb = "overwrite"
134
+ else
135
+ err.puts "#{path_to_test_file.relative_path_from(Brut.container.project_root)} exists. Use --overwrite to replace it"
136
+ return 1
137
+ end
138
+ end
139
+
140
+
141
+ code = %{require "spec_helper"
142
+
143
+ RSpec.describe "#{test_name}" do
144
+ it "should have tests" do
145
+ page.goto("/")
146
+ expect(page).to be_page_for(page_class_here)
147
+ end
148
+ end}
149
+ if global_options.dry_run?
150
+ out.puts "Will #{dry_run_verb} #{path_to_test_file.relative_path_from(Brut.container.project_root)} with this code:"
151
+ out.puts_no_prefix
152
+ out.puts_no_prefix code
153
+ else
154
+ FileUtils.mkdir_p test_file_dir
155
+ File.open(path_to_test_file,"w") do |file|
156
+ file.puts code
157
+ end
158
+ out.puts "#{verb} #{path_to_test_file.relative_path_from(Brut.container.project_root)}"
159
+ end
160
+ 0
161
+ end
103
162
 
163
+ private
164
+
165
+ def find_classes(ast,current_modules = [])
166
+ classes = []
167
+ if ast.nil?
168
+ return classes
169
+ end
170
+ new_module = nil
171
+ if ast.kind_of?(Prism::ClassNode)
172
+ classes << [ current_modules, ast ]
173
+ new_module = ast
174
+ elsif ast.kind_of?(Prism::ModuleNode)
175
+ new_module = ast
176
+ end
177
+ ast.child_nodes.each do |child|
178
+ new_current_modules = current_modules + [ new_module ]
179
+ result = find_classes(child, new_current_modules.compact)
180
+ classes = classes + result
181
+ end
182
+ classes
183
+ end
104
184
  end
185
+
105
186
  class Component < Brut::CLI::Command
106
187
  description "Create a new component, template, and associated test"
188
+ detailed_description "New components go in the `components/` folder of your app, however using --page will create a 'page private' component. To do that, the component name must be an inner class of an existing page, for example HomePage::Welcome. This component goes in a sub-folder inside the `pages/` area of your app"
107
189
  opts.on("--page","If set, this component is for a specific page and won't go with the other components")
108
190
  args "ComponentName"
109
191
  def execute
110
192
  if args.length != 1
111
193
  raise "component requires exactly one argument, got #{args.length}"
112
194
  end
113
- class_name = RichString.new(args[0])
195
+ class_name = RichString.new(args[0]).capitalize(:first_only)
114
196
  if class_name.to_s !~ /Component$/
115
197
  class_name = RichString.new(class_name.to_s + "Component")
116
198
  end
@@ -153,9 +235,9 @@ end}
153
235
  end
154
236
 
155
237
  if global_options.dry_run?
156
- puts "FileUtils.mkdir_p #{source_path.dirname}"
157
- puts "FileUtils.mkdir_p #{html_source_path.dirname}"
158
- puts "FileUtils.mkdir_p #{spec_path.dirname}"
238
+ out.puts "FileUtils.mkdir_p #{source_path.dirname}"
239
+ out.puts "FileUtils.mkdir_p #{html_source_path.dirname}"
240
+ out.puts "FileUtils.mkdir_p #{spec_path.dirname}"
159
241
  else
160
242
  FileUtils.mkdir_p source_path.dirname
161
243
  FileUtils.mkdir_p html_source_path.dirname
@@ -186,6 +268,293 @@ end}
186
268
  0
187
269
  end
188
270
  end
271
+ class Page < Brut::CLI::Command
272
+ class Route < Brut::FrontEnd::Routing::PageRoute
273
+ def initialize(path_template)
274
+ path_template = "/#{path_template}".gsub(/\/\//,"/")
275
+ super(path_template)
276
+ end
277
+ def locate_handler_class(suffix,preposition, on_missing: :raise)
278
+ begin
279
+ super(suffix,preposition,on_missing: :raise).name.split(/::/)
280
+ rescue Brut::Framework::Errors::NoClassForPath => ex
281
+ class_name_path = ex.class_name_path
282
+ ex.class_name_path
283
+ end
284
+ end
285
+ end
286
+ description "Create a new page, template, and associated test"
287
+ args "page_route"
288
+ def execute
289
+ if args.length != 1
290
+ raise "page requires exactly one argument, got #{args.length}"
291
+ end
292
+ route = Route.new(args[0])
293
+
294
+ page_class_name = RichString.from_string(route.handler_class.join("::"))
295
+ page_relative_path = page_class_name.underscorized
296
+
297
+ pages_src_dir = Brut.container.pages_src_dir
298
+ pages_specs_dir = Brut.container.pages_specs_dir
299
+ i18n_locales_dir = Brut.container.i18n_locales_dir
300
+
301
+ page_source_path = Pathname( (pages_src_dir / page_relative_path).to_s + ".rb" )
302
+ template_source_path = Pathname( (pages_src_dir / page_relative_path).to_s + ".html.erb" )
303
+ page_spec_path = Pathname( (pages_specs_dir / page_relative_path).to_s + ".spec.rb" )
304
+ app_path = Pathname( Brut.container.app_src_dir / "app.rb" )
305
+ app_translations = Pathname( i18n_locales_dir / "en" / "2_app.rb")
306
+
307
+ exists = [
308
+ page_source_path,
309
+ template_source_path,
310
+ page_spec_path,
311
+ ].select(&:exist?)
312
+
313
+ if exists.any? && !global_options.overwrite?
314
+ exists.each do |path|
315
+ err.puts "'#{path.relative_path_from(Brut.container.project_root)}' exists already"
316
+ end
317
+ err.puts "Re-run with --overwrite to overwrite these files"
318
+ return 1
319
+ end
320
+
321
+ FileUtils.mkdir_p page_source_path.dirname, noop: global_options.dry_run?
322
+ FileUtils.mkdir_p template_source_path.dirname, noop: global_options.dry_run?
323
+ FileUtils.mkdir_p page_spec_path.dirname, noop: global_options.dry_run?
324
+
325
+ route_code = "page \"#{route.path_template}\""
326
+
327
+ initializer_params = route.path_params
328
+ initializer_params_code = if initializer_params.empty?
329
+ ""
330
+ else
331
+ "(" + initializer_params.map { "#{it}:" }.join(", ") + ")"
332
+ end
333
+
334
+ page_class_code = %{class #{page_class_name} < AppPage
335
+ def initialize#{initializer_params_code} # add needed arguments here
336
+ end
337
+ end}
338
+ template_code = %{<h1>#{page_class_name} is ready!</h1>}
339
+ page_spec_code = %{require "spec_helper"
340
+
341
+ RSpec.describe #{page_class_name} do
342
+ it "should have tests" do
343
+ expect(true).to eq(false)
344
+ end
345
+ end}
346
+
347
+ title = RichString.new(page_class_name).underscorized.humanized.to_s.capitalize
348
+ translations_code = " \"#{page_class_name}\": {\n title: \"#{title}\",\n \},"
349
+
350
+ if global_options.dry_run?
351
+ out.puts app_path.relative_path_from(Brut.container.project_root)
352
+ out.puts "will contain:\n\n#{route_code}\n\n"
353
+ out.puts page_source_path.relative_path_from(Brut.container.project_root)
354
+ out.puts "will contain:\n\n#{page_class_code}\n\n"
355
+ out.puts template_source_path.relative_path_from(Brut.container.project_root)
356
+ out.puts "will contain:\n\n#{template_code}\n\n"
357
+ out.puts page_spec_path.relative_path_from(Brut.container.project_root)
358
+ out.puts "will contain:\n\n#{page_spec_code}\n\n"
359
+ out.puts app_translations.relative_path_from(Brut.container.project_root)
360
+ out.puts "will contain:\n\n#{translations_code}\n\n"
361
+ else
362
+
363
+ File.open(page_source_path,"w") { it.puts page_class_code }
364
+ File.open(template_source_path,"w") { it.puts template_code }
365
+ File.open(page_spec_path,"w") { it.puts page_spec_code }
366
+
367
+ existing_translations = File.read(app_translations).split(/\n/)
368
+ inserted_translation = false
369
+ File.open(app_translations,"w") do |file|
370
+ existing_translations.each do |line|
371
+ if line =~ /^ pages:\s*{/
372
+ file.puts line
373
+ file.puts translations_code
374
+ inserted_translation = true
375
+ else
376
+ file.puts line
377
+ end
378
+ end
379
+ end
380
+ if !inserted_translation
381
+ err.puts "WARNING: Could not find a place to insert the translation for this page's title"
382
+ err.puts " The page may not render properly the first time you load it"
383
+ end
384
+
385
+ routes_editor = RoutesEditor.new(app_path:,out:)
386
+ routes_editor.add_route!(route_code:)
387
+
388
+ if !routes_editor.found_routes?
389
+ out.puts "Could not find routes declaration in #{app_path.relative_path_from(Brut.container.project_root)}"
390
+ out.puts "Please add this to wherever you have defined your routes:\n\n#{route_code}\n\n"
391
+ elsif routes_editor.routes_existed?
392
+ out.puts "Routes declaration in #{app_path.relative_path_from(Brut.container.project_root)} contained the route defition already"
393
+ out.puts "Please make sure everything is correct. Here is the defintion that was not inserted:\n\n#{route_code}"
394
+ end
395
+ end
396
+ out.puts "Page source is in #{page_source_path.relative_path_from(Brut.container.project_root)}"
397
+ out.puts "Page HTML template is in #{template_source_path.relative_path_from(Brut.container.project_root)}"
398
+ out.puts "Page test is in #{page_spec_path.relative_path_from(Brut.container.project_root)}"
399
+ out.puts "Added title to #{app_translations.relative_path_from(Brut.container.project_root)}"
400
+ out.puts "Added route to #{app_path.relative_path_from(Brut.container.project_root)}"
401
+ 0
402
+ end
403
+ end
404
+ class Action < Brut::CLI::Command
405
+ class Route < Brut::FrontEnd::Routing::FormRoute
406
+ def initialize(path_template)
407
+ path_template = "/#{path_template}".gsub(/\/\//,"/")
408
+ super(path_template)
409
+ end
410
+ def locate_handler_class(suffix,preposition, on_missing: :raise)
411
+ begin
412
+ super(suffix,preposition,on_missing: :raise).name.split(/::/)
413
+ rescue Brut::Framework::Errors::NoClassForPath => ex
414
+ class_name_path = ex.class_name_path
415
+ ex.class_name_path
416
+ end
417
+ end
418
+ end
419
+ description "Create a handler for an action"
420
+ args "action_route"
421
+ opts.on "--http-method=METHOD", "If present, the action will be a path available on the given route and this HTTP method. If omitted, this will create an action available via POST"
422
+ def execute(form: false)
423
+ if args.length != 1
424
+ raise "#{self.class.command_name} requires exactly one argument, got #{args.length}"
425
+ end
426
+ route = Route.new(args[0])
427
+
428
+ form_class_name = RichString.from_string(route.form_class.join("::"))
429
+ handler_class_name = RichString.from_string(route.handler_class.join("::"))
430
+
431
+ relative_path = form_class_name.underscorized
432
+ handler_relative_path = handler_class_name.underscorized
433
+
434
+ forms_src_dir = Brut.container.forms_src_dir
435
+ handlers_src_dir = Brut.container.handlers_src_dir
436
+ handlers_specs_dir = Brut.container.handlers_specs_dir
437
+
438
+ form_source_path = Pathname( (forms_src_dir / relative_path).to_s + ".rb" )
439
+ handler_source_path = Pathname( (handlers_src_dir / handler_relative_path).to_s + ".rb" )
440
+ handler_spec_path = Pathname( (handlers_specs_dir / handler_relative_path).to_s + ".spec.rb" )
441
+ app_path = Pathname( Brut.container.app_src_dir / "app.rb" )
442
+
443
+ paths_to_check = [
444
+ handler_source_path,
445
+ handler_spec_path,
446
+ ]
447
+ if form
448
+ paths_to_check << form_source_path
449
+ end
450
+
451
+ exists = paths_to_check.select(&:exist?)
452
+
453
+ if exists.any? && !global_options.overwrite?
454
+ exists.each do |path|
455
+ err.puts "'#{path.relative_path_from(Brut.container.project_root)}' exists already"
456
+ end
457
+ err.puts "Re-run with global option --overwrite to overwrite these files"
458
+ return 1
459
+ end
460
+
461
+ if form
462
+ FileUtils.mkdir_p form_source_path.dirname, noop: global_options.dry_run?
463
+ end
464
+ FileUtils.mkdir_p handler_source_path.dirname, noop: global_options.dry_run?
465
+ FileUtils.mkdir_p handler_spec_path.dirname, noop: global_options.dry_run?
466
+
467
+ form_code = %{class #{form_class_name} < AppForm
468
+ input :some_field, minlength: 3
469
+ end}
470
+ handle_method_code = if form
471
+ 'raise "You need to implement your Handler\#{form.class.input_definitions.length < 2 ? " and likely your Form as well" : ""}"'
472
+ else
473
+ raise "You need to implement your Handler"
474
+ end
475
+ handler_code = begin
476
+ handle_params = []
477
+ if form
478
+ handle_params << :form
479
+ end
480
+ handle_params += route.path_params
481
+ handle_params_code = handle_params.map { "#{it}:" }.join(", ")
482
+ %{class #{handler_class_name} < AppHandler
483
+ def handle(#{handle_params_code}) # add other args here as needed
484
+ #{handle_method_code}
485
+ end
486
+ end}
487
+ end
488
+
489
+ spec_code = %{require "spec_helper"
490
+
491
+ RSpec.describe #{handler_class_name} do
492
+ subject(:handler) { described_class.new }
493
+ describe "#handle!" do
494
+ it "needs tests" do
495
+ expect(true).to eq(false)
496
+ end
497
+ end
498
+ end}
499
+
500
+ route_code = if form
501
+ "form \"#{route.path_template}\""
502
+ elsif options.http_method.nil?
503
+ "action \"#{route.path_template}\""
504
+ else
505
+ "path \"#{route.path_template}\", method: :#{options.http_method.downcase}"
506
+ end
507
+
508
+ if global_options.dry_run?
509
+ out.puts app_path.relative_path_from(Brut.container.project_root)
510
+ out.puts "will contain:\n\n#{route_code}\n\n"
511
+ if form
512
+ out.puts form_source_path.relative_path_from(Brut.container.project_root)
513
+ out.puts "will contain:\n\n#{form_code}\n\n"
514
+ end
515
+ out.puts handler_source_path.relative_path_from(Brut.container.project_root)
516
+ out.puts "will contain:\n\n#{handler_code}\n\n"
517
+ out.puts handler_spec_path.relative_path_from(Brut.container.project_root)
518
+ out.puts "will contain:\n\n#{spec_code}\n\n"
519
+ else
520
+ class_name_length = [ form_class_name.length, handler_class_name.length, "Spec".length ].max
521
+ printf_string = "%-#{class_name_length}s in %s\n"
522
+ out.puts "\n\n"
523
+ if form
524
+ out.printf printf_string,form_class_name,form_source_path.relative_path_from(Brut.container.project_root)
525
+ end
526
+ out.printf printf_string,handler_class_name, handler_source_path.relative_path_from(Brut.container.project_root)
527
+ out.printf printf_string,"Spec", handler_spec_path.relative_path_from(Brut.container.project_root)
528
+
529
+ routes_editor = RoutesEditor.new(app_path:,out:)
530
+ routes_editor.add_route!(route_code:)
531
+
532
+ if form
533
+ File.open(form_source_path,"w") { it.puts form_code }
534
+ end
535
+ File.open(handler_source_path,"w") { it.puts handler_code }
536
+ File.open(handler_spec_path,"w") { it.puts spec_code }
537
+ if !routes_editor.found_routes?
538
+ out.puts "Could not find routes declaration in #{app_path.relative_path_from(Brut.container.project_root)}"
539
+ out.puts "Please add this to wherever you have defined your routes:\n\n#{route_code}\n\n"
540
+ elsif routes_editor.routes_existed?
541
+ out.puts "Routes declaration in #{app_path.relative_path_from(Brut.container.project_root)} contained the route defition already"
542
+ out.puts "Please make sure everything is correct. Here is the defintion that was not inserted:\n\n#{route_code}"
543
+ end
544
+ end
545
+ 0
546
+ end
547
+ end
548
+
549
+ class Form < Action
550
+ description "Create a form and handler"
551
+ args "form_route"
552
+
553
+ def execute
554
+ super(form:true)
555
+ end
556
+ end
557
+
189
558
  class CustomElementTest < Brut::CLI::Command
190
559
  description "Create a test for a custom element in your app"
191
560
  args "path_to_js_files..."
@@ -214,7 +583,7 @@ end}
214
583
 
215
584
  if existing_files.any? && !global_options.overwrite?
216
585
  relative_paths = existing_files.map { |_,pathname| pathname.relative_path_from(Brut.container.project_root) }
217
- err.puts "Some files to be generated already exist. Set --overwrite to overwrite them:"
586
+ err.puts "Some files to be generated already exist. Set global option --overwrite to overwrite them:"
218
587
  relative_paths.each do |file|
219
588
  err.puts file
220
589
  end
@@ -253,4 +622,41 @@ describe("#{description}", () => {
253
622
  0
254
623
  end
255
624
  end
625
+
626
+ class RoutesEditor
627
+ def initialize(app_path:,out:)
628
+ @app_path = app_path
629
+ @out = out
630
+ @found_routes = false
631
+ @routes_existed = false
632
+ end
633
+
634
+ def found_routes? = @found_routes
635
+ def routes_existed? = @routes_existed
636
+
637
+ def add_route!(route_code:)
638
+ app_contents = File.read(@app_path).split(/\n/)
639
+ File.open(@app_path,"w") do |file|
640
+ in_routes = false
641
+ app_contents.each do |line|
642
+ if line =~ /^ routes do\s*$/
643
+ in_routes = true
644
+ end
645
+ if in_routes && line.include?(route_code)
646
+ @routes_existed = true
647
+ end
648
+ if in_routes && line =~ /^ end\s*$/
649
+ if !@routes_existed
650
+ @out.puts "Inserted route into #{@app_path.relative_path_from(Brut.container.project_root)}"
651
+ file.puts " #{route_code}"
652
+ end
653
+ @found_routes = true
654
+ in_routes = false
655
+ end
656
+ file.puts line
657
+ end
658
+ end
659
+ end
660
+ end
256
661
  end
662
+
@@ -15,6 +15,7 @@ class Brut::CLI::Apps::Test < Brut::CLI::App
15
15
  opts.on("--[no-]rebuild-after", "If true, test database is rebuilt after tests are run (default false)")
16
16
  opts.on("--seed SEED", "Set the random seed to allow duplicating a test run")
17
17
  args "specs_to_run..."
18
+ env_var("LOGGER_LEVEL_FOR_TESTS",purpose: "Can be set to debug, info, warn, error, or fatal to control logging during tests. Defaults to 'warn' to avoid verbose test output")
18
19
 
19
20
  def rspec_command
20
21
  parts = [
@@ -64,6 +65,10 @@ class Brut::CLI::Apps::Test < Brut::CLI::App
64
65
  opts.on("--[no-]rebuild-after", "If true, test database is rebuilt after tests are run (default true)")
65
66
  opts.on("--seed SEED", "Set the random seed to allow duplicating a test run")
66
67
  args "specs_to_run..."
68
+ env_var("E2E_RECORD_VIDEOS",purpose: "If set to 'true', videos of each test run are saved in `./tmp/e2e-videos`")
69
+ env_var("E2E_SLOW_MO",purpose: "If set to, will attempt to slow operations down by this many milliseconds")
70
+ env_var("E2E_TIMEOUT_MS",purpose: "ms to wait for any browser activity before failing the test. And here you didn't think you'd get away without using sleep in browse-based tests?")
71
+ env_var("LOGGER_LEVEL_FOR_TESTS",purpose: "Can be set to debug, info, warn, error, or fatal to control logging during tests. Defaults to 'warn' to avoid verbose test output")
67
72
 
68
73
  def rspec_cli_args = "--tag e2e"
69
74
  def rebuild_by_default? = true
@@ -76,7 +81,15 @@ class Brut::CLI::Apps::Test < Brut::CLI::App
76
81
  if options.build_assets?
77
82
  system!({ "RACK_ENV" => "test" }, "bin/build-assets")
78
83
  end
79
- system!({ "NODE_DISABLE_COLORS" => "1" },"npx mocha #{Brut.container.js_specs_dir} --no-color --extension 'spec.js' --recursive")
84
+ begin
85
+ system!({ "NODE_DISABLE_COLORS" => "1" },"npx mocha #{Brut.container.js_specs_dir} --no-color --extension 'spec.js' --recursive")
86
+ rescue Brut::CLI::SystemExecError => ex
87
+ if ex.exit_status == 1
88
+ out.puts "mocha exited 1 - assuming this is because there are no test files and that this is intentional"
89
+ else
90
+ raise ex
91
+ end
92
+ end
80
93
  0
81
94
  end
82
95
  end