wsv 0.10.1 → 0.12.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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class RangeRequestTest < Minitest::Test
6
+ def test_nil_header_is_full
7
+ result = Wsv::RangeRequest.parse(nil, 100)
8
+
9
+ assert_predicate result, :full?
10
+ end
11
+
12
+ def test_empty_header_is_full
13
+ result = Wsv::RangeRequest.parse("", 100)
14
+
15
+ assert_predicate result, :full?
16
+ end
17
+
18
+ def test_unparseable_syntax_is_full
19
+ # Per RFC 7233 an unparseable Range is treated as if absent.
20
+ result = Wsv::RangeRequest.parse("garbage", 100)
21
+
22
+ assert_predicate result, :full?
23
+ end
24
+
25
+ def test_empty_range_is_full
26
+ # `bytes=-` matches the regex but yields no bounds; treat as absent.
27
+ result = Wsv::RangeRequest.parse("bytes=-", 100)
28
+
29
+ assert_predicate result, :full?
30
+ end
31
+
32
+ def test_bounded_range
33
+ result = Wsv::RangeRequest.parse("bytes=2-5", 100)
34
+
35
+ assert_predicate result, :partial?
36
+ assert_equal 2..5, result.bounds
37
+ end
38
+
39
+ def test_open_range
40
+ result = Wsv::RangeRequest.parse("bytes=5-", 10)
41
+
42
+ assert_predicate result, :partial?
43
+ assert_equal 5..9, result.bounds
44
+ end
45
+
46
+ def test_suffix_range
47
+ result = Wsv::RangeRequest.parse("bytes=-3", 10)
48
+
49
+ assert_predicate result, :partial?
50
+ assert_equal 7..9, result.bounds
51
+ end
52
+
53
+ def test_suffix_larger_than_file_clamps_to_zero
54
+ result = Wsv::RangeRequest.parse("bytes=-99", 10)
55
+
56
+ assert_predicate result, :partial?
57
+ assert_equal 0..9, result.bounds
58
+ end
59
+
60
+ def test_bounded_last_past_file_clamps_to_end
61
+ result = Wsv::RangeRequest.parse("bytes=5-99", 10)
62
+
63
+ assert_predicate result, :partial?
64
+ assert_equal 5..9, result.bounds
65
+ end
66
+
67
+ def test_zero_byte_suffix_is_unsatisfiable
68
+ result = Wsv::RangeRequest.parse("bytes=-0", 10)
69
+
70
+ assert_predicate result, :unsatisfiable?
71
+ end
72
+
73
+ def test_suffix_against_empty_file_is_unsatisfiable
74
+ result = Wsv::RangeRequest.parse("bytes=-3", 0)
75
+
76
+ assert_predicate result, :unsatisfiable?
77
+ end
78
+
79
+ def test_open_range_past_file_is_unsatisfiable
80
+ result = Wsv::RangeRequest.parse("bytes=10-", 5)
81
+
82
+ assert_predicate result, :unsatisfiable?
83
+ end
84
+
85
+ def test_bounded_first_past_file_is_unsatisfiable
86
+ result = Wsv::RangeRequest.parse("bytes=10-20", 5)
87
+
88
+ assert_predicate result, :unsatisfiable?
89
+ end
90
+
91
+ def test_inverted_bounded_range_is_unsatisfiable
92
+ result = Wsv::RangeRequest.parse("bytes=5-3", 100)
93
+
94
+ assert_predicate result, :unsatisfiable?
95
+ end
96
+
97
+ def test_bounded_range_at_exact_file_boundary
98
+ result = Wsv::RangeRequest.parse("bytes=0-9", 10)
99
+
100
+ assert_predicate result, :partial?
101
+ assert_equal(0..9, result.bounds)
102
+ end
103
+
104
+ def test_suffix_range_equal_to_file_size
105
+ result = Wsv::RangeRequest.parse("bytes=-10", 10)
106
+
107
+ assert_predicate result, :partial?
108
+ assert_equal(0..9, result.bounds)
109
+ end
110
+
111
+ def test_single_byte_range_at_last_position
112
+ result = Wsv::RangeRequest.parse("bytes=9-9", 10)
113
+
114
+ assert_predicate result, :partial?
115
+ assert_equal(9..9, result.bounds)
116
+ end
117
+
118
+ def test_multipart_range_is_full
119
+ # `bytes=0-2,5-7` doesn't match the single-range regex; treat as absent.
120
+ result = Wsv::RangeRequest.parse("bytes=0-2,5-7", 100)
121
+
122
+ assert_predicate result, :full?
123
+ end
124
+ end
data/test/server_test.rb CHANGED
@@ -5,6 +5,8 @@ require "socket"
5
5
  require_relative "test_helper"
