sandals 0.1.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.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module Sandals
8
+ class Server
9
+ SESSION_COOKIE = "sandals_session"
10
+ MAX_UPLOAD_SIZE = 10 * 1024 * 1024
11
+
12
+ attr_reader :port
13
+
14
+ def initialize(application, port: 0, reload_path: nil)
15
+ @application_factory = application
16
+ @reloader = CodeReloader.new(reload_path) if reload_path
17
+ @applications = {}
18
+ @applications_mutex = Mutex.new
19
+ @server = WEBrick::HTTPServer.new(
20
+ BindAddress: "127.0.0.1",
21
+ Port: port,
22
+ AccessLog: [],
23
+ Logger: WEBrick::Log.new(File::NULL)
24
+ )
25
+ @port = @server.listeners.first.addr[1]
26
+ @server.mount_proc("/") { |request, response| render(request, response) }
27
+ end
28
+
29
+ def start
30
+ @server.start
31
+ end
32
+
33
+ def stop
34
+ @server.shutdown
35
+ end
36
+
37
+ def url
38
+ "http://127.0.0.1:#{port}"
39
+ end
40
+
41
+ private
42
+
43
+ def render(request, response)
44
+ if request.request_method == "GET" && request.path == HTMLRenderer::TURBO_URL
45
+ render_turbo(response)
46
+ return
47
+ end
48
+
49
+ if request.request_method == "GET" && request.path == "/__sandals/reload"
50
+ render_reload(response)
51
+ return
52
+ end
53
+
54
+ application = application_for(request, response)
55
+ if request.request_method == "POST" && request.path == "/events"
56
+ render_event(request, response, application)
57
+ elsif request.request_method == "POST" && request.path == "/files"
58
+ render_file(request, response, application)
59
+ else
60
+ response.status = 200
61
+ response["content-type"] = "text/html; charset=utf-8"
62
+ response.body = application.render(development_reload: !@reloader.nil?)
63
+ end
64
+ rescue StandardError => error
65
+ response.status = 500
66
+ response["content-type"] = "text/plain; charset=utf-8"
67
+ response.body = "#{error.class}: #{error.message}\n#{error.backtrace&.first}"
68
+ end
69
+
70
+ def render_turbo(response)
71
+ response.status = 200
72
+ response["cache-control"] = "public, max-age=31536000, immutable"
73
+ response["content-type"] = "text/javascript; charset=utf-8"
74
+ response.body = File.binread(HTMLRenderer::TURBO_PATH)
75
+ end
76
+
77
+ def render_event(request, response, application)
78
+ message = JSON.parse(request.body)
79
+ revision, content = application.dispatch(
80
+ revision: message.fetch("revision"),
81
+ oldest_pending_revision: message.fetch("oldest_pending_revision"),
82
+ events: message.fetch("events")
83
+ )
84
+
85
+ render_update(response, revision, content)
86
+ rescue ActionError => error
87
+ render_action_error(response, message.fetch("revision"), error)
88
+ end
89
+
90
+ def render_file(request, response, application)
91
+ form = request.query
92
+ revision = Integer(form.fetch("revision"))
93
+ file_data = form.fetch("file")
94
+ content = file_data.to_s.b
95
+ if content.bytesize > MAX_UPLOAD_SIZE
96
+ response.status = 413
97
+ response["content-type"] = "text/plain; charset=utf-8"
98
+ response.body = "uploaded file exceeds the 10 MB limit"
99
+ return
100
+ end
101
+
102
+ file = UploadedFile.new(
103
+ name: file_data.filename,
104
+ content_type: file_data["content-type"] || "application/octet-stream",
105
+ content:
106
+ )
107
+ next_revision, rendered_view = application.dispatch(
108
+ revision:,
109
+ oldest_pending_revision: Integer(form.fetch("oldest_pending_revision")),
110
+ events: [
111
+ {
112
+ "target" => form.fetch("target"),
113
+ "event" => "change",
114
+ "value" => file
115
+ }
116
+ ]
117
+ )
118
+
119
+ render_update(response, next_revision, rendered_view)
120
+ rescue ActionError => error
121
+ render_action_error(response, revision, error)
122
+ end
123
+
124
+ def render_update(response, revision, content)
125
+ response.status = 200
126
+ response["content-type"] = "text/vnd.turbo-stream.html; charset=utf-8"
127
+ response["X-Sandals-Revision"] = revision.to_s
128
+ response.body = HTMLRenderer.new.clear_action_error + <<~HTML
129
+ <turbo-stream action="update" method="morph" target="sandals-view">
130
+ <template>#{content}</template>
131
+ </turbo-stream>
132
+ HTML
133
+ end
134
+
135
+ def render_action_error(response, revision, error)
136
+ response.status = 500
137
+ response["content-type"] = "text/vnd.turbo-stream.html; charset=utf-8"
138
+ response["X-Sandals-Revision"] = revision.to_s
139
+ response.body = HTMLRenderer.new.render_action_error(error)
140
+ end
141
+
142
+ def render_reload(response)
143
+ unless @reloader
144
+ response.status = 404
145
+ response.body = "development reload is not enabled"
146
+ return
147
+ end
148
+
149
+ result = @reloader.check
150
+ if result.application
151
+ @applications_mutex.synchronize do
152
+ @application_factory = result.application
153
+ @applications.clear
154
+ end
155
+ end
156
+
157
+ response.status = 200
158
+ response["cache-control"] = "no-store"
159
+ response["content-type"] = "application/json; charset=utf-8"
160
+ response.body = JSON.generate(
161
+ version: result.version,
162
+ error: result.error
163
+ )
164
+ end
165
+
166
+ def application_for(request, response)
167
+ session_id = request.cookies.find { |cookie| cookie.name == SESSION_COOKIE }&.value
168
+ application = @applications_mutex.synchronize { @applications[session_id] }
169
+ return application if application
170
+
171
+ session_id = SecureRandom.hex(16)
172
+ application = @application_factory.new_session
173
+ @applications_mutex.synchronize { @applications[session_id] = application }
174
+ response["Set-Cookie"] =
175
+ "#{SESSION_COOKIE}=#{session_id}; Path=/; HttpOnly; SameSite=Lax"
176
+ application
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class SnapshotHistory
5
+ Snapshot = Data.define(:revision, :view, :controls)
6
+
7
+ def initialize
8
+ @revision = 0
9
+ @snapshots = []
10
+ end
11
+
12
+ def publish(view, controls:)
13
+ @revision += 1
14
+ snapshot = Snapshot.new(@revision, view, controls)
15
+ @snapshots << snapshot
16
+ snapshot
17
+ end
18
+
19
+ def fetch(revision)
20
+ snapshot = @snapshots.find { |candidate| candidate.revision == revision }
21
+ return snapshot if snapshot
22
+
23
+ raise ArgumentError, "unknown revision: #{revision}"
24
+ end
25
+
26
+ def current
27
+ @snapshots.last
28
+ end
29
+
30
+ def discard_before(revision)
31
+ @snapshots.reject! { |snapshot| snapshot.revision < revision }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class TestSession
5
+ class AssertionError < StandardError; end
6
+
7
+ class FieldLocator
8
+ attr_reader :label
9
+
10
+ def initialize(session, label)
11
+ @session = session
12
+ @label = label.to_s
13
+ end
14
+
15
+ def fill(value)
16
+ @session.fill(label, with: value)
17
+ end
18
+
19
+ def check
20
+ @session.check(label)
21
+ end
22
+
23
+ def uncheck
24
+ @session.uncheck(label)
25
+ end
26
+
27
+ def select(option)
28
+ @session.select(option, from: label)
29
+ end
30
+
31
+ def choose(option)
32
+ @session.choose(option, from: label)
33
+ end
34
+
35
+ def value
36
+ @session.field_value(label)
37
+ end
38
+
39
+ def attach(file)
40
+ @session.attach(label, file:)
41
+ end
42
+
43
+ def inspect
44
+ %(#<#{self.class} label="#{label}">)
45
+ end
46
+ end
47
+
48
+ FIELD_TYPES = [
49
+ TextField,
50
+ NumberField,
51
+ TextArea,
52
+ Checkbox,
53
+ Select,
54
+ Radio,
55
+ FileField
56
+ ].freeze
57
+ FILLABLE_TYPES = [TextField, NumberField, TextArea].freeze
58
+
59
+ def initialize(application)
60
+ @application = application
61
+ use_snapshot(@application.__send__(:publish_view))
62
+ end
63
+
64
+ def field(label)
65
+ find_field(label)
66
+ FieldLocator.new(self, label)
67
+ end
68
+
69
+ def fill(label, with:)
70
+ control = find_field(label)
71
+ unless FILLABLE_TYPES.any? { |type| control.is_a?(type) }
72
+ raise AssertionError, %(field "#{label}" cannot be filled)
73
+ end
74
+
75
+ dispatch(control, "change", "value" => serialize(with))
76
+ self
77
+ end
78
+
79
+ def field_value(label_or_locator)
80
+ label =
81
+ if label_or_locator.is_a?(FieldLocator)
82
+ label_or_locator.label
83
+ else
84
+ label_or_locator
85
+ end
86
+ control = find_field(label)
87
+ return control.checked? if control.is_a?(Checkbox)
88
+
89
+ control.value
90
+ end
91
+
92
+ def click(content)
93
+ button = find_one(
94
+ controls.select do |control|
95
+ control.is_a?(Button) && control.content == content.to_s
96
+ end,
97
+ %(button named "#{content}")
98
+ )
99
+ dispatch(button, button.is_a?(Submit) ? "submit" : "click")
100
+ self
101
+ end
102
+
103
+ def attach(label, file:)
104
+ control = find_field(label)
105
+ unless control.is_a?(FileField)
106
+ raise AssertionError, %(field "#{label}" is not a file field)
107
+ end
108
+
109
+ uploaded_file =
110
+ if file.is_a?(UploadedFile)
111
+ file
112
+ else
113
+ path = File.expand_path(file.to_s)
114
+ UploadedFile.new(
115
+ name: File.basename(path),
116
+ content_type: content_type_for(path),
117
+ content: File.binread(path)
118
+ )
119
+ end
120
+
121
+ dispatch(control, "change", "value" => uploaded_file)
122
+ self
123
+ end
124
+
125
+ def check(label)
126
+ change_checkbox(label, true)
127
+ end
128
+
129
+ def uncheck(label)
130
+ change_checkbox(label, false)
131
+ end
132
+
133
+ def select(option, from:)
134
+ control = find_field(from)
135
+ unless control.instance_of?(Select)
136
+ raise AssertionError, %(field "#{from}" is not a select)
137
+ end
138
+
139
+ change_option(control, option)
140
+ end
141
+
142
+ def choose(option, from:)
143
+ control = find_field(from)
144
+ unless control.is_a?(Radio)
145
+ raise AssertionError, %(field "#{from}" is not a radio)
146
+ end
147
+
148
+ change_option(control, option)
149
+ end
150
+
151
+ def assert_text(expected)
152
+ return self if text?(expected)
153
+
154
+ raise AssertionError,
155
+ %(expected the view to include "#{expected}", but it rendered:\n#{visible_text})
156
+ end
157
+
158
+ def refute_text(unexpected)
159
+ return self unless text?(unexpected)
160
+
161
+ raise AssertionError,
162
+ %(expected the view not to include "#{unexpected}", but it rendered:\n#{visible_text})
163
+ end
164
+
165
+ def text?(expected)
166
+ visible_text.include?(expected.to_s)
167
+ end
168
+
169
+ def notice?(expected)
170
+ messages(Notice).include?(expected.to_s)
171
+ end
172
+
173
+ def error?(expected)
174
+ messages(Error).include?(expected.to_s)
175
+ end
176
+
177
+ def assert_notice(expected)
178
+ assert_message(Notice, "notice", expected)
179
+ end
180
+
181
+ def refute_notice(unexpected)
182
+ refute_message(Notice, "notice", unexpected)
183
+ end
184
+
185
+ def assert_error(expected)
186
+ assert_message(Error, "error", expected)
187
+ end
188
+
189
+ def refute_error(unexpected)
190
+ refute_message(Error, "error", unexpected)
191
+ end
192
+
193
+ def field_error(label_or_locator)
194
+ label =
195
+ if label_or_locator.is_a?(FieldLocator)
196
+ label_or_locator.label
197
+ else
198
+ label_or_locator
199
+ end
200
+ find_field(label).error
201
+ end
202
+
203
+ def assert_field_error(label_or_locator, expected)
204
+ actual = field_error(label_or_locator)
205
+ return self if actual == expected.to_s
206
+
207
+ label =
208
+ if label_or_locator.is_a?(FieldLocator)
209
+ label_or_locator.label
210
+ else
211
+ label_or_locator
212
+ end
213
+
214
+ raise AssertionError,
215
+ "expected field \"#{label}\" to have error \"#{expected}\", " \
216
+ "but its error was #{actual.inspect}"
217
+ end
218
+
219
+ private
220
+
221
+ def find_field(label)
222
+ find_one(
223
+ controls.select do |control|
224
+ FIELD_TYPES.any? { |type| control.is_a?(type) } &&
225
+ control.label == label.to_s
226
+ end,
227
+ %(field labeled "#{label}")
228
+ )
229
+ end
230
+
231
+ def find_one(matches, description)
232
+ return matches.first if matches.one?
233
+
234
+ raise AssertionError,
235
+ "expected one #{description}, but found #{matches.length}"
236
+ end
237
+
238
+ def assert_message(type, name, expected)
239
+ return self if messages(type).include?(expected.to_s)
240
+
241
+ raise AssertionError,
242
+ %(expected #{name} "#{expected}", but found #{messages(type).inspect})
243
+ end
244
+
245
+ def refute_message(type, name, unexpected)
246
+ return self unless messages(type).include?(unexpected.to_s)
247
+
248
+ raise AssertionError,
249
+ %(expected not to find #{name} "#{unexpected}", but found #{messages(type).inspect})
250
+ end
251
+
252
+ def messages(type)
253
+ descendants(@view)
254
+ .select { |element| element.is_a?(type) }
255
+ .map(&:content)
256
+ end
257
+
258
+ def controls
259
+ descendants(@view).select do |element|
260
+ FIELD_TYPES.any? { |type| element.is_a?(type) } ||
261
+ element.is_a?(Button)
262
+ end
263
+ end
264
+
265
+ def descendants(container)
266
+ container.children.flat_map do |element|
267
+ element.is_a?(Container) ? [element, *descendants(element)] : element
268
+ end
269
+ end
270
+
271
+ def dispatch(control, event, attributes = {})
272
+ revision, = @application.dispatch(
273
+ revision: @revision,
274
+ events: [
275
+ {
276
+ "target" => control.id,
277
+ "event" => event,
278
+ **attributes
279
+ }
280
+ ]
281
+ )
282
+ snapshot = @application.__send__(:current_snapshot)
283
+ raise "snapshot revision mismatch" unless snapshot.revision == revision
284
+
285
+ use_snapshot(snapshot)
286
+ end
287
+
288
+ def change_checkbox(label, checked)
289
+ control = find_field(label)
290
+ unless control.is_a?(Checkbox)
291
+ raise AssertionError, %(field "#{label}" is not a checkbox)
292
+ end
293
+
294
+ dispatch(control, "change", "value" => checked)
295
+ self
296
+ end
297
+
298
+ def change_option(control, option_label)
299
+ option = find_one(
300
+ control.options.select { |candidate| candidate.label == option_label.to_s },
301
+ %(option named "#{option_label}" in field "#{control.label}")
302
+ )
303
+ dispatch(control, "change", "value" => option.value.to_s)
304
+ self
305
+ end
306
+
307
+ def use_snapshot(snapshot)
308
+ @revision = snapshot.revision
309
+ @view = snapshot.view
310
+ end
311
+
312
+ def serialize(value)
313
+ value.nil? ? "" : value.to_s
314
+ end
315
+
316
+ def content_type_for(path)
317
+ File.extname(path).downcase == ".csv" ? "text/csv" : "application/octet-stream"
318
+ end
319
+
320
+ def visible_text
321
+ text_for(@view).reject(&:empty?).join("\n")
322
+ end
323
+
324
+ def text_for(element)
325
+ case element
326
+ when View, Container
327
+ element.children.flat_map { |child| text_for(child) }
328
+ when Title, Subtitle, Error, Notice, Strong, Emphasis
329
+ [element.content]
330
+ when Para
331
+ [element.parts.flat_map { |part| text_for(part) }.join]
332
+ when Image
333
+ [element.alt]
334
+ when Link
335
+ [element.content]
336
+ when Table
337
+ [
338
+ *element.headers,
339
+ *element.rows.flat_map { |row| row.flat_map { |cell| text_for(cell) } }
340
+ ]
341
+ when TextField, NumberField, TextArea, Checkbox, Select, FileField
342
+ [element.label, element.error.to_s]
343
+ when Button
344
+ [element.content]
345
+ when String
346
+ [element]
347
+ else
348
+ []
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class UploadedFile
5
+ attr_reader :name, :content_type, :size
6
+
7
+ def initialize(name:, content_type:, content:)
8
+ @name = File.basename(name.to_s)
9
+ @content_type = content_type.to_s
10
+ @content = content.to_s.b.freeze
11
+ @size = @content.bytesize
12
+ end
13
+
14
+ def read
15
+ @content.dup
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ VERSION = "0.1.0"
5
+ end