diff --git a/CHANGELOG.md b/CHANGELOG.md index 1325032..a8edc40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Load `SolidObjects::ActorChannel` with the gem and pass stream and component + subscription tokens through Turbo-compatible `data-*` attributes. + ## 0.4.0 - 2026-08-06 - Add dependency-driven live ERB components with request-time authorization, diff --git a/app/helpers/solid_objects/actor_helper.rb b/app/helpers/solid_objects/actor_helper.rb index 93598f2..4d7c2db 100644 --- a/app/helpers/solid_objects/actor_helper.rb +++ b/app/helpers/solid_objects/actor_helper.rb @@ -10,19 +10,19 @@ def solid_object(reference, authorization_context: self, &block) authorization_context: ) content = capture(actor, &block) - subscription_attributes = { - channel: "SolidObjects::ActorChannel", + subscription_data = { token: StreamToken.generate( reference, observables: actor.scalar_observable_names ) } if actor.component_tokens.any? - subscription_attributes[:data] = { - components: JSON.generate(actor.component_tokens) - } + subscription_data[:components] = JSON.generate(actor.component_tokens) end - subscription = tag.turbo_cable_stream_source(**subscription_attributes) + subscription = tag.turbo_cable_stream_source( + channel: "SolidObjects::ActorChannel", + data: subscription_data + ) content_tag( :div, diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index d00fd9d..db26a27 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -42,6 +42,7 @@ require "solid_objects/component_renderer" require "solid_objects/state_snapshot" require "solid_objects/actor_view" +require "solid_objects/actor_channel" require "solid_objects/action_cable_broadcast_adapter" require "solid_objects/wake_up" require "solid_objects/effect_registry" diff --git a/lib/solid_objects/actor_channel.rb b/lib/solid_objects/actor_channel.rb index 520a94d..05c0e78 100644 --- a/lib/solid_objects/actor_channel.rb +++ b/lib/solid_objects/actor_channel.rb @@ -1,6 +1,6 @@ # rbs_inline: enabled -require "action_cable/channel/base" +require "action_cable" module SolidObjects class ActorChannel < ActionCable::Channel::Base diff --git a/test/dummy/boot_check.rb b/test/dummy/boot_check.rb index 62ca28f..2b43c40 100644 --- a/test/dummy/boot_check.rb +++ b/test/dummy/boot_check.rb @@ -9,5 +9,6 @@ abort "engine is not isolated" unless SolidObjects::Engine.isolated? abort "record is not loaded" unless SolidObjects::Record < ActiveRecord::Base abort "actor helper is not installed" unless ActionView::Base < SolidObjects::ActorHelper +abort "actor channel is not loaded" unless "SolidObjects::ActorChannel".safe_constantize puts "solid_objects_dummy_booted" diff --git a/test/integration/actor_channel_test.rb b/test/integration/actor_channel_test.rb index ae159b6..6edf744 100644 --- a/test/integration/actor_channel_test.rb +++ b/test/integration/actor_channel_test.rb @@ -3,7 +3,10 @@ require "database_test_helper" require "action_cable/test_helper" require "action_cable/channel/test_case" -require "solid_objects/actor_channel" +require "action_view/test_case" +require "action_view/testing/resolvers" +require "cgi/escape" +require_relative "../../app/helpers/solid_objects/actor_helper" ActionCable.server.config.cable = { "adapter" => "test" } @@ -38,9 +41,80 @@ def update_missing SolidObjects.configuration.stream_signing_secret = "test-stream-signing-secret" SolidObjects.configuration.authorize_message = ->(**) { true } SolidObjects.configuration.authorize_query = ->(**) { true } + SolidObjects.configuration.component_path_resolver = lambda do |view_context:| + "/solid_objects/components" + end ActionCable.server.config.logger = Logger.new(nil) end + test "loads the actor channel with the gem" do + assert_equal SolidObjects::ActorChannel, + "SolidObjects::ActorChannel".safe_constantize + end + + test "subscribes to scalar updates through rendered Turbo data" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + parameters = rendered_subscription_parameters(reference) do |actor| + actor.missing + end + + assert_equal "SolidObjects::ActorChannel", parameters.fetch(:channel) + assert parameters.key?(:token) + refute parameters.key?(:components) + + subscribe(**parameters) + + assert subscription.confirmed? + assert_has_stream SolidObjects::StreamName.for(reference) + + reference.async(:update_missing) + worker = SolidObjects::Worker.new + worker.run_until_idle + broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing") + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + + target = SolidObjects::DomIdentity.observable(reference, :missing) + updates = transmissions.select { |transmission| transmission.include?(target) } + assert_equal 2, updates.length + assert_includes updates.last, ">1" + ensure + worker&.stop + end + + test "subscribes to component updates through rendered Turbo data" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { true } + parameters = rendered_subscription_parameters(reference) do |actor| + actor.component(:summary, observes: :missing) + end + + assert_equal "SolidObjects::ActorChannel", parameters.fetch(:channel) + assert parameters.key?(:token) + assert parameters.key?(:components) + + subscribe(**parameters) + + assert subscription.confirmed? + assert_has_stream SolidObjects::StreamName.for(reference) + + reference.async(:update_missing) + worker = SolidObjects::Worker.new + worker.run_until_idle + broadcast = SolidObjects::Broadcast.find_by!(observable_name: "missing") + subscription.__send__( + :receive_broadcast, + SolidObjects::TurboStreamRenderer.observable(broadcast) + ) + + assert_equal 1, component_refreshes(reference, :summary).length + ensure + worker&.stop + end + test "streams only after token verification and host authorization" do reference = ChannelActor.ref("actor-1") SolidObjects.configuration.authorize_subscription = lambda do |actor_type:, actor_id:, authorization_context:| @@ -256,6 +330,36 @@ def update_missing private + def rendered_subscription_parameters(reference, &block) + html = actor_view.solid_object(reference, &block) + source = html.match(/[^>]*)>/) + attributes = source[:attributes] + .scan(/([a-z-]+)="([^"]*)"/) + .to_h + data = attributes + .select { |name, _value| name.start_with?("data-") } + .to_h do |name, value| + [ name.delete_prefix("data-").tr("-", "_").to_sym, CGI.unescapeHTML(value) ] + end + + { + channel: attributes.fetch("channel"), + **data + } + end + + def actor_view + resolver = ActionView::FixtureResolver.new( + "actors/actor_channel_test/channel_actor/_summary.html.erb" => + "