6
6
 
7
7
  class ServerTest < Minitest::Test
8
+ include TlsTestHelpers
9
+
8
10
  def setup
9
11
  @dir = Dir.mktmpdir
10
12
  @server = nil
@@ -164,55 +166,74 @@ class ServerTest < Minitest::Test
164
166
  socket&.close
165
167
  end
166
168
 
167
- def test_slow_client_does_not_block_other_clients
168
- File.write(File.join(@dir, "x.txt"), "ok")
169
- start_server(read_timeout: 5)
170
-
171
- slow_socket = TCPSocket.open("127.0.0.1", @server.port)
169
+ def test_408_carries_cors_header_when_cors_enabled
170
+ start_server(read_timeout: 0.1, cors: true)
172
171
 
173
- started = Time.now
174
- response = get("/x.txt")
175
- elapsed = Time.now - started
172
+ socket = TCPSocket.open("127.0.0.1", @server.port)
173
+ response = socket.read
176
174
 
177
- assert_equal "200", response.code
178
- assert_equal "ok", response.body
179
- assert_operator elapsed, :<, 1.0, "request should not be serialized behind slow client"
175
+ assert_includes response, "HTTP/1.1 408"
176
+ assert_includes response, "Access-Control-Allow-Origin: *"
180
177
  ensure
181
- slow_socket&.close
178
+ socket&.close
182
179
  end
183
180
 
184
- def test_warns_when_binding_to_non_loopback
185
- err = StringIO.new
186
- server = Wsv::Server.new(host: "0.0.0.0", port: 0, root: @dir, out: StringIO.new, err: err)
187
- server.send(:log_startup)
181
+ def test_414_carries_cors_header_when_cors_enabled
182
+ start_server(cors: true)
188
183
 
189
- assert_includes err.string, "WARNING"
190
- assert_includes err.string, "0.0.0.0"
184
+ long_path = "/" + ("a" * 9000)
185
+ socket = TCPSocket.open("127.0.0.1", @server.port)
186
+ socket.write("GET #{long_path} HTTP/1.1\r\nHost: localhost\r\n\r\n")
187
+ response = socket.read
188
+
189
+ assert_includes response, "HTTP/1.1 414"
190
+ assert_includes response, "Access-Control-Allow-Origin: *"
191
+ ensure
192
+ socket&.close
191
193
  end
192
194
 
193
- def test_no_warning_for_loopback_bind
194
- err = StringIO.new
195
- server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir, out: StringIO.new, err: err)
196
- server.send(:log_startup)
195
+ def test_200_carries_cors_header_when_cors_enabled
196
+ File.write(File.join(@dir, "x.txt"), "hi")
197
+ start_server(cors: true)
198
+
199
+ response = get("/x.txt")
197
200
 
198
- refute_includes err.string, "WARNING"
201
+ assert_equal "200", response.code
202
+ assert_equal "*", response["access-control-allow-origin"]
203
+ assert_equal "Origin", response["vary"]
199
204
  end
200
205
 
201
- def test_banner_brackets_ipv6_address_in_url
202
- out = StringIO.new
203
- server = Wsv::Server.new(host: "::1", port: 8000, root: @dir, out: out, err: StringIO.new)
204
- server.send(:log_startup)
206
+ def test_options_preflight_carries_cors_headers_when_cors_enabled
207
+ start_server(cors: true)
205
208
 
