syntropy 0.38.0 → 0.39.0

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.
@@ -21,7 +21,7 @@ module Syntropy
21
21
  # reference `_lib/foo` will point to a module residing in
22
22
  # `/my_site/_lib/foo.rb`.
23
23
  class ModuleLoader
24
- attr_reader :modules
24
+ attr_reader :modules, :app_root
25
25
 
26
26
  # Instantiates a module loader
27
27
  #
@@ -30,10 +30,14 @@ module Syntropy
30
30
  # @return [void]
31
31
  def initialize(env, extensions: nil)
32
32
  @env = env
33
+ @machine = env[:machine]
33
34
  @app_root = env[:app_root]
34
35
  @modules = {} # maps ref to module entry
35
36
  @fn_map = {} # maps filename to ref
36
37
  @extensions = extensions
38
+ @loading = Set.new
39
+ @lock = UM::Mutex.new
40
+ @lock_holder = nil
37
41
  end
38
42
 
39
43
  # Loads a module (if not already loaded) and returns its export value.
@@ -41,14 +45,16 @@ module Syntropy
41
45
  # @param ref [String] module reference
42
46
  # @return [any] export value
43
47
  def load(ref, raise_on_missing: true)
44
- ref = "/#{ref}" if ref !~ /^\//
45
- if !(entry = @modules[ref])
46
- entry = load_module(ref, raise_on_missing:)
47
- return if !entry
48
-
49
- @modules[ref] = entry
48
+ lock do
49
+ ref = "/#{ref}" if ref !~ /^\//
50
+ if !(entry = @modules[ref])
51
+ entry = load_module(ref, raise_on_missing:)
52
+ return if !entry
53
+
54
+ @modules[ref] = entry
55
+ end
56
+ entry[:export_value]
50
57
  end
51
- entry[:export_value]
52
58
  end
53
59
 
54
60
  # Returns a list of modules found in the given relative path. The module
@@ -70,13 +76,27 @@ module Syntropy
70
76
  # @param fn [String] module filename
71
77
  # @return [void]
72
78
  def invalidate_fn(fn)
73
- ref = @fn_map[fn]
74
- invalidate_ref(ref) if ref
75
- invalidate_collection_modules
79
+ lock do
80
+ ref = @fn_map[fn]
81
+ invalidate_ref(ref) if ref
82
+ invalidate_collection_modules
83
+ end
76
84
  end
77
85
 
78
86
  private
79
87
 
88
+ # Synchronizes access to the module loader state
89
+ def lock
90
+ return yield if @lock_holder == Fiber.current
91
+
92
+ @machine.synchronize(@lock) do
93
+ @lock_holder = Fiber.current
94
+ yield
95
+ ensure
96
+ @lock_holder = nil
97
+ end
98
+ end
99
+
80
100
  # Invalidates a module by its reference, normally following a change to the
81
101
  # underlying file (in order to cause reloading of the module). The module
82
102
  # will be removed from the modules map, as well as modules dependending on
@@ -131,14 +151,21 @@ module Syntropy
131
151
  return
132
152
  end
133
153
 
154
+ raise Syntropy::Error, "Circular dependency detected" if @loading.include?(ref)
155
+ do_load_module(ref, fn, raise_on_missing:)
156
+ end
157
+
158
+ def do_load_module(ref, fn, raise_on_missing:)
159
+ @loading << ref
134
160
  @fn_map[fn] = ref
135
- code = IO.read(fn)
161
+ code = read_file(fn)
136
162
  env = @env.merge(module_loader: self, ref: clean_ref(ref))
137
- mod = Syntropy::ModuleContext.load(env, code, fn, @extensions)
163
+ mod = Syntropy::ModuleContext.new(env, code, fn, @extensions)
138
164
  add_dependencies(ref, mod.__dependencies__)
139
165
  export_value = transform_module_export_value(
140
166
  mod.__export_value__, fn, raise_on_missing:
141
167
  )
168
+ @env[:logger]&.info(message: "Loaded module at #{fn}")
142
169
 
143
170
  {
144
171
  fn: fn,
@@ -146,6 +173,19 @@ module Syntropy
146
173
  export_value: export_value,
147
174
  reverse_deps: []
148
175
  }
