rojo/plugin/src/ServeSession.spec.lua
Joe 1ad54fb0c4 Enhance CI pipeline documentation for Rojo and rbx-dom
- Updated the `/ci` command documentation to include detailed steps for running the CI pipeline for both Rojo and rbx-dom.
- Added specific instructions for auto-fixing formatting, linting, building, and testing within the rbx-dom submodule.
- Improved reporting structure to summarize results for both Rojo and rbx-dom, ensuring clarity on formatting, linting, and test outcomes.
2026-02-06 22:36:50 -06:00

948 lines
25 KiB
Lua

--[[
Tests for ServeSession, focused on one-shot sync and WebSocket message handling.
These tests construct a ServeSession directly and manipulate internal state
to test specific behaviors without going through the full start() flow.
]]
return function()
local ServeSession = require(script.Parent.ServeSession)
local PatchSet = require(script.Parent.PatchSet)
local Settings = require(script.Parent.Settings)
local testUtils = require(script.Parent.testUtils)
local MockApiContext = testUtils.MockApiContext
local function createSession(overrides)
overrides = overrides or {}
local mockApi = MockApiContext.new()
local session = ServeSession.new({
apiContext = mockApi,
twoWaySync = if overrides.twoWaySync ~= nil then overrides.twoWaySync else true,
})
-- Set to Connected so __onWebSocketMessage doesn't bail at the Disconnected check
session.__status = ServeSession.Status.Connected
session.__serverInfo = {
projectName = "TestProject",
sessionId = "test-session-id",
}
return session, mockApi
end
local function createMessagesPacket(patch)
return {
messageCursor = 1,
messages = { patch },
}
end
local function createTestPatch()
local patch = PatchSet.newEmpty()
table.insert(patch.updated, {
id = "test-instance-id",
changedProperties = {
Value = { String = "changed" },
},
})
return patch
end
describe("__onWebSocketMessage", function()
describe("merge behavior during confirmation", function()
it("should merge messages into __confirmingPatch when confirmation is active", function()
local session = createSession()
-- Simulate active confirmation: __confirmingPatch is non-nil
local confirmingPatch = PatchSet.newEmpty()
table.insert(confirmingPatch.updated, {
id = "existing-update-id",
changedProperties = {
Value = { String = "existing" },
},
})
session.__confirmingPatch = confirmingPatch
session.__isConfirming = true
local patchUpdateCalled = false
session:setPatchUpdateCallback(function()
patchUpdateCalled = true
end)
-- Send a WebSocket message while confirming
local incomingPatch = createTestPatch()
session:__onWebSocketMessage(createMessagesPacket(incomingPatch))
-- Should have merged into the confirming patch, not spawned a new confirmation
expect(patchUpdateCalled).to.equal(true)
-- The confirming patch should now have both updates
expect(#confirmingPatch.updated).to.equal(2)
session:stop()
end)
it("should not spawn a new confirmation when __confirmingPatch is non-nil", function()
local session = createSession()
local confirmCallbackCalled = false
session:setConfirmCallback(function()
confirmCallbackCalled = true
return "Accept"
end)
-- Set up active confirmation
session.__confirmingPatch = PatchSet.newEmpty()
session.__isConfirming = true
-- Send WebSocket message
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- Confirm callback should NOT have been called (message was merged)
expect(confirmCallbackCalled).to.equal(false)
session:stop()
end)
end)
describe("new confirmation when not already confirming", function()
it("should call confirm callback when __confirmingPatch is nil", function()
local session = createSession()
local confirmCallbackCalled = false
session:setConfirmCallback(function()
confirmCallbackCalled = true
return "Accept"
end)
-- No active confirmation
session.__confirmingPatch = nil
session.__isConfirming = false
-- Send WebSocket message
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- Allow the task.spawn to execute
task.wait()
expect(confirmCallbackCalled).to.equal(true)
session:stop()
end)
it("should pause ChangeBatcher when spawning new confirmation", function()
local session = createSession()
local wasPausedDuringCallback = false
session:setConfirmCallback(function()
-- task.spawn runs the function immediately until first yield,
-- so we check pause state INSIDE the callback
wasPausedDuringCallback = session.__changeBatcher:isPaused()
return "Accept"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
-- Should not be paused before
expect(session.__changeBatcher:isPaused()).to.equal(false)
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- task.spawn ran synchronously (no yield in callback),
-- so batcher was paused during callback and resumed after
task.wait()
expect(wasPausedDuringCallback).to.equal(true)
expect(session.__changeBatcher:isPaused()).to.equal(false)
session:stop()
end)
end)
describe("Skip response handling", function()
it("should not apply patches when confirm callback returns Skip", function()
local session = createSession()
local applyPatchCalled = false
local originalApplyPatch = session.__applyPatch
function session:__applyPatch(_patch)
applyPatchCalled = true
-- Don't actually apply
end
session:setConfirmCallback(function()
return "Skip"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- Allow task.spawn to execute
task.wait()
expect(applyPatchCalled).to.equal(false)
-- Restore
session.__applyPatch = originalApplyPatch
session:stop()
end)
it("should clear confirmation flags after Skip", function()
local session = createSession()
session:setConfirmCallback(function()
return "Skip"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- Allow task.spawn to execute
task.wait()
expect(session.__confirmingPatch).to.equal(nil)
expect(session.__isConfirming).to.equal(false)
session:stop()
end)
it("should resume ChangeBatcher after Skip", function()
local session = createSession()
session:setConfirmCallback(function()
return "Skip"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- Allow task.spawn to execute
task.wait()
expect(session.__changeBatcher:isPaused()).to.equal(false)
session:stop()
end)
end)
describe("Accept response handling", function()
it("should apply patches when confirm callback returns Accept", function()
local session = createSession()
local appliedPatch = nil
local originalApplyPatch = session.__applyPatch
function session:__applyPatch(patch)
appliedPatch = patch
end
session:setConfirmCallback(function()
return "Accept"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
local testPatch = createTestPatch()
session:__onWebSocketMessage(createMessagesPacket(testPatch))
-- Allow task.spawn to execute
task.wait()
expect(appliedPatch).to.be.ok()
session.__applyPatch = originalApplyPatch
session:stop()
end)
end)
describe("Abort response handling", function()
it("should stop session when confirm callback returns Abort", function()
local session = createSession()
session:setConfirmCallback(function()
return "Abort"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- Allow task.spawn to execute
task.wait()
expect(session:getStatus()).to.equal(ServeSession.Status.Disconnected)
end)
end)
describe("Disconnected guard", function()
it("should ignore messages when status is Disconnected", function()
local session = createSession()
session.__status = ServeSession.Status.Disconnected
local confirmCallbackCalled = false
session:setConfirmCallback(function()
confirmCallbackCalled = true
return "Accept"
end)
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
expect(confirmCallbackCalled).to.equal(false)
end)
end)
end)
describe("one-shot sync confirm callback pattern", function()
--[[
These tests verify the pattern used in App/init.lua where
an `initialSyncConfirmed` flag gates the confirm callback
to return "Skip" for post-initial-sync echo patches.
We simulate the pattern directly rather than testing through
the full App component. PatchSet.removeDataModelName is skipped
here since it requires an instanceMap and isn't what we're testing.
]]
it("should allow first confirmation in one-shot mode", function()
local initialSyncConfirmed = false
local function confirmCallback(_instanceMap, _patch, _serverInfo)
local isOneShotMode = true
if isOneShotMode and initialSyncConfirmed then
return "Skip"
end
-- Simulate user accepting (would normally wait on UI)
initialSyncConfirmed = true
return { type = "Confirm", selections = {} }
end
-- First call: should NOT skip
local result = confirmCallback(nil, createTestPatch(), nil)
expect(type(result)).to.equal("table")
expect(result.type).to.equal("Confirm")
end)
it("should skip subsequent patches after initial sync is confirmed", function()
local initialSyncConfirmed = false
local function confirmCallback(_instanceMap, _patch, _serverInfo)
local isOneShotMode = true
if isOneShotMode and initialSyncConfirmed then
return "Skip"
end
initialSyncConfirmed = true
return { type = "Confirm", selections = {} }
end
-- First call: user confirms
confirmCallback(nil, createTestPatch(), nil)
-- Second call: should skip
local result = confirmCallback(nil, createTestPatch(), nil)
expect(result).to.equal("Skip")
-- Third call: should also skip
local result2 = confirmCallback(nil, createTestPatch(), nil)
expect(result2).to.equal("Skip")
end)
it("should not skip in non-one-shot mode", function()
local initialSyncConfirmed = false
local function confirmCallback(_instanceMap, patch, _serverInfo)
local isOneShotMode = false
if isOneShotMode and initialSyncConfirmed then
return "Skip"
end
if PatchSet.isEmpty(patch) then
return "Accept"
end
initialSyncConfirmed = true
return { type = "Confirm", selections = {} }
end
-- First call
confirmCallback(nil, createTestPatch(), nil)
-- Second call: should NOT skip since one-shot is off
local result = confirmCallback(nil, createTestPatch(), nil)
expect(result).to.never.equal("Skip")
end)
it("should not skip empty patches in non-one-shot mode (auto-accept instead)", function()
local initialSyncConfirmed = false
local function confirmCallback(_instanceMap, patch, _serverInfo)
local isOneShotMode = false
if isOneShotMode and initialSyncConfirmed then
return "Skip"
end
if PatchSet.isEmpty(patch) then
return "Accept"
end
initialSyncConfirmed = true
return { type = "Confirm", selections = {} }
end
-- First call with non-empty patch
confirmCallback(nil, createTestPatch(), nil)
-- Second call with empty patch
local result = confirmCallback(nil, PatchSet.newEmpty(), nil)
expect(result).to.equal("Accept")
end)
end)
describe("one-shot sync end-to-end with ServeSession", function()
it("should skip echo patches after initial sync via confirm callback", function()
local session = createSession()
-- Simulate the App pattern: initialSyncConfirmed flag in closure
local initialSyncConfirmed = false
session:setConfirmCallback(function(_instanceMap, _patch, _serverInfo)
if initialSyncConfirmed then
return "Skip"
end
-- First call: simulate user confirming
initialSyncConfirmed = true
return "Accept"
end)
local applyCount = 0
local originalApplyPatch = session.__applyPatch
function session:__applyPatch(_patch)
applyCount += 1
end
session.__confirmingPatch = nil
session.__isConfirming = false
-- First WebSocket message: should be confirmed and applied
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
expect(applyCount).to.equal(1)
-- Second WebSocket message (echo): should be skipped
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
-- Still 1, not 2 — echo was skipped
expect(applyCount).to.equal(1)
-- Third WebSocket message: also skipped
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
expect(applyCount).to.equal(1)
session.__applyPatch = originalApplyPatch
session:stop()
end)
it("should not write echo patches to server", function()
local session, mockApi = createSession()
local initialSyncConfirmed = false
session:setConfirmCallback(function()
if initialSyncConfirmed then
return "Skip"
end
initialSyncConfirmed = true
return "Accept"
end)
-- Stub __applyPatch to prevent reconciler calls
function session:__applyPatch() end
session.__confirmingPatch = nil
session.__isConfirming = false
-- Initial sync message
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
-- Echo message
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
-- Should not have called write() — only setMessageCursor
local writeRequests = mockApi:getRequestsByMethod("write")
expect(#writeRequests).to.equal(0)
session:stop()
end)
end)
describe("WebSocket message edge cases", function()
it("should batch multiple messages in a single packet into one patch", function()
local session = createSession()
local appliedPatch = nil
function session:__applyPatch(patch)
appliedPatch = patch
end
session:setConfirmCallback(function()
return "Accept"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
-- Create a packet with multiple messages
local msg1 = PatchSet.newEmpty()
table.insert(msg1.updated, {
id = "id-1",
changedProperties = { Value = { String = "one" } },
})
local msg2 = PatchSet.newEmpty()
table.insert(msg2.updated, {
id = "id-2",
changedProperties = { Value = { String = "two" } },
})
local packet = {
messageCursor = 1,
messages = { msg1, msg2 },
}
session:__onWebSocketMessage(packet)
task.wait()
-- Both messages should be combined into one patch
expect(appliedPatch).to.be.ok()
expect(#appliedPatch.updated).to.equal(2)
session:stop()
end)
it("should handle rapid sequential messages alternating merge and spawn", function()
local session = createSession()
local applyCount = 0
function session:__applyPatch()
applyCount += 1
end
session:setConfirmCallback(function()
return "Accept"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
-- Send 5 rapid messages
for i = 1, 5 do
local patch = PatchSet.newEmpty()
table.insert(patch.updated, {
id = "id-" .. tostring(i),
changedProperties = { Value = { String = "val-" .. tostring(i) } },
})
session:__onWebSocketMessage(createMessagesPacket(patch))
end
-- Let all task.spawns run
task.wait()
task.wait()
-- Each message should have been processed (Accept returns immediately,
-- so __confirmingPatch is cleared before next message arrives)
expect(applyCount).to.equal(5)
session:stop()
end)
it("should safely handle confirm callback returning unexpected value", function()
local session = createSession()
local applyPatchCalled = false
function session:__applyPatch()
applyPatchCalled = true
end
session:setConfirmCallback(function()
return 42 -- Not a valid response
end)
session.__confirmingPatch = nil
session.__isConfirming = false
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
-- Invalid value should fall through without applying
expect(applyPatchCalled).to.equal(false)
-- Session should still be alive (not crashed)
expect(session.__isConfirming).to.equal(false)
expect(session.__changeBatcher:isPaused()).to.equal(false)
session:stop()
end)
it("should discard merged content when user aborts", function()
local session = createSession()
local applyPatchCalled = false
function session:__applyPatch()
applyPatchCalled = true
end
-- Set up active confirmation with existing patch
local confirmingPatch = PatchSet.newEmpty()
table.insert(confirmingPatch.updated, {
id = "existing-id",
changedProperties = { Value = { String = "existing" } },
})
session.__confirmingPatch = confirmingPatch
session.__isConfirming = true
session:setPatchUpdateCallback(function() end)
-- Merge additional content during confirmation
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
-- Confirming patch now has 2 updates
expect(#confirmingPatch.updated).to.equal(2)
-- Now simulate the user aborting (done outside this test, but verify
-- the patch is NOT auto-applied by the merge)
expect(applyPatchCalled).to.equal(false)
session:stop()
end)
it("should handle message with no confirm callback set", function()
local session = createSession()
function session:__applyPatch() end
-- No confirm callback set - should default to "Accept"
session.__userConfirmCallback = nil
session.__confirmingPatch = nil
session.__isConfirming = false
-- Should not crash
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
session:stop()
end)
end)
describe("state transitions", function()
it("should start with NotStarted status", function()
local mockApi = MockApiContext.new()
local session = ServeSession.new({
apiContext = mockApi,
twoWaySync = true,
})
expect(session:getStatus()).to.equal(ServeSession.Status.NotStarted)
session:stop()
end)
it("should transition to Disconnected on stop", function()
local session = createSession()
session:stop()
expect(session:getStatus()).to.equal(ServeSession.Status.Disconnected)
end)
it("should handle double stop without crashing", function()
local session = createSession()
session:stop()
expect(session:getStatus()).to.equal(ServeSession.Status.Disconnected)
-- Second stop should be safe
session:stop()
expect(session:getStatus()).to.equal(ServeSession.Status.Disconnected)
end)
it("should fire statusChangedCallback on stop", function()
local session = createSession()
local statusChanges = {}
session:onStatusChanged(function(status, detail)
table.insert(statusChanges, { status = status, detail = detail })
end)
session:stop()
expect(#statusChanges).to.equal(1)
expect(statusChanges[1].status).to.equal(ServeSession.Status.Disconnected)
end)
it("should fire statusChangedCallback with error detail on abort", function()
local session = createSession()
local statusChanges = {}
session:onStatusChanged(function(status, detail)
table.insert(statusChanges, { status = status, detail = detail })
end)
session:setConfirmCallback(function()
return "Abort"
end)
session.__confirmingPatch = nil
session.__isConfirming = false
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
expect(#statusChanges).to.equal(1)
expect(statusChanges[1].status).to.equal(ServeSession.Status.Disconnected)
end)
it("should ignore WebSocket messages after stop", function()
local session = createSession()
local confirmCallbackCalled = false
session:setConfirmCallback(function()
confirmCallbackCalled = true
return "Accept"
end)
session:stop()
-- Message after stop
session:__onWebSocketMessage(createMessagesPacket(createTestPatch()))
task.wait()
expect(confirmCallbackCalled).to.equal(false)
end)
end)
describe("__applyPatch defense-in-depth", function()
it("should block unexpected patch during confirmation", function()
local session = createSession()
-- Set up confirmation state
local confirmingPatch = createTestPatch()
session.__isConfirming = true
session.__confirmingPatch = confirmingPatch
-- Try to apply a DIFFERENT patch
local unexpectedPatch = PatchSet.newEmpty()
table.insert(unexpectedPatch.updated, {
id = "different-id",
changedProperties = { Value = { String = "sneaky" } },
})
-- Log.error throws in this codebase, so wrap in pcall
local success = pcall(function()
session:__applyPatch(unexpectedPatch)
end)
-- Should have errored (defense-in-depth)
expect(success).to.equal(false)
session.__isConfirming = false
session.__confirmingPatch = nil
session:stop()
end)
it("should run precommit callbacks before patch application", function()
local session = createSession()
local precommitCalled = false
local precommitPatch = nil
session:hookPrecommit(function(patch, _instanceMap)
precommitCalled = true
precommitPatch = patch
end)
local patch = PatchSet.newEmpty()
session:__applyPatch(patch)
expect(precommitCalled).to.equal(true)
expect(precommitPatch).to.equal(patch)
session:stop()
end)
it("should continue applying patch even if precommit callback errors", function()
local session = createSession()
local secondPrecommitCalled = false
session:hookPrecommit(function()
error("precommit explosion!")
end)
session:hookPrecommit(function()
secondPrecommitCalled = true
end)
-- Should not crash despite precommit error
local patch = PatchSet.newEmpty()
session:__applyPatch(patch)
-- Second precommit should still run
expect(secondPrecommitCalled).to.equal(true)
session:stop()
end)
it("should run postcommit callbacks after patch application", function()
local session = createSession()
local postcommitCalled = false
session:hookPostcommit(function(_patch, _instanceMap, _unappliedPatch)
postcommitCalled = true
end)
local patch = PatchSet.newEmpty()
session:__applyPatch(patch)
-- Postcommit runs in task.spawn, so wait
task.wait()
expect(postcommitCalled).to.equal(true)
session:stop()
end)
it("should not crash if postcommit callback errors", function()
local session = createSession()
session:hookPostcommit(function()
error("postcommit explosion!")
end)
-- Should not crash
local patch = PatchSet.newEmpty()
session:__applyPatch(patch)
task.wait()
-- Session should still be functional
expect(session:getStatus()).to.equal(ServeSession.Status.Connected)
session:stop()
end)
it("should return cleanup function from hookPrecommit", function()
local session = createSession()
local callCount = 0
local cleanup = session:hookPrecommit(function()
callCount += 1
end)
session:__applyPatch(PatchSet.newEmpty())
expect(callCount).to.equal(1)
-- Disconnect
cleanup()
session:__applyPatch(PatchSet.newEmpty())
-- Should still be 1 (callback disconnected)
expect(callCount).to.equal(1)
session:stop()
end)
end)
describe("ChangeBatcher one-shot blocking", function()
it("should block automatic outgoing writes in one-shot mode", function()
-- Save and override setting
local originalValue = Settings:get("oneShotSync")
Settings:set("oneShotSync", true)
local writeCallCount = 0
local mockApi = MockApiContext.new()
mockApi.onWrite = function()
writeCallCount += 1
return true, { success = true }
end
local session = ServeSession.new({
apiContext = mockApi,
twoWaySync = true,
})
session.__status = ServeSession.Status.Connected
-- Simulate a ChangeBatcher flush by triggering the onChangesFlushed path
-- The ChangeBatcher calls this internally, but we can test the guard
-- by checking that write() is never called even if changes are pending
local part = Instance.new("Part")
session.__instanceMap:insert("TEST_PART", part)
session.__changeBatcher:add(part, "Name")
-- Force a flush cycle
session.__changeBatcher:__cycle(1.0)
-- Write should NOT have been called due to one-shot guard
expect(writeCallCount).to.equal(0)
-- Restore setting
Settings:set("oneShotSync", originalValue)
part:Destroy()
session:stop()
end)
it("should allow automatic outgoing writes when one-shot is off", function()
-- Save and override setting
local originalValue = Settings:get("oneShotSync")
Settings:set("oneShotSync", false)
local writeCallCount = 0
local mockApi = MockApiContext.new()
mockApi.onWrite = function()
writeCallCount += 1
return true, { success = true }
end
local session = ServeSession.new({
apiContext = mockApi,
twoWaySync = true,
})
session.__status = ServeSession.Status.Connected
local part = Instance.new("Part")
session.__instanceMap:insert("TEST_PART", part)
session.__changeBatcher:add(part, "Name")
-- Force a flush cycle
session.__changeBatcher:__cycle(1.0)
-- Write SHOULD have been called
expect(writeCallCount).to.equal(1)
-- Restore setting
Settings:set("oneShotSync", originalValue)
part:Destroy()
session:stop()
end)
end)
end