206
- assert_includes out.string, "http://[::1]:8000/"
207
- refute_includes out.string, "http://::1:8000/"
209
+ socket = TCPSocket.open("127.0.0.1", @server.port)
210
+ socket.write("OPTIONS /x.txt HTTP/1.1\r\nHost: localhost\r\n" \
211
+ "Access-Control-Request-Method: GET\r\n\r\n")
212
+ response = socket.read
213
+
214
+ assert_includes response, "HTTP/1.1 204"
215
+ assert_includes response, "Access-Control-Allow-Origin: *"
216
+ assert_includes response, "Access-Control-Allow-Methods: GET, HEAD, OPTIONS"
217
+ assert_includes response, "Vary: Origin"
218
+ ensure
219
+ socket&.close
208
220
  end
209
221
 
210
- def test_banner_percent_encodes_ipv6_zone_identifier
211
- out = StringIO.new
212
- server = Wsv::Server.new(host: "fe80::1%eth0", port: 8000, root: @dir, out: out, err: StringIO.new)
213
- server.send(:log_startup)
222
+ def test_slow_client_does_not_block_other_clients
223
+ File.write(File.join(@dir, "x.txt"), "ok")
224
+ start_server(read_timeout: 5)
225
+
226
+ slow_socket = TCPSocket.open("127.0.0.1", @server.port)
227
+
228
+ started = Time.now
229
+ response = get("/x.txt")
230
+ elapsed = Time.now - started
214
231
 
215
- assert_includes out.string, "http://[fe80::1%25eth0]:8000/"
232
+ assert_equal "200", response.code
233
+ assert_equal "ok", response.body
234
+ assert_operator elapsed, :<, 1.0, "request should not be serialized behind slow client"
235
+ ensure
236
+ slow_socket&.close
216
237
  end
217
238
 
218
239
  def test_accept_loop_survives_transient_accept_error
@@ -272,7 +293,7 @@ class ServerTest < Minitest::Test
272
293
 
273
294
  def test_serves_over_tls
274
295
  File.write(File.join(@dir, "x.txt"), "secret")
275
- start_server(tls: build_ephemeral_tls)
296
+ start_server(tls: ephemeral_tls)
276
297
 
277
298
  response = Net::HTTP.start("127.0.0.1", @server.port, use_ssl: true,
278
299
  verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
@@ -283,36 +304,44 @@ class ServerTest < Minitest::Test
283
304
  assert_equal "secret", response.body
284
305
  end
285
306
 
286
- def test_logs_https_scheme_when_tls_enabled
287
- out = StringIO.new
288
- @server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir,
289
- out: out, err: StringIO.new, tls: build_ephemeral_tls)
290
- @server.send(:log_startup)
307
+ def test_unsupported_method
308
+ start_server
309
+
310
+ response = raw_request("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n")
291
311
 
292
- assert_includes out.string, "https://"
312
+ assert_includes response, "HTTP/1.1 405 Method Not Allowed"
313
+ assert_includes response, "Allow: GET, HEAD"
293
314
  end
294
315
 
295
- def test_warns_about_self_signed_cert
296
- err = StringIO.new
297
- @server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir,
298
- out: StringIO.new, err: err, tls: build_ephemeral_tls)
299
- @server.send(:log_startup)
316
+ def test_emits_access_log_by_default
317
+ File.write(File.join(@dir, "ok.txt"), "hello")
318
+ out = StringIO.new
319
+ @server = Wsv::Server.new(host: "127.0.0.1", port: free_port, root: @dir, out: out, err: StringIO.new)
320
+ @thread = Thread.new { @server.start }
321
+ wait_until_ready
300
322
 
301
- assert_includes err.string, "self-signed"
323
+ get("/ok.txt")
324
+
325
+ assert_includes out.string, %("GET /ok.txt HTTP/1.1" 200 5)
302
326
  end
303
327
 
304
- def test_unsupported_method
305
- start_server
328
+ def test_quiet_suppresses_access_log
329
+ File.write(File.join(@dir, "ok.txt"), "hello")
330
+ out = StringIO.new
331
+ @server = Wsv::Server.new(host: "127.0.0.1", port: free_port, root: @dir,
332
+ out: out, err: StringIO.new, quiet: true)
333
+ @thread = Thread.new { @server.start }
334
+ wait_until_ready
335
+ baseline = out.string.dup
306
336
 
307
- response = raw_request("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n")
337
+ get("/ok.txt")
308
338
 