176
+ rescue StandardError, SyntaxError => e
177
+ env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
178
+ e.is_a?(SyntaxError) ? handle_syntax_error(fn, e) : (raise e)
179
+ ensure
180
+ @loading.delete(ref)
181
+ end
182
+
183
+ def read_file(fn)
184
+ @machine.open(fn, UM::O_RDONLY) { |fd|
185
+ buf = +''
186
+ res = @machine.read(fd, buf, 1 << 20)
187
+ buf
188
+ }
149
189
  end
150
190
 
151
191
  # Cleans up a module reference specifier, turning /index into /
@@ -174,6 +214,17 @@ module Syntropy
174
214
  export_value
175
215
  end
176
216
  end
217
+
218
+ def handle_syntax_error(fn, e)
219
+ $stderr.puts("\n#{e.message}") if !Syntropy.test_mode
220
+ m = e.message.match(/^(.+): syntax/)
221
+ raise e if !m
222
+
223
+ location = m[1]
224
+ e2 = SyntaxError.new("Syntax errors found in module at #{fn}")
225
+ e2.set_backtrace([location] + e.backtrace)
226
+ raise e2
227
+ end
177
228
  end
178
229
 
179
230
  # The Syntropy::ModuleContext class provides a context for loading a module. A
@@ -193,44 +244,14 @@ module Syntropy
193
244
  # is set to `self`, and may be used to refer to various methods defined in the
194
245
  # module.
195
246
  class ModuleContext
196
- # Loads a module, returning the module instance
197
- # @param env [Hash] app environment
198
- # @param code [String] module source code
199
- # @param fn [String] module file name
200
- # @param extensions [Module, Array<Module>] extension module(s)
201
- # @return [Syntropy::ModuleContext] created module context
202
- def self.load(env, code, fn, extensions)
203
- mod = new(env)
204
- apply_extensions(mod, extensions)
205
- mod.instance_eval(code, fn)
206
- env[:logger]&.info(message: "Loaded module at #{fn}")
207
- mod
208
- rescue StandardError, SyntaxError => e
209
- env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
210
- e.is_a?(SyntaxError) ? handle_syntax_error(env, e) : (raise e)
211
- end
212
-
213
- # Applies the given extension(s) to the given module context.
214
- #
215
- # @param mod [Syntropy::ModuleContext] module context
216
- # @param extensions [Module, Array<Module>] extension module(s)
217
- def self.apply_extensions(mod, extensions)
218
- case extensions
219
- when Array
220
- extensions.each { mod.extend(it) }
221
- when Module
222
- mod.extend(extensions)
223
- when nil # return
224
- else
225
- raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
226
- end
227
- end
228
-
229
247
  # Initializes a module with the given environment hash.
230
248
  #
231
249
  # @param env [Hash] environment hash
250
+ # @param code [String] module source code
251
+ # @param fn [String] module filename
252
+ # @param extensions [Array<Module>, Module]
232
253
  # @return [void]
233
- def initialize(env)
254
+ def initialize(env, code, fn, extensions)
234
255
  @env = env
235
256
  @machine = env[:machine]
236
257
  @module_loader = env[:module_loader]
@@ -239,18 +260,13 @@ module Syntropy
239
260
  @logger = env[:logger]
240
261
  @__dependencies__ = []
241
262
  singleton_class.const_set(:MODULE, self)
263
+
264
+ apply_extensions(extensions)
265
+ instance_eval(code, fn)
242
266
  end
243
267
 
244
268
  attr_reader :__export_value__, :__dependencies__
245
269
 
246
- # Returns a list of pages found at the given ref.
247
- #
248
- # @param ref [String] directory reference
249
- # @return [Array] array of pages found in directory
250
- def page_list(ref)
251
- Syntropy.page_list(@env, ref)
252
- end
253
-
254
270
  # Returns true if the module is a collection module. See also
255
271
  # #collection_module!
256
272
  #
@@ -342,15 +358,20 @@ module Syntropy
342
358
  Syntropy::App.new(**env)
343
359
  end
344
360
 
345
- def handle_syntax_error(env, e)
346
- $stderr.puts("\n#{e.message}") if !Syntropy.test_mode
347
- m = e.message.match(/^(.+): syntax/)
348
- raise e if !m
349
-
350
- location = m[1]
351
- e2 = SyntaxError.new("Syntax errors found in module #{env[:ref]}")
352
- e2.set_backtrace([location] + e.backtrace)
353
- raise e2
361
+ # Applies the given extension(s) to the given module context.
362
+ #
363
+ # @param extensions [Module, Array<Module>] extension module(s)
364
+ # @return [void]
365
+ def apply_extensions(extensions)
366
+ case extensions
367
+ when Array
368
+ extensions.each { extend(it) }
369
+ when Module
370
+ extend(extensions)
371
+ when nil # return
372
+ else
373
+ raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
374
+ end
354
375
  end