<%= actor.missing %>

" + ) + view = ActionView::Base + .with_empty_template_cache + .with_view_paths([ resolver ]) + view.extend(SolidObjects::ActorHelper) + view + end + def component_token(reference, component_name:, dependencies:, revision:) SolidObjects::ComponentToken.generate( reference:, diff --git a/test/integration/actor_helper_test.rb b/test/integration/actor_helper_test.rb index 6e6f284..3d26491 100644 --- a/test/integration/actor_helper_test.rb +++ b/test/integration/actor_helper_test.rb @@ -74,8 +74,10 @@ def close assert_includes html, %(channel="SolidObjects::ActorChannel") assert_includes html, %(id="#{SolidObjects::DomIdentity.scope(reference)}") refute_includes html, reference.actor_id + assert_includes html, "data-token=" + assert_nil html[/]*\stoken="/] - token = html[/token="([^"]+)"/, 1] + token = html[/data-token="([^"]+)"/, 1] identity = SolidObjects::StreamToken.verify(token) assert_equal [ "items_count" ], identity.fetch("observables") end @@ -110,8 +112,10 @@ def close assert_includes html, "
  • Second
  • " refute_includes html, JSON.generate(reference.snapshot.items) assert_includes html, "data-components=" + assert_includes html, "data-token=" + assert_nil html[/]*\stoken="/] - token = html[/token="([^"]+)"/, 1] + token = html[/data-token="([^"]+)"/, 1] identity = SolidObjects::StreamToken.verify(token) assert_empty identity.fetch("observables") end