309
- assert_includes response, "HTTP/1.1 405 Method Not Allowed"
310
- assert_includes response, "Allow: GET, HEAD"
339
+ assert_equal baseline, out.string
311
340
  end
312
341
 
313
342
  private
314
343
 
315
- def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil)
344
+ def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil, cors: false)
316
345
  @server = Wsv::Server.new(
317
346
  host: "127.0.0.1",
318
347
  port: free_port,
@@ -320,39 +349,32 @@ class ServerTest < Minitest::Test
320
349
  out: StringIO.new,
321
350
  err: StringIO.new,
322
351
  read_timeout: read_timeout,
323
- tls: tls
352
+ tls: tls,
353
+ cors: cors
324
354
  )
325
355
  @thread = Thread.new { @server.start }
326
356
  wait_until_ready
327
357
  end
328
358
 
359
+ # Wrap accept_loop so the very first call to @server.accept raises a
360
+ # transient error. Avoids redefining Server#start, which would silently
361
+ # drift if start grew new steps (open_in_browser, etc.).
329
362
  def inject_one_accept_error(server, error_class)
363
+ original_accept_loop = server.method(:accept_loop)
330
364
  fired = false
331
- server.define_singleton_method(:start) do
332
- @server = TCPServer.new(host, port)
333
- original = @server.method(:accept)
365
+ server.define_singleton_method(:accept_loop) do
366
+ original_accept = @server.method(:accept)
334
367
  @server.define_singleton_method(:accept) do
335
368
  unless fired
336
369
  fired = true
337
370
  raise error_class, "injected"
338
371
  end
339
- original.call
372
+ original_accept.call
340
373
  end
341
- @running = true
342
- log_startup
343
- trap_signals
344
- accept_loop
345
- ensure
346
- close
374
+ original_accept_loop.call
347
375
  end
348
376
  end
349
377
 
350
- def build_ephemeral_tls
351
- key = OpenSSL::PKey::RSA.new(2048)
352
- cert = Wsv::TlsContext::SelfSignedCert.build(key)
353
- Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true)
354
- end
355
-
356
378
  def free_port
357
379
  server = TCPServer.new("127.0.0.1", 0)
358
380
  server.addr[1]
data/test/sse_test.rb ADDED
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class SseBodyTest < Minitest::Test
6
+ def test_requires_a_block
7
+ assert_raises(ArgumentError) { Wsv::Response::SseBody.new }
8
+ end
9
+
10
+ def test_to_s_raises_not_implemented
11
+ body = Wsv::Response::SseBody.new { |io| io }
12
+ assert_raises(NotImplementedError) { body.to_s }
13
+ end
14
+
15
+ def test_bytesize_is_zero
16
+ body = Wsv::Response::SseBody.new { |io| io }
17
+
18
+ assert_equal 0, body.bytesize
19
+ end
20
+
21
+ def test_write_to_invokes_producer_with_io
22
+ received_io = nil
23
+ body = Wsv::Response::SseBody.new { |io| received_io = io }
24
+ buffer = StringIO.new
25
+ body.write_to(buffer)
26
+
27
+ assert_equal buffer, received_io
28
+ end
29
+
30
+ def test_producer_can_write_multiple_chunks
31
+ body = Wsv::Response::SseBody.new do |io|
32
+ io.write("data: hello\n\n")
33
+ io.write("data: world\n\n")
34
+ end
35
+ buffer = StringIO.new
36
+ body.write_to(buffer)
37
+
38
+ assert_equal "data: hello\n\ndata: world\n\n", buffer.string
39
+ end
40
+ end
41
+
42
+ class ResponseSseTest < Minitest::Test
43
+ def test_sse_helper_returns_response_with_sse_defaults
44
+ response = Wsv::Response.sse { |io| io }
45
+
46
+ assert_equal 200, response.status
47
+ assert_equal "text/event-stream; charset=utf-8", response.headers["Content-Type"]
48
+ assert_equal "no-cache", response.headers["Cache-Control"]
49
+ assert_equal "no", response.headers["X-Accel-Buffering"]
50
+ end
51
+
52
+ def test_sse_helper_allows_custom_status
53
+ response = Wsv::Response.sse(status: 503) { |io| io }
54
+
55
+ assert_equal 503, response.status
56
+ end
57
+
58
+ def test_sse_helper_merges_extra_headers
59
+ response = Wsv::Response.sse(headers: { "X-Custom" => "v" }) { |io| io }
60
+
61
+ assert_equal "v", response.headers["X-Custom"]
62
+ assert_equal "no-cache", response.headers["Cache-Control"]
63
+ end
64
+
65
+ def test_sse_helper_overrides_default_headers_when_supplied
66
+ response = Wsv::Response.sse(headers: { "Content-Type" => "application/x-ndjson" }) { |io| io }
67
+
68
+ assert_equal "application/x-ndjson", response.headers["Content-Type"]
69
+ end
70
+
71
+ def test_response_write_to_does_not_inject_content_length_for_sse
72
+ response = Wsv::Response.sse do |io|
73
+ io.write("data: hi\n\n")
74
+ end
75
+ buffer = StringIO.new
76
+ response.write_to(buffer)
77
+
78
+ refute_match(/^Content-Length:/i, buffer.string)
79
+ assert_match(%r{^Content-Type: text/event-stream}i, buffer.string)
80
+ assert_match(/data: hi\n\n\z/, buffer.string)
81
+ end
82
+
83
+ def test_bytesize_of_sse_response_is_zero
84
+ response = Wsv::Response.sse { |io| io }
85
+
86
+ assert_equal 0, response.bytesize
87
+ end
88
+ end
data/test/test_helper.rb CHANGED
@@ -7,3 +7,11 @@ require "fileutils"
7
7
  require "stringio"