355
376
  end
356
377
  end
@@ -228,6 +228,14 @@ module Syntropy
228
228
  nil
229
229
  end
230
230
 
231
+ # Expands a relative path based on the request's path.
232
+ #
233
+ # param rel_path [String]
234
+ # @return [String]
235
+ def rel(rel_path)
236
+ File.expand_path(File.join(path, rel_path))
237
+ end
238
+
231
239
  private
232
240
 
233
241
  # Parses an accept string into an array of accepted MIME types.
@@ -23,6 +23,7 @@ module Syntropy
23
23
  end
24
24
 
25
25
  def redirect(url, status = HTTP::FOUND)
26
+ url = File.expand_path(File.join(path, url)) if url =~ /^\./
26
27
  respond(nil, ':status' => status, 'Location' => url)
27
28
  end
28
29
 
data/lib/syntropy/test.rb CHANGED
@@ -12,6 +12,14 @@ module Syntropy
12
12
  HTTP = Syntropy::HTTP
13
13
 
14
14
  class << self
15
+ def global_env=(env)
16
+ @@global_env = env
17
+ end
18
+
19
+ def global_env
20
+ @@global_env
21
+ end
22
+
15
23
  # Gets/sets app environment for tests
16
24
  attr_accessor :env
17
25
  end
@@ -22,7 +30,8 @@ module Syntropy
22
30
  #
23
31
  # @return [Hash] test app environment
24
32
  def env
25
- self.class.env
33
+ klass = self.class
34
+ klass.env || klass.global_env
26
35
  end
27
36
 
28
37
  # Loads and returns a module with the given reference.
@@ -122,7 +131,7 @@ module Syntropy
122
131
  #
123
132
  # @return [void]
124
133
  def setup
125
- env = self.class.env
134
+ env = self.env
126
135
  raise 'Environment not set' if !env
127
136
 
128
137
  Syntropy.load_config(env)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.38.0'
4
+ VERSION = '0.39.0'
5
5
  end
data/lib/syntropy.rb CHANGED
@@ -77,7 +77,8 @@ module Syntropy
77
77
 
78
78
  loader_env = env.merge(
79
79
  app_root: env[:config_root],
80
- logger: nil
80
+ logger: nil,
81
+ machine: UM.new(size: 8)
81
82
  )
82
83
  loader = ModuleLoader.new(loader_env)
83
84
  if (config = loader.load(env[:mode], raise_on_missing: false))
@@ -3,6 +3,6 @@ export template { |**props|
3
3
  h1 'Foo'
4
4
  }
5
5
  content {
6
- render_yield(**props)
6
+ render_children(**props)
7
7
  }
8
8
  }
@@ -0,0 +1,8 @@
1
+ export template { |**props|
2
+ header {
3
+ h1 'Kuku'
4
+ }
5
+ content {
6
+ render_children(**props)
7
+ }
8
+ }
@@ -0,0 +1,2 @@
1
+ export self
2
+ B = import './b'
@@ -0,0 +1,2 @@
1
+ export self
2
+ C = import './c'
@@ -0,0 +1,2 @@
1
+ export self
2
+ A = import './a'
@@ -0,0 +1,9 @@
1
+ export self
2
+
3
+ @machine.sleep(rand(0.001..0.01))
4
+
5
+ @env[:concurrent_counter][:count] += 1
6
+
7
+ def id
8
+ object_id
9
+ end
data/test/test_app.rb CHANGED
@@ -100,7 +100,7 @@ class AppTest < Minitest::Test
100
100
  assert_equal 'About', req.response_body.chomp
101
101
 
102
102
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/about/foo')
103
- assert_equal '<!DOCTYPE html><html><head><title></title></head><body><p>Hello from Markdown</p></body></html>', req.response_body.gsub(/\n/, '')
103
+ assert_equal '<!DOCTYPE html><html><head></head><body><p>Hello from Markdown</p></body></html>', req.response_body.gsub(/\n/, '')
104
104
 
105
105
  req = @test_harness.request(':method' => 'HEAD', ':path' => '/test/about/foo')
106
106
  assert_nil req.response_body
data/test/test_caching.rb CHANGED
@@ -75,7 +75,7 @@ class CachingTest < Minitest::Test
75
75
  @adapter.serve_request
