tep 0.11.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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
data/test/test_jwt.rb ADDED
@@ -0,0 +1,143 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Jwt -- HS256 encode + decode + verify, plus the b64url
4
+ # helpers in sphttp.c that back it.
5
+ class TestJwt < TepTest
6
+ app_source <<~RB
7
+ require 'sinatra'
8
+
9
+ SECRET = "supersecret"
10
+
11
+ post '/issue' do
12
+ res.headers["Content-Type"] = "text/plain"
13
+ user = Tep::Json.get_str(req.raw_body, "user")
14
+ payload = "{" + Tep::Json.encode_pair_str("sub", user) + "}"
15
+ Tep::Jwt.encode_hs256(payload, SECRET)
16
+ end
17
+
18
+ post '/verify' do
19
+ res.headers["Content-Type"] = "text/plain"
20
+ Tep::Jwt.verify_hs256(req.raw_body, SECRET) ? "ok" : "bad"
21
+ end
22
+
23
+ post '/decode' do
24
+ res.headers["Content-Type"] = "text/plain"
25
+ Tep::Jwt.decode_payload(req.raw_body)
26
+ end
27
+
28
+ post '/verify_and_decode' do
29
+ res.headers["Content-Type"] = "text/plain"
30
+ Tep::Jwt.verify_and_decode(req.raw_body, SECRET)
31
+ end
32
+
33
+ post '/b64u_encode' do
34
+ res.headers["Content-Type"] = "text/plain"
35
+ Crypto.sp_crypto_b64url_encode(req.raw_body)
36
+ end
37
+
38
+ post '/b64u_decode' do
39
+ res.headers["Content-Type"] = "text/plain"
40
+ Crypto.sp_crypto_b64url_decode(req.raw_body)
41
+ end
42
+
43
+ post '/timing_eq' do
44
+ res.headers["Content-Type"] = "text/plain"
45
+ a = Tep::Json.get_str(req.raw_body, "a")
46
+ b = Tep::Json.get_str(req.raw_body, "b")
47
+ Tep::Jwt.timing_safe_eq(a, b) ? "yes" : "no"
48
+ end
49
+ RB
50
+
51
+ def issue(user)
52
+ post("/issue", %({"user":"#{user}"})).body.strip
53
+ end
54
+
55
+ def test_encode_decode_round_trip
56
+ token = issue("alice")
57
+ parts = token.split(".")
58
+ assert_equal 3, parts.length
59
+ # Decode the payload via the route -- exercises sphttp_b64url_decode
60
+ # in the same path the verify uses.
61
+ res = post("/decode", token)
62
+ assert_match(/"sub":"alice"/, res.body)
63
+ end
64
+
65
+ def test_verify_signature_match
66
+ token = issue("alice")
67
+ res = post("/verify", token)
68
+ assert_equal "ok", res.body.strip
69
+ end
70
+
71
+ def test_verify_rejects_tampered_signature
72
+ token = issue("alice")
73
+ # Flip a byte in the signature segment.
74
+ bad = token + "x"
75
+ res = post("/verify", bad)
76
+ assert_equal "bad", res.body.strip
77
+ end
78
+
79
+ def test_verify_rejects_tampered_payload
80
+ token = issue("alice")
81
+ # Replace "alice" in the encoded payload with a different
82
+ # subject and re-stitch -- signature should no longer match.
83
+ parts = token.split(".")
84
+ new_payload = '{"sub":"mallory"}'
85
+ require "base64"
86
+ new_b64 = Base64.urlsafe_encode64(new_payload, padding: false)
87
+ forged = parts[0] + "." + new_b64 + "." + parts[2]
88
+ res = post("/verify", forged)
89
+ assert_equal "bad", res.body.strip
90
+ end
91
+
92
+ def test_verify_and_decode_one_shot
93
+ token = issue("alice")
94
+ res = post("/verify_and_decode", token)
95
+ assert_match(/"sub":"alice"/, res.body)
96
+ end
97
+
98
+ def test_verify_and_decode_returns_empty_on_bad_sig
99
+ token = issue("alice")
100
+ res = post("/verify_and_decode", token + "x")
101
+ assert_equal "", res.body.strip
102
+ end
103
+
104
+ def test_b64url_encode_round_trip
105
+ plain = "hello, world!"
106
+ enc = post("/b64u_encode", plain).body.strip
107
+ # JWT-style: no padding.
108
+ refute_match(/=/, enc)
109
+ dec = post("/b64u_decode", enc).body.strip
110
+ assert_equal plain, dec
111
+ end
112
+
113
+ def test_b64url_round_trip_with_special_chars
114
+ plain = '{"a":"x?y","z":1}'
115
+ enc = post("/b64u_encode", plain).body.strip
116
+ dec = post("/b64u_decode", enc).body.strip
117
+ assert_equal plain, dec
118
+ end
119
+
120
+ def test_timing_safe_eq
121
+ res = post("/timing_eq", '{"a":"hello","b":"hello"}')
122
+ assert_equal "yes", res.body.strip
123
+ res = post("/timing_eq", '{"a":"hello","b":"world"}')
124
+ assert_equal "no", res.body.strip
125
+ res = post("/timing_eq", '{"a":"hello","b":"hi"}')
126
+ assert_equal "no", res.body.strip
127
+ end
128
+
129
+ # Interop: the canonical CRuby `jwt` gem must be able to verify
130
+ # tokens we issue. Skipped if the gem isn't installed locally.
131
+ def test_interop_with_jwt_gem
132
+ begin
133
+ require "jwt"
134
+ rescue LoadError
135
+ skip "jwt gem not installed locally"
136
+ end
137
+ token = issue("alice")
138
+ payload, header = JWT.decode(token, "supersecret", true, { algorithm: "HS256" })
139
+ assert_equal "alice", payload["sub"]
140
+ assert_equal "HS256", header["alg"]
141
+ assert_equal "JWT", header["typ"]
142
+ end
143
+ end
@@ -0,0 +1,324 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::LiveView base class + helpers (Battery 4 chunk 4.1).
4
+ # v1 ships the manual-wiring path: a base class apps subclass +
5
+ # the render_page / dispatch_event cmeths. Auto-wiring lands in
6
+ # 4.2; these tests cover the building blocks.
7
+ class TestLiveView < TepTest
8
+ app_source <<~RB
9
+ require 'sinatra'
10
+
11
+ # A counter view: state is an integer; "inc" increments,
12
+ # "dec" decrements, "reset" zeroes.
13
+ class CounterView < Tep::LiveView
14
+ attr_accessor :count
15
+ def initialize
16
+ super
17
+ @count = 0
18
+ end
19
+ def mount(req)
20
+ # Pull a seed value from the request's params if present;
21
+ # otherwise leave at 0.
22
+ seed = req.params["seed"]
23
+ if seed.length > 0
24
+ @count = seed.to_i
25
+ end
26
+ 0
27
+ end
28
+ def render
29
+ "<div id='tep-live-root'>Count: " + @count.to_s + "</div>"
30
+ end
31
+ def handle_event(event, payload, req)
32
+ if event == "inc"
33
+ @count += 1
34
+ elsif event == "dec"
35
+ @count -= 1
36
+ elsif event == "reset"
37
+ @count = 0
38
+ end
39
+ 0
40
+ end
41
+ end
42
+
43
+ # Per-request scratchpad: the test app boots once, but we
44
+ # need a fresh view per request so tests don't pollute each
45
+ # other. The handler routes below construct a view, run the
46
+ # operation, return the result; no cross-request state.
47
+
48
+ before do
49
+ res.headers["Content-Type"] = "text/plain"
50
+ end
51
+
52
+ get '/initial_render' do
53
+ v = CounterView.new
54
+ v.mount(req)
55
+ v.render
56
+ end
57
+
58
+ get '/render_page' do
59
+ Tep::LiveView.render_page("<p>hi</p>", "/_live")
60
+ end
61
+
62
+ get '/event_inc' do
63
+ v = CounterView.new
64
+ v.mount(req)
65
+ v.dispatch_event_json("{\\"event\\":\\"inc\\",\\"payload\\":\\"\\"}", req)
66
+ v.render
67
+ end
68
+
69
+ get '/event_chain' do
70
+ # Multiple events through the same view to verify state
71
+ # carries forward.
72
+ v = CounterView.new
73
+ v.mount(req)
74
+ v.dispatch_event_json("{\\"event\\":\\"inc\\",\\"payload\\":\\"\\"}", req)
75
+ v.dispatch_event_json("{\\"event\\":\\"inc\\",\\"payload\\":\\"\\"}", req)
76
+ v.dispatch_event_json("{\\"event\\":\\"inc\\",\\"payload\\":\\"\\"}", req)
77
+ v.dispatch_event_json("{\\"event\\":\\"dec\\",\\"payload\\":\\"\\"}", req)
78
+ v.render
79
+ end
80
+
81
+ get '/event_unknown' do
82
+ v = CounterView.new
83
+ v.mount(req)
84
+ v.dispatch_event_json("{\\"event\\":\\"never\\",\\"payload\\":\\"\\"}", req)
85
+ v.render
86
+ end
87
+
88
+ get '/base_class_render' do
89
+ # The Tep::LiveView base class's default render is a noop
90
+ # shell -- subclasses are expected to override.
91
+ Tep::LiveView.new.render
92
+ end
93
+
94
+ # ---- chunk 4.2: broadcast binding ----
95
+
96
+ # A view bound to a topic. Setting the topic via a class
97
+ # constant rather than a per-instance ivar so the test
98
+ # endpoint doesn't need to thread state across calls.
99
+ class RoomView < Tep::LiveView
100
+ def topic
101
+ "room:lobby"
102
+ end
103
+ def render
104
+ "<div id='tep-live-root'>room:lobby</div>"
105
+ end
106
+ end
107
+
108
+ # Default base class topic.
109
+ get '/base_topic' do
110
+ Tep::LiveView.new.topic
111
+ end
112
+
113
+ # Subclass with overridden topic.
114
+ get '/room_topic' do
115
+ RoomView.new.topic
116
+ end
117
+
118
+ # broadcast_render on a topic-less view is a no-op (returns 0).
119
+ get '/broadcast_noop_topicless' do
120
+ Tep::LiveView.new.broadcast_render.to_s
121
+ end
122
+
123
+ # ---- Tep.live auto-wiring ----
124
+ #
125
+ # `Tep.live "/auto", CounterView` is lowered by the translator
126
+ # into a GET handler at /auto (initial render + bootstrap JS)
127
+ # and a WS handler at /auto/ws (event dispatch + re-render).
128
+ # The blocking server returns 501 for the WS upgrade, so the
129
+ # test exercises the GET side only.
130
+ Tep.live "/auto", CounterView
131
+
132
+ # ---- chunk 4.3: presence diff binding ----
133
+
134
+ # A view that records every presence diff it receives -- the
135
+ # subclass override of handle_presence_diff pulls a field out
136
+ # and appends to a class-level Array so the test endpoint can
137
+ # report it back.
138
+ class PresenceTrackingView < Tep::LiveView
139
+ def initialize
140
+ super
141
+ @last_principal = ""
142
+ @last_kind = ""
143
+ @last_state = ""
144
+ end
145
+ attr_reader :last_principal, :last_kind, :last_state
146
+ def topic
147
+ "room:lobby"
148
+ end
149
+ def render
150
+ "<div id='tep-live-root'>" + @last_principal + ":" + @last_state + "</div>"
151
+ end
152
+ def handle_presence_diff(diff_json)
153
+ @last_principal = Tep::Json.get_str(diff_json, "principal")
154
+ @last_kind = Tep::Json.get_str(diff_json, "kind")
155
+ @last_state = Tep::Json.get_str(diff_json, "state")
156
+ 0
157
+ end
158
+ end
159
+
160
+ get '/presence_diff_default_noop' do
161
+ Tep::LiveView.new.handle_presence_diff("{\\"kind\\":\\"join\\"}").to_s
162
+ end
163
+
164
+ get '/presence_diff_apply' do
165
+ v = PresenceTrackingView.new
166
+ # Feed a synthetic diff JSON.
167
+ diff = "{\\"kind\\":\\"status\\",\\"principal\\":\\"user:42\\"," +
168
+ "\\"state\\":\\"busy\\",\\"note\\":\\"\\"}"
169
+ v.apply_presence_diff_json(diff)
170
+ v.last_principal + "|" + v.last_kind + "|" + v.last_state
171
+ end
172
+
173
+ get '/presence_diff_render_after_apply' do
174
+ v = PresenceTrackingView.new
175
+ diff = "{\\"kind\\":\\"join\\",\\"principal\\":\\"user:99\\"," +
176
+ "\\"state\\":\\"available\\",\\"note\\":\\"\\"}"
177
+ v.apply_presence_diff_json(diff)
178
+ v.render
179
+ end
180
+
181
+ # broadcast_render with a topic + an existing subscriber.
182
+ get '/broadcast_render_match_count' do
183
+ # Subscribe a fake fd to room:lobby so broadcast_render has
184
+ # someone to match.
185
+ Tep::Broadcast.clear
186
+ Tep::Broadcast.subscribe("room:lobby", -1)
187
+ v = RoomView.new
188
+ "topic=" + v.topic +
189
+ "|subs=" + Tep::Broadcast.subscribers_for("room:lobby").to_s +
190
+ "|direct_publish=" + Tep::Broadcast.publish("room:lobby", "x").to_s +
191
+ "|broadcast_render=" + v.broadcast_render.to_s
192
+ end
193
+ RB
194
+
195
+ def test_initial_render_default_count
196
+ res = get("/initial_render")
197
+ assert_equal "<div id='tep-live-root'>Count: 0</div>", res.body
198
+ end
199
+
200
+ def test_mount_picks_seed_from_params
201
+ res = get("/initial_render?seed=42")
202
+ assert_equal "<div id='tep-live-root'>Count: 42</div>", res.body
203
+ end
204
+
205
+ def test_render_page_wraps_content_and_includes_bootstrap
206
+ res = get("/render_page").body
207
+ assert_includes res, "<!doctype html>"
208
+ assert_includes res, "<p>hi</p>"
209
+ # The bootstrap script connects to the supplied WS path.
210
+ assert_includes res, "new WebSocket"
211
+ assert_includes res, "/_live"
212
+ # Click->event dispatch wire shape (uses t.dataset.event on the
213
+ # client side).
214
+ assert_includes res, "dataset.event"
215
+ # innerHTML/outerHTML swap on incoming frame.
216
+ assert_includes res, "outerHTML"
217
+ end
218
+
219
+ def test_dispatch_event_inc
220
+ res = get("/event_inc").body
221
+ assert_equal "<div id='tep-live-root'>Count: 1</div>", res
222
+ end
223
+
224
+ def test_dispatch_event_chain_preserves_state_across_events
225
+ # 3 inc + 1 dec = 2
226
+ res = get("/event_chain").body
227
+ assert_equal "<div id='tep-live-root'>Count: 2</div>", res
228
+ end
229
+
230
+ def test_dispatch_event_unknown_event_is_noop
231
+ res = get("/event_unknown").body
232
+ assert_equal "<div id='tep-live-root'>Count: 0</div>", res
233
+ end
234
+
235
+ def test_base_class_render_is_empty_shell
236
+ res = get("/base_class_render").body
237
+ assert_equal "<div id='tep-live-root'></div>", res
238
+ end
239
+
240
+ # ---- chunk 4.2: broadcast binding ----
241
+
242
+ def test_base_class_topic_is_empty
243
+ assert_equal "", get("/base_topic").body
244
+ end
245
+
246
+ def test_subclass_topic_override
247
+ assert_equal "room:lobby", get("/room_topic").body
248
+ end
249
+
250
+ def test_broadcast_render_noop_when_topicless
251
+ # broadcast_render on a view with no topic is a no-op
252
+ # (subscribers can't bind to "" topics).
253
+ assert_equal "0", get("/broadcast_noop_topicless").body
254
+ end
255
+
256
+ def test_broadcast_render_publishes_to_topic
257
+ # Pre-subscribe a fake fd to room:lobby. broadcast_render
258
+ # should publish + match the subscriber.
259
+ res = get("/broadcast_render_match_count").body
260
+ # res shape:
261
+ # topic=room:lobby|subs=1|direct_publish=1|broadcast_render=1
262
+ assert_match(/topic=room:lobby/, res)
263
+ assert_match(/subs=1/, res)
264
+ assert_match(/direct_publish=1/, res)
265
+ assert_match(/broadcast_render=1/, res)
266
+ end
267
+
268
+ # ---- chunk 4.3: presence diff binding ----
269
+
270
+ def test_base_class_handle_presence_diff_is_noop
271
+ # Default returns 0 and doesn't crash on arbitrary JSON.
272
+ assert_equal "0", get("/presence_diff_default_noop").body
273
+ end
274
+
275
+ def test_subclass_handle_presence_diff_receives_diff_fields
276
+ # The subclass override pulls principal/kind/state out of the
277
+ # diff JSON and updates ivars. apply_presence_diff_json is
278
+ # the imeth that bridges JSON to handle_presence_diff.
279
+ assert_equal "user:42|status|busy", get("/presence_diff_apply").body
280
+ end
281
+
282
+ def test_handle_presence_diff_can_drive_render
283
+ # After applying a join diff, render reflects the new state.
284
+ res = get("/presence_diff_render_after_apply").body
285
+ assert_equal "<div id='tep-live-root'>user:99:available</div>", res
286
+ end
287
+
288
+ # ---- Tep.live auto-wiring ----
289
+
290
+ def test_tep_live_get_returns_initial_render_wrapped_in_page
291
+ # GET /auto runs the translator-emitted route: instantiate
292
+ # CounterView, mount(req), render, wrap in render_page targeted
293
+ # at /auto/ws.
294
+ res = get("/auto")
295
+ assert_equal "200", res.code
296
+ body = res.body
297
+ # Initial render comes from CounterView#render with @count = 0.
298
+ assert_includes body, "<div id='tep-live-root'>Count: 0</div>"
299
+ # render_page bootstrap JS targets the auto-generated WS path.
300
+ assert_includes body, "/auto/ws"
301
+ # render_page wraps in a full HTML doc with the bootstrap shell.
302
+ assert_includes body, "<!doctype html>"
303
+ assert_includes body, "var ws=new WebSocket("
304
+ end
305
+
306
+ def test_tep_live_get_honors_view_mount
307
+ # CounterView#mount reads ?seed= from req.params and seeds @count.
308
+ res = get("/auto?seed=42")
309
+ assert_includes res.body, "<div id='tep-live-root'>Count: 42</div>"
310
+ end
311
+
312
+ def test_tep_live_ws_path_returns_501_under_blocking_server
313
+ # The auto-wired WS path requires the scheduled server. The
314
+ # blocking server returns 501 for WS upgrade attempts (same
315
+ # behavior as a hand-written `websocket` block).
316
+ res = req(:get, "/auto/ws", nil, {
317
+ "Upgrade" => "websocket",
318
+ "Connection" => "Upgrade",
319
+ "Sec-WebSocket-Key" => "x3JJHMbDL1EzLkh9GBhXDw==",
320
+ "Sec-WebSocket-Version" => "13",
321
+ })
322
+ assert_equal "501", res.code
323
+ end
324
+ end