8
8
  require "tmpdir"
9
9
  require "wsv"
10
+
11
+ module TlsTestHelpers
12
+ def ephemeral_tls
13
+ key = OpenSSL::PKey::RSA.new(2048)
14
+ cert = Wsv::TlsContext::SelfSignedCert.build(key)
15
+ Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true)
16
+ end
17
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wsv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-07 00:00:00.000000000 Z
10
+ date: 2026-05-16 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: 'wsv is a Ruby CLI that previews a directory over HTTP/HTTPS. Stdlib-only,
13
13
  no runtime dependencies. Defensive by design: blocks dotfiles, binds to loopback,
@@ -29,15 +29,19 @@ files:
29
29
  - lib/wsv/cors.rb
30
30
  - lib/wsv/mime_types.rb
31
31
  - lib/wsv/path_resolver.rb
32
+ - lib/wsv/range_request.rb
32
33
  - lib/wsv/request.rb
33
34
  - lib/wsv/request/parser.rb
34
35
  - lib/wsv/request/too_large.rb
35
36
  - lib/wsv/response.rb
36
37
  - lib/wsv/response/file_body.rb
37
38
  - lib/wsv/response/file_builder.rb
39
+ - lib/wsv/response/sse_body.rb
40
+ - lib/wsv/response/sse_builder.rb
38
41
  - lib/wsv/response/string_body.rb
39
42
  - lib/wsv/response/text_builder.rb
40
43
  - lib/wsv/server.rb
44
+ - lib/wsv/server/access_log.rb
41
45
  - lib/wsv/server/banner.rb
42
46
  - lib/wsv/server/browser_launcher.rb
43
47
  - lib/wsv/server/connection.rb
@@ -49,13 +53,19 @@ files:
49
53
  - lib/wsv/tls_context/resolver.rb
50
54
  - lib/wsv/tls_context/self_signed_cert.rb
51
55
  - lib/wsv/version.rb
56
+ - test/access_log_test.rb
52
57
  - test/app_test.rb
58
+ - test/banner_test.rb
53
59
  - test/browser_launcher_test.rb
54
60
  - test/cli_test.rb
61
+ - test/cors_test.rb
62
+ - test/custom_app_test.rb
55
63
  - test/path_resolver_test.rb
64
+ - test/range_request_test.rb
56
65
  - test/request_test.rb
57
66
  - test/response_test.rb
58
67
  - test/server_test.rb
68
+ - test/sse_test.rb
59
69
  - test/test_helper.rb
60
70
  - test/tls_context_test.rb
61
71
  - wsv.gemspec