Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of taking turns behind an edit lock. - The document's content is a sequence of Yjs updates (document_updates); DocumentChannel stores and relays them immediately, checks access on subscribe and again whenever links or memberships change. - app/frontend/collab/: the store stays position-based; a binding maps it to a Yjs form with stable row/column ids, so a row inserted by one person doesn't misplace what another is typing. Remote changes are applied only after pending local ones went into Yjs. - Per-person undo (Y.UndoManager), presence via awareness: the others' selections as coloured boxes with names, avatars in the toolbar. - Existing JSON documents convert on first open, deterministically, so two people opening one at once end up with the same Yjs state. - Editors compact the stored updates after 200 and keep a JSON snapshot current for export and duplicating. Edit lock and polling are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
require "test_helper"
|
||||
|
||||
class DocumentChannelTest < ActionCable::Channel::TestCase
|
||||
setup do
|
||||
@anna = create_user("anna@example.com")
|
||||
@bob = create_user("bob@example.com")
|
||||
@doc = create_document(@anna)
|
||||
end
|
||||
|
||||
def b64(s) = Base64.strict_encode64(s)
|
||||
def last = transmissions.last
|
||||
|
||||
test "rejects without access" do
|
||||
stub_connection current_user: @bob
|
||||
subscribe id: @doc.id
|
||||
assert subscription.rejected?
|
||||
end
|
||||
|
||||
test "init sends the JSON while there are no updates yet, then the updates" do
|
||||
stub_connection current_user: @anna
|
||||
subscribe id: @doc.id, key: "a"
|
||||
assert subscription.confirmed?
|
||||
assert_equal "owner", last["role"].to_s
|
||||
assert_equal DOC_JSON, last["content"]
|
||||
assert_empty last["updates"]
|
||||
|
||||
@doc.updates.create!(data: "\x01\x02")
|
||||
subscribe id: @doc.id, key: "a"
|
||||
assert_nil last["content"]
|
||||
assert_equal [ b64("\x01\x02") ], last["updates"].map { _1["data"] }
|
||||
end
|
||||
|
||||
test "an editor's update is stored and passed on to everyone" do
|
||||
stub_connection current_user: @anna
|
||||
subscribe id: @doc.id, key: "a"
|
||||
assert_broadcasts(DocumentChannel.broadcasting_for(@doc), 1) do
|
||||
perform :update, data: b64("abc")
|
||||
end
|
||||
assert_equal [ "abc" ], @doc.updates.pluck(:data)
|
||||
end
|
||||
|
||||
test "a viewer (link) can't change anything but may send awareness" do
|
||||
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
|
||||
stub_connection current_user: nil
|
||||
subscribe id: @doc.id, token: link.token, key: "v"
|
||||
assert subscription.confirmed?
|
||||
perform :update, data: b64("x")
|
||||
perform :compact, state: b64("x"), ids: [], snapshot: DOC_JSON
|
||||
assert_equal 0, @doc.updates.count
|
||||
assert_broadcasts(DocumentChannel.broadcasting_for(@doc), 1) do
|
||||
perform :awareness, data: b64("cursor")
|
||||
end
|
||||
end
|
||||
|
||||
test "compact replaces the listed updates by one state and stores the snapshot" do
|
||||
a = @doc.updates.create!(data: "1")
|
||||
b = @doc.updates.create!(data: "2")
|
||||
keep = @doc.updates.create!(data: "3")
|
||||
stub_connection current_user: @anna
|
||||
subscribe id: @doc.id, key: "a"
|
||||
snapshot = { version: 2, sheets: [ { id: "s9", name: "Neu", objects: [] } ] }.to_json
|
||||
perform :compact, state: b64("12"), ids: [ a.id, b.id ], snapshot: snapshot
|
||||
assert_equal %w[3 12], @doc.updates.order(:id).pluck(:data)
|
||||
assert keep.reload
|
||||
assert_equal snapshot, @doc.reload.content
|
||||
end
|
||||
|
||||
test "invalid snapshots are ignored" do
|
||||
stub_connection current_user: @anna
|
||||
subscribe id: @doc.id, key: "a"
|
||||
perform :snapshot, content: "[1]"
|
||||
assert_equal DOC_JSON, @doc.reload.content
|
||||
end
|
||||
|
||||
test "losing access mid-session" do
|
||||
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
|
||||
stub_connection current_user: @bob
|
||||
subscribe id: @doc.id, key: "b"
|
||||
@doc.document_memberships.delete_all
|
||||
subscription.send(:revalidate)
|
||||
assert_equal "revoked", last["type"]
|
||||
perform :update, data: b64("late")
|
||||
assert_equal 0, @doc.updates.count
|
||||
end
|
||||
|
||||
test "role change is announced" do
|
||||
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
|
||||
stub_connection current_user: @bob
|
||||
subscribe id: @doc.id, key: "b"
|
||||
@doc.document_memberships.update_all(role: DocumentMembership.roles[:viewer])
|
||||
subscription.send(:revalidate)
|
||||
assert_equal({ "type" => "role", "role" => "viewer" }, last.slice("type", "role").transform_values(&:to_s))
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user