mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
- 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.
948 lines
25 KiB
Lua
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
|