76
76
  response = read_client_side
77
77
 
78
- expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
78
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
79
79
  assert_equal expected, response
80
80
  end
81
81
 
@@ -92,7 +92,7 @@ class CachingTest < Minitest::Test
92
92
  @adapter.serve_request
93
93
  response = read_client_side
94
94
 
95
- expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
95
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
96
96
  assert_equal expected, response
97
97
 
98
98
  # good etag
@@ -118,7 +118,7 @@ class CachingTest < Minitest::Test
118
118
  @adapter.serve_request
119
119
  response = read_client_side
120
120
 
121
- expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
121
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=604800\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
122
122
  assert_equal expected, response
123
123
 
124
124
  # good etag
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+ require 'securerandom'
5
+
6
+ class MarkdownParseTest < Minitest::Test
7
+ def test_markdown_parse_md
8
+ md = <<~MD
9
+ ---
10
+ foo: bar
11
+ ---
12
+ foo *bar*
13
+ MD
14
+
15
+ h = {}
16
+ atts, md2 = Syntropy::Markdown.parse_md(h, md)
17
+
18
+ assert_equal h, atts
19
+ assert_equal({ foo: 'bar' }, atts)
20
+ assert_equal "foo *bar*\n", md2
21
+ end
22
+
23
+ def test_markdown_parse_file
24
+ FileUtils.mkdir_p('/tmp/test')
25
+ name = "2008-06-14-test-#{SecureRandom.hex(8)}"
26
+ path = "/tmp/test/#{name}.md"
27
+ IO.write(
28
+ path,
29
+ <<~MD
30
+ ---
31
+ bar: baz
32
+ ---
33
+ bar *baz*
34
+ MD
35
+ )
36
+
37
+ env = { app_root: '/' }
38
+ atts, md = Syntropy::Markdown.parse_file(path, env)
39
+ assert_equal "/tmp/test/#{name}", atts[:url]
40
+ assert_equal 'baz', atts[:bar]
41
+ assert_equal "bar *baz*\n", md
42
+
43
+ env = { app_root: '/tmp' }
44
+ atts, md = Syntropy::Markdown.parse_file(path, env)
45
+ assert_equal "/test/#{name}", atts[:url]
46
+ assert_equal 'baz', atts[:bar]
47
+ assert_equal "bar *baz*\n", md
48
+ end
49
+ end
50
+
51
+ class MarkdownControllerTest < Minitest::Test
52
+ HTTP = Syntropy::HTTP
53
+
54
+ def setup
55
+ @machine = UM.new
56
+ @root = File.join(__dir__, 'fixtures/app')
57
+ @env = { app_root: @root, machine: @machine }
58
+ @env[:module_loader] = Syntropy::ModuleLoader.new(@env)
59
+
60
+ @controller = nil
61
+ @test_harness = Syntropy::TestHarness.new(->(req) { @controller.(req) })
62
+ end
63
+
64
+ def test_markdown_renderer_no_layout
65
+ md = <<~MD
66
+ foo *bar*
67
+ MD
68
+
69
+ atts = { foo: 'bar', title: 'Title' }
70
+
71
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
72
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
73
+
74
+ assert_equal HTTP::OK, req.response_status
75
+ assert_equal(
76
+ "<!DOCTYPE html><html><head><title>Title</title></head><body><p>foo <em>bar</em></p>\n</body></html>",
77
+ req.response_body
78
+ )
79
+ end
80
+
81
+ def test_markdown_renderer_with_layout
82
+ src = <<~MD
83
+ ---
84
+ layout: kuku
85
+ ---
86
+ ## Bar
87
+ MD
88
+
89
+ atts, md = Syntropy::Markdown.parse_md({}, src)
90
+ assert_equal 'kuku', atts[:layout]
91
+
92
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
93
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
94
+
95
+ assert_equal HTTP::OK, req.response_status
96
+ assert_equal(
97
+ "<header><h1>Kuku</h1></header><content><h2 id=\"bar\">Bar</h2>\n</content>",
98
+ req.response_body
99
+ )
100
+ end
101
+
102
+ def test_markdown_renderer_with_missing_module_loader
103
+ src = <<~MD
104
+ ---
105
+ layout: kuku
106
+ ---
107
+ ## Bar
108
+ MD
109
+
110
+ @env[:module_loader] = nil
111
+ atts, md = Syntropy::Markdown.parse_md({}, src)
112
+ assert_equal 'kuku', atts[:layout]
113
+
114
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
115
+ assert_raises(Syntropy::Error) {
116
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
117
+ }
118
+ end
119
+
120
+ def test_markdown_renderer_with_invalid_layout
121
+ src = <<~MD
122
+ ---
123
+ layout: nunu
124
+ ---
125
+ ## Bar
126
+ MD
127
+
128
+ atts, md = Syntropy::Markdown.parse_md({}, src)
129
+ assert_equal 'nunu', atts[:layout]
130
+
131
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
132
+ assert_raises(Syntropy::Error) {
133
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
134
+ }
135
+ end
136
+
137
+ def test_markdown_renderer_with_code_snipet
138
+ md = <<~MD
139
+ foo
140
+
141
+ ```ruby
142
+ foo { bar }
143
+ ```
144
+
145
+ bar
146
+ MD
147
+
148
+ atts = { foo: 'bar' }
149
+
150
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
151
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
152
+
153
+ assert_equal HTTP::OK, req.response_status
154
+ assert_equal(
155
+ (
156
+ <<~HTML.chomp
157
+ <!DOCTYPE html><html><head></head><body><p>foo</p>
158
+
159
+ <div class=\"language-ruby highlighter-rouge\"><div class=\"highlight\"><pre class=\"highlight\"><code><span class=\"n\">foo</span> <span class=\"p\">{</span> <span class=\"n\">bar</span> <span class=\"p\">}</span>
160
+ </code></pre></div></div>
161
+
162
+ <p>bar</p>
163
+ </body></html>
164
+ HTML
165
+ ),
166
+ req.response_body
167
+ )
168
+ end
169
+
170
+ def test_markdown_renderer_with_embedded_papercraft_simple
171
+ md = <<~MD
172
+ foo
173
+
174
+ ```ruby
175
+ # render: true
176
+ foo { bar }
177
+ ```
178
+
179
+ bar
180
+ MD
181
+
182
+ atts = { foo: 'bar' }
183
+
184
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
185
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
186
+
187
+ assert_equal HTTP::OK, req.response_status
188
+ assert_equal(
189
+ (
190
+ <<~HTML.chomp
191
+ <!DOCTYPE html><html><head></head><body><p>foo</p>
192
+
193
+ <foo><bar></bar></foo>
194
+ <p>bar</p>
195
+ </body></html>
196
+ HTML
197
+ ),
198
+ req.response_body
199
+ )
200
+ end
201
+
202
+ def test_markdown_renderer_with_embedded_papercraft_env
203
+ md = <<~MD
204
+ ```ruby
205
+ # render: true
206
+ h3 @env[:app_root]
207
+ ```
208
+ bar
209
+ MD
210
+
211
+ atts = { foo: 'bar' }
212
+
213
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
214
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
215
+
216
+ assert_equal HTTP::OK, req.response_status
217
+ assert_equal(
218
+ (
219
+ <<~HTML.chomp
220
+ <!DOCTYPE html><html><head></head><body><h3>#{@env[:app_root]}</h3>
221
+ <p>bar</p>
222
+ </body></html>
223
+ HTML
224
+ ),
225
+ req.response_body
226
+ )
227
+ end
228
+
229
+ def test_markdown_renderer_with_embedded_papercraft_dynamic
230
+ md = <<~MD
231
+ ```ruby
232
+ # render: true
233
+ @env[:count] ||= 0
234
+ h3 (@env[:count] += 1)
235
+ ```
236
+ bar
237
+ MD
238
+
239
+ atts = { foo: 'bar' }
240
+
241
+ @controller = Syntropy::Markdown.make_controller(@env, atts, md)
242
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
243
+ assert_equal HTTP::OK, req.response_status
244
+ assert_equal(
245
+ (
246
+ <<~HTML.chomp
247
+ <!DOCTYPE html><html><head></head><body><h3>1</h3>
248
+ <p>bar</p>
249
+ </body></html>
250
+ HTML
251
+ ),
252
+ req.response_body
253
+ )
254
+
255
+ req = @test_harness.request(':method' => 'GET', ':path' => '/')
256
+ assert_equal HTTP::OK, req.response_status
257
+ assert_equal(
258
+ (
259
+ <<~HTML.chomp
260
+ <!DOCTYPE html><html><head></head><body><h3>2</h3>
261
+ <p>bar</p>
262
+ </body></html>
263
+ HTML
264
+ ),
265
+ req.response_body
266
+ )
267
+ end
268
+ end