diff --git a/src/init.lua b/src/init.lua index f002f975..2e33cc5d 100644 --- a/src/init.lua +++ b/src/init.lua @@ -6,6 +6,7 @@ local GlobalConfig = require(script.GlobalConfig) local createReconciler = require(script.createReconciler) local createReconcilerCompat = require(script.createReconcilerCompat) local RobloxRenderer = require(script.RobloxRenderer) +local shallow = require(script.shallow) local strict = require(script.strict) local Binding = require(script.Binding) @@ -39,6 +40,8 @@ local Roact = strict { setGlobalConfig = GlobalConfig.set, + shallow = shallow, + -- APIs that may change in the future without warning UNSTABLE = { }, diff --git a/src/init.spec.lua b/src/init.spec.lua index 7fcf79c8..390d3846 100644 --- a/src/init.spec.lua +++ b/src/init.spec.lua @@ -13,6 +13,7 @@ return function() update = "function", oneChild = "function", setGlobalConfig = "function", + shallow = "function", -- These functions are deprecated and throw warnings! reify = "function", diff --git a/src/shallow.lua b/src/shallow.lua index df76a868..01ef95d4 100644 --- a/src/shallow.lua +++ b/src/shallow.lua @@ -3,6 +3,7 @@ local Children = require(script.Parent.PropMarkers.Children) local RobloxRenderer = require(script.Parent.RobloxRenderer) local ElementKind = require(script.Parent.ElementKind) local ElementUtils = require(script.Parent.ElementUtils) +local snapshot = require(script.Parent.snapshot) local robloxReconciler = createReconciler(RobloxRenderer) @@ -50,18 +51,19 @@ local function findNextVirtualNode(virtualNode, maxDepth) end local ContraintFunctions = { - kind = function(element, expectKind) - return ElementKind.of(element) == expectKind + kind = function(virtualNode, expectKind) + return ElementKind.of(virtualNode.currentElement) == expectKind end, - className = function(element, className) + className = function(virtualNode, className) + local element = virtualNode.currentElement local isHost = ElementKind.of(element) == ElementKind.Host return isHost and element.component == className end, - component = function(element, expectComponentValue) - return element.component == expectComponentValue + component = function(virtualNode, expectComponentValue) + return virtualNode.currentElement.component == expectComponentValue end, - props = function(element, propSubSet) - local elementProps = element.props + props = function(virtualNode, propSubSet) + local elementProps = virtualNode.currentElement.props for propKey, propValue in pairs(propSubSet) do if elementProps[propKey] ~= propValue then @@ -70,7 +72,10 @@ local ContraintFunctions = { end return true - end + end, + hostKey = function(virtualNode, expectHostKey) + return virtualNode.hostKey == expectHostKey + end, } local function countChildrenOfElement(element) @@ -111,7 +116,7 @@ local function filterProps(props) for key, value in pairs(props) do if key ~= Children then - props[key] = value + filteredProps[key] = value end end @@ -128,6 +133,8 @@ function ShallowWrapper.new(virtualNode, maxDepth) _shallowChildren = nil, type = getTypeFromVirtualNode(virtualNode), props = filterProps(virtualNode.currentElement.props), + hostKey = virtualNode.hostKey, + instance = virtualNode.hostObject, } return setmetatable(wrapper, ShallowWrapperMetatable) @@ -145,7 +152,7 @@ function ShallowWrapper:childrenCount() end function ShallowWrapper:find(constraints) - for constraint in pairs(constraints) do + for constraint in pairs(constraints) do if not ContraintFunctions[constraint] then error(('unknown constraint %q'):format(constraint)) end @@ -164,6 +171,27 @@ function ShallowWrapper:find(constraints) return results end +function ShallowWrapper:findUnique(constraints) + local children = self:getChildren() + + if constraints == nil then + assert( + #children == 1, + ("expect to contain exactly one child, but found %d"):format(#children) + ) + return children[1] + end + + local constrainedChildren = self:find(constraints) + + assert( + #constrainedChildren == 1, + ("expect to find only one child, but found %d"):format(#constrainedChildren) + ) + + return constrainedChildren[1] +end + function ShallowWrapper:getChildren() if self._shallowChildren then return self._shallowChildren @@ -179,13 +207,21 @@ function ShallowWrapper:getChildren() return results end +function ShallowWrapper:toMatchSnapshot(identifier) + assert(typeof(identifier) == "string", "Snapshot identifier must be a string") + + local snapshotResult = snapshot(identifier, self) + + snapshotResult:match() +end + function ShallowWrapper:_satisfiesAllContraints(constraints) - local element = self._virtualNode.currentElement + local virtualNode = self._virtualNode for constraint, value in pairs(constraints) do local constraintFunction = ContraintFunctions[constraint] - if not constraintFunction(element, value) then + if not constraintFunction(virtualNode, value) then return false end end diff --git a/src/shallow.spec.lua b/src/shallow.spec.lua index 66ec0212..256ed98a 100644 --- a/src/shallow.spec.lua +++ b/src/shallow.spec.lua @@ -379,6 +379,37 @@ return function() end) end) + describe("instance", function() + it("should contain the instance when it is a host component", function() + local className = "Frame" + local function Component(props) + return createElement(className) + end + + local element = createElement(Component) + + local result = shallow(element) + + expect(result.instance).to.be.ok() + expect(result.instance.ClassName).to.equal(className) + end) + + it("should not have an instance if it is a function component", function() + local function Child() + return createElement("Frame") + end + local function Component(props) + return createElement(Child) + end + + local element = createElement(Component) + + local result = shallow(element) + + expect(result.instance).never.to.be.ok() + end) + end) + describe("find children", function() local function Component(props) return createElement("Frame", {}, props.children) @@ -648,6 +679,47 @@ return function() end) end) + describe("hostKey constraint", function() + it("should find the child element", function() + local hostKey = "Child" + local element = createElement(Component, { + children = { + [hostKey] = createElement("TextLabel"), + }, + }) + + local result = shallow(element) + + local constraints = { + hostKey = hostKey, + } + local children = result:find(constraints) + + expect(#children).to.equal(1) + + local child = children[1] + + expect(child.type.kind).to.equal(ElementKind.Host) + end) + + it("should return an empty list when no children is found", function() + local element = createElement(Component, { + children = { + Child = createElement("TextLabel"), + }, + }) + + local result = shallow(element) + + local constraints = { + hostKey = "NotFound", + } + local children = result:find(constraints) + + expect(next(children)).never.to.be.ok() + end) + end) + it("should throw if the constraint does not exist", function() local element = createElement("Frame") @@ -734,4 +806,63 @@ return function() expect(#children).to.equal(1) end) end) + + describe("findUnique", function() + it("should return the only child when no constraints are given", function() + local element = createElement("Frame", {}, { + Child = createElement("TextLabel"), + }) + + local result = shallow(element) + + local child = result:findUnique() + + expect(child.type.kind).to.equal(ElementKind.Host) + expect(child.type.className).to.equal("TextLabel") + end) + + it("should return the only child that satifies the constraint", function() + local element = createElement("Frame", {}, { + ChildA = createElement("TextLabel"), + ChildB = createElement("TextButton"), + }) + + local result = shallow(element) + + local child = result:findUnique({ + className = "TextLabel", + }) + + expect(child.type.className).to.equal("TextLabel") + end) + + it("should throw if there is not any child element", function() + local element = createElement("Frame") + + local result = shallow(element) + + local function shouldThrow() + result:findUnique() + end + + expect(shouldThrow).to.throw() + end) + + it("should throw if more than one child satisfies the constraint", function() + local element = createElement("Frame", {}, { + ChildA = createElement("TextLabel"), + ChildB = createElement("TextLabel"), + }) + + local result = shallow(element) + + local function shouldThrow() + result:findUnique({ + className = "TextLabel", + }) + end + + expect(shouldThrow).to.throw() + end) + end) end \ No newline at end of file diff --git a/src/snapshot/Serialize/AnonymousFunction.lua b/src/snapshot/Serialize/AnonymousFunction.lua new file mode 100644 index 00000000..62be7341 --- /dev/null +++ b/src/snapshot/Serialize/AnonymousFunction.lua @@ -0,0 +1,5 @@ +local Symbol = require(script.Parent.Parent.Parent.Symbol) + +local AnonymousFunction = Symbol.named("AnonymousFunction") + +return AnonymousFunction \ No newline at end of file diff --git a/src/snapshot/Serialize/IndentedOutput.lua b/src/snapshot/Serialize/IndentedOutput.lua new file mode 100644 index 00000000..cfef8376 --- /dev/null +++ b/src/snapshot/Serialize/IndentedOutput.lua @@ -0,0 +1,54 @@ +local IndentedOutput = {} +local IndentedOutputMetatable = { + __index = IndentedOutput, +} + +function IndentedOutput.new(indentation) + indentation = indentation or 2 + + local output = { + _level = 0, + _indentation = (" "):rep(indentation), + _lines = {}, + } + + setmetatable(output, IndentedOutputMetatable) + + return output +end + +function IndentedOutput:write(line, ...) + if select("#", ...) > 0 then + line = line:format(...) + end + + local indentedLine = ("%s%s"):format(self._indentation:rep(self._level), line) + + table.insert(self._lines, indentedLine) +end + +function IndentedOutput:push() + self._level = self._level + 1 +end + +function IndentedOutput:pop() + self._level = math.max(self._level - 1, 0) +end + +function IndentedOutput:writeAndPush(line) + self:write(line) + self:push() +end + +function IndentedOutput:popAndWrite(line) + self:pop() + self:write(line) +end + +function IndentedOutput:join(separator) + separator = separator or "\n" + + return table.concat(self._lines, separator) +end + +return IndentedOutput diff --git a/src/snapshot/Serialize/IndentedOutput.spec.lua b/src/snapshot/Serialize/IndentedOutput.spec.lua new file mode 100644 index 00000000..7f9ffbe1 --- /dev/null +++ b/src/snapshot/Serialize/IndentedOutput.spec.lua @@ -0,0 +1,72 @@ +return function() + local IndentedOutput = require(script.Parent.IndentedOutput) + + describe("join", function() + it("should concat the lines with a new line by default", function() + local output = IndentedOutput.new() + + output:write("foo") + output:write("bar") + + expect(output:join()).to.equal("foo\nbar") + end) + + it("should concat the lines with the given string", function() + local output = IndentedOutput.new() + + output:write("foo") + output:write("bar") + + expect(output:join("-")).to.equal("foo-bar") + end) + end) + + describe("push", function() + it("should indent next written lines", function() + local output = IndentedOutput.new() + + output:write("foo") + output:push() + output:write("bar") + + expect(output:join()).to.equal("foo\n bar") + end) + end) + + describe("pop", function() + it("should dedent next written lines", function() + local output = IndentedOutput.new() + + output:write("foo") + output:push() + output:write("bar") + output:pop() + output:write("baz") + + expect(output:join()).to.equal("foo\n bar\nbaz") + end) + end) + + describe("writeAndPush", function() + it("should write the line and push", function() + local output = IndentedOutput.new() + + output:writeAndPush("foo") + output:write("bar") + + expect(output:join()).to.equal("foo\n bar") + end) + end) + + describe("popAndWrite", function() + it("should write the line and push", function() + local output = IndentedOutput.new() + + output:writeAndPush("foo") + output:write("bar") + output:popAndWrite("baz") + + expect(output:join()).to.equal("foo\n bar\nbaz") + end) + end) +end \ No newline at end of file diff --git a/src/snapshot/Serialize/Serializer.lua b/src/snapshot/Serialize/Serializer.lua new file mode 100644 index 00000000..3bb7ce63 --- /dev/null +++ b/src/snapshot/Serialize/Serializer.lua @@ -0,0 +1,189 @@ +local AnonymousFunction = require(script.Parent.AnonymousFunction) +local ElementKind = require(script.Parent.Parent.Parent.ElementKind) +local IndentedOutput = require(script.Parent.IndentedOutput) +local Type = require(script.Parent.Parent.Parent.Type) + +local function sortRoactEvents(a, b) + return a.name < b.name +end + +local Serializer = {} + +function Serializer.kind(kind) + if kind == ElementKind.Host then + return "Host" + elseif kind == ElementKind.Function then + return "Function" + elseif kind == ElementKind.Stateful then + return "Stateful" + else + error(("Cannot serialize ElementKind %q"):format(tostring(kind))) + end +end + +function Serializer.type(data, output) + output:writeAndPush("type = {") + output:write("kind = ElementKind.%s,", Serializer.kind(data.kind)) + + if data.className then + output:write("className = %q,", data.className) + elseif data.componentName then + output:write("componentName = %q,", data.componentName) + end + + output:popAndWrite("},") +end + +function Serializer.propKey(key) + if key:match("^%a%w+$") then + return key + else + return ("[%q]"):format(key) + end +end + +function Serializer.propValue(prop) + local propType = typeof(prop) + + if propType == "string" then + return ("%q"):format(prop) + + elseif propType == "number" or propType == "boolean" then + return ("%s"):format(tostring(prop)) + + elseif propType == "Color3" then + return ("Color3.new(%s, %s, %s)"):format(prop.r, prop.g, prop.b) + + elseif propType == "EnumItem" then + return ("%s"):format(tostring(prop)) + + elseif propType == "UDim" then + return ("UDim.new(%s, %s)"):format(prop.Scale, prop.Offset) + + elseif propType == "UDim2" then + return ("UDim2.new(%s, %s, %s, %s)"):format( + prop.X.Scale, + prop.X.Offset, + prop.Y.Scale, + prop.Y.Offset + ) + + elseif propType == "Vector2" then + return ("Vector2.new(%s, %s)"):format(prop.X, prop.Y) + + elseif prop == AnonymousFunction then + return "AnonymousFunction" + + else + error(("Cannot serialize prop %q with value of type %q"):format( + tostring(prop), + propType + )) + end +end + +function Serializer.tableContent(dict, output) + local keys = {} + + for key in pairs(dict) do + table.insert(keys, key) + end + + table.sort(keys) + + for i=1, #keys do + local key = keys[i] + output:write("%s = %s,", Serializer.propKey(key), Serializer.propValue(dict[key], output)) + end +end + +function Serializer.props(props, output) + if next(props) == nil then + output:write("props = {},") + return + end + + local stringProps = {} + local events = {} + local changedEvents = {} + + output:writeAndPush("props = {") + + for key, value in pairs(props) do + if type(key) == "string" then + stringProps[key] = value + + elseif Type.of(key) == Type.HostEvent then + table.insert(events, key) + + elseif Type.of(key) == Type.HostChangeEvent then + table.insert(changedEvents, key) + + end + end + + Serializer.tableContent(stringProps, output) + table.sort(events, sortRoactEvents) + table.sort(changedEvents, sortRoactEvents) + + for i=1, #events do + local event = events[i] + local serializedPropValue = Serializer.propValue(props[event], output) + output:write("[Roact.Event.%s] = %s,", event.name, serializedPropValue) + end + + for i=1, #changedEvents do + local changedEvent = changedEvents[i] + local serializedPropValue = Serializer.propValue(props[changedEvent], output) + output:write("[Roact.Change.%s] = %s,", changedEvent.name, serializedPropValue) + end + + output:popAndWrite("},") +end + +function Serializer.children(children, output) + if #children == 0 then + output:write("children = {},") + return + end + + output:writeAndPush("children = {") + + for i=1, #children do + Serializer.snapshotData(children[i], output) + end + + output:popAndWrite("},") +end + +function Serializer.snapshotDataContent(snapshotData, output) + Serializer.type(snapshotData.type, output) + output:write("hostKey = %q,", snapshotData.hostKey) + Serializer.props(snapshotData.props, output) + Serializer.children(snapshotData.children, output) +end + +function Serializer.snapshotData(snapshotData, output) + output:writeAndPush("{") + Serializer.snapshotDataContent(snapshotData, output) + output:popAndWrite("},") +end + +function Serializer.firstSnapshotData(snapshotData) + local output = IndentedOutput.new() + output:writeAndPush("return function(dependencies)") + output:write("local Roact = dependencies.Roact") + output:write("local ElementKind = dependencies.ElementKind") + output:write("local AnonymousFunction = dependencies.AnonymousFunction") + output:write("") + output:writeAndPush("return {") + + Serializer.snapshotDataContent(snapshotData, output) + + output:popAndWrite("}") + output:popAndWrite("end") + + return output:join() +end + +return Serializer diff --git a/src/snapshot/Serialize/Serializer.spec.lua b/src/snapshot/Serialize/Serializer.spec.lua new file mode 100644 index 00000000..b5cec7e9 --- /dev/null +++ b/src/snapshot/Serialize/Serializer.spec.lua @@ -0,0 +1,274 @@ +return function() + local AnonymousFunction = require(script.Parent.AnonymousFunction) + local Change = require(script.Parent.Parent.Parent.PropMarkers.Change) + local Event = require(script.Parent.Parent.Parent.PropMarkers.Event) + local ElementKind = require(script.Parent.Parent.Parent.ElementKind) + local IndentedOutput = require(script.Parent.IndentedOutput) + local Serializer = require(script.Parent.Serializer) + + describe("type", function() + it("should serialize host elements", function() + local output = IndentedOutput.new() + Serializer.type({ + kind = ElementKind.Host, + className = "TextLabel", + }, output) + + expect(output:join()).to.equal( + "type = {\n" + .. " kind = ElementKind.Host,\n" + .. " className = \"TextLabel\",\n" + .. "}," + ) + end) + + it("should serialize stateful elements", function() + local output = IndentedOutput.new() + Serializer.type({ + kind = ElementKind.Stateful, + componentName = "SomeComponent", + }, output) + + expect(output:join()).to.equal( + "type = {\n" + .. " kind = ElementKind.Stateful,\n" + .. " componentName = \"SomeComponent\",\n" + .. "}," + ) + end) + + it("should serialize function elements", function() + local output = IndentedOutput.new() + Serializer.type({ + kind = ElementKind.Function, + }, output) + + expect(output:join()).to.equal( + "type = {\n" + .. " kind = ElementKind.Function,\n" + .. "}," + ) + end) + end) + + describe("propKey", function() + it("should serialize to a named dictionary field", function() + local keys = {"foo", "foo1"} + + for i=1, #keys do + local key = keys[i] + local result = Serializer.propKey(key) + + expect(result).to.equal(key) + end + end) + + it("should serialize to a string value field to escape non-alphanumeric characters", function() + local keys = {"foo.bar", "1foo"} + + for i=1, #keys do + local key = keys[i] + local result = Serializer.propKey(key) + + expect(result).to.equal('["' .. key .. '"]') + end + end) + end) + + describe("propValue", function() + it("should serialize strings", function() + local result = Serializer.propValue("foo") + + expect(result).to.equal('"foo"') + end) + + it("should serialize strings with \"", function() + local result = Serializer.propValue('foo"bar') + + expect(result).to.equal('"foo\\"bar"') + end) + + it("should serialize numbers", function() + local result = Serializer.propValue(10.5) + + expect(result).to.equal("10.5") + end) + + it("should serialize booleans", function() + expect(Serializer.propValue(true)).to.equal("true") + expect(Serializer.propValue(false)).to.equal("false") + end) + + it("should serialize enum items", function() + local result = Serializer.propValue(Enum.SortOrder.LayoutOrder) + + expect(result).to.equal("Enum.SortOrder.LayoutOrder") + end) + + it("should serialize Color3", function() + local result = Serializer.propValue(Color3.new(0.1, 0.2, 0.3)) + + expect(result).to.equal("Color3.new(0.1, 0.2, 0.3)") + end) + + it("should serialize UDim", function() + local result = Serializer.propValue(UDim.new(1, 0.5)) + + expect(result).to.equal("UDim.new(1, 0.5)") + end) + + it("should serialize UDim2", function() + local result = Serializer.propValue(UDim2.new(1, 0.5, 2, 2.5)) + + expect(result).to.equal("UDim2.new(1, 0.5, 2, 2.5)") + end) + + it("should serialize Vector2", function() + local result = Serializer.propValue(Vector2.new(1.5, 0.3)) + + expect(result).to.equal("Vector2.new(1.5, 0.3)") + end) + + it("should serialize AnonymousFunction symbol", function() + local result = Serializer.propValue(AnonymousFunction) + + expect(result).to.equal("AnonymousFunction") + end) + end) + + describe("props", function() + it("should serialize an empty table", function() + local output = IndentedOutput.new() + Serializer.props({}, output) + + expect(output:join()).to.equal("props = {},") + end) + + it("should serialize table fields", function() + local output = IndentedOutput.new() + Serializer.props({ + key = 8, + }, output) + + expect(output:join()).to.equal("props = {\n key = 8,\n},") + end) + + it("should serialize Roact.Event", function() + local output = IndentedOutput.new() + Serializer.props({ + [Event.Activated] = AnonymousFunction, + }, output) + + expect(output:join()).to.equal( + "props = {\n" + .. " [Roact.Event.Activated] = AnonymousFunction,\n" + .. "}," + ) + end) + + it("should serialize Roact.Change", function() + local output = IndentedOutput.new() + Serializer.props({ + [Change.Position] = AnonymousFunction, + }, output) + + expect(output:join()).to.equal( + "props = {\n" + .. " [Roact.Change.Position] = AnonymousFunction,\n" + .. "}," + ) + end) + end) + + describe("children", function() + it("should serialize an empty table", function() + local output = IndentedOutput.new() + Serializer.children({}, output) + + expect(output:join()).to.equal("children = {},") + end) + + it("should serialize children in an array", function() + local snapshotData = { + type = { + kind = ElementKind.Function, + }, + hostKey = "HostKey", + props = {}, + children = {}, + } + + local childrenOutput = IndentedOutput.new() + Serializer.children({snapshotData}, childrenOutput) + + local snapshotDataOutput = IndentedOutput.new() + snapshotDataOutput:push() + Serializer.snapshotData(snapshotData, snapshotDataOutput) + + local expectResult = "children = {\n" .. snapshotDataOutput:join() .. "\n}," + expect(childrenOutput:join()).to.equal(expectResult) + end) + end) + + describe("snapshotDataContent", function() + it("should serialize all fields", function() + local snapshotData = { + type = { + kind = ElementKind.Function, + }, + hostKey = "HostKey", + props = {}, + children = {}, + } + local output = IndentedOutput.new() + Serializer.snapshotDataContent(snapshotData, output) + + expect(output:join()).to.equal( + "type = {\n" + .. " kind = ElementKind.Function,\n" + .. "},\n" + .. 'hostKey = "HostKey",\n' + .. "props = {},\n" + .. "children = {}," + ) + end) + end) + + describe("snapshotData", function() + it("should wrap snapshotDataContent result between curly braces", function() + local snapshotData = { + type = { + kind = ElementKind.Function, + }, + hostKey = "HostKey", + props = {}, + children = {}, + } + local contentOutput = IndentedOutput.new() + contentOutput:push() + Serializer.snapshotDataContent(snapshotData, contentOutput) + + local output = IndentedOutput.new() + Serializer.snapshotData(snapshotData, output) + + local expectResult = "{\n" .. contentOutput:join() .. "\n}," + expect(output:join()).to.equal(expectResult) + end) + end) + + describe("firstSnapshotData", function() + it("should return a function that returns a table", function() + local result = Serializer.firstSnapshotData({ + type = { + kind = ElementKind.Function, + }, + hostKey = "HostKey", + props = {}, + children = {}, + }) + + local pattern = "^return function%(.-%).+return%s+{(.+)}%s+end$" + expect(result:match(pattern)).to.be.ok() + end) + end) +end \ No newline at end of file diff --git a/src/snapshot/Serialize/SnapshotData.lua b/src/snapshot/Serialize/SnapshotData.lua new file mode 100644 index 00000000..cbbb7f37 --- /dev/null +++ b/src/snapshot/Serialize/SnapshotData.lua @@ -0,0 +1,90 @@ +local AnonymousFunction = require(script.Parent.AnonymousFunction) +local ElementKind = require(script.Parent.Parent.Parent.ElementKind) +local Type = require(script.Parent.Parent.Parent.Type) + +local function sortSerializedChildren(childA, childB) + return childA.hostKey < childB.hostKey +end + +local SnapshotData = {} + +function SnapshotData.type(wrapperType) + local typeData = { + kind = wrapperType.kind, + } + + if wrapperType.kind == ElementKind.Host then + typeData.className = wrapperType.className + elseif wrapperType.kind == ElementKind.Stateful then + typeData.componentName = tostring(wrapperType.component) + end + + return typeData +end + +function SnapshotData.propValue(prop) + local propType = type(prop) + + if propType == "string" + or propType == "number" + or propType == "boolean" + or propType == "userdata" + then + return prop + + elseif propType == 'function' then + return AnonymousFunction + + else + error(("SnapshotData does not support prop with value %q (type %q)"):format( + tostring(prop), + propType + )) + end +end + +function SnapshotData.props(wrapperProps) + local serializedProps = {} + + for key, prop in pairs(wrapperProps) do + if type(key) == "string" + or Type.of(key) == Type.HostChangeEvent + or Type.of(key) == Type.HostEvent + then + serializedProps[key] = SnapshotData.propValue(prop) + + else + error(("SnapshotData does not support prop with key %q (type: %s)"):format( + tostring(key), + type(key) + )) + end + end + + return serializedProps +end + +function SnapshotData.children(children) + local serializedChildren = {} + + for i=1, #children do + local childWrapper = children[i] + + serializedChildren[i] = SnapshotData.wrapper(childWrapper) + end + + table.sort(serializedChildren, sortSerializedChildren) + + return serializedChildren +end + +function SnapshotData.wrapper(wrapper) + return { + type = SnapshotData.type(wrapper.type), + hostKey = wrapper.hostKey, + props = SnapshotData.props(wrapper.props), + children = SnapshotData.children(wrapper:getChildren()), + } +end + +return SnapshotData diff --git a/src/snapshot/Serialize/SnapshotData.spec.lua b/src/snapshot/Serialize/SnapshotData.spec.lua new file mode 100644 index 00000000..19d59262 --- /dev/null +++ b/src/snapshot/Serialize/SnapshotData.spec.lua @@ -0,0 +1,208 @@ +return function() + local RoactRoot = script.Parent.Parent.Parent + + local AnonymousFunction = require(script.Parent.AnonymousFunction) + local assertDeepEqual = require(RoactRoot.assertDeepEqual) + local Change = require(RoactRoot.PropMarkers.Change) + local Component = require(RoactRoot.Component) + local createElement = require(RoactRoot.createElement) + local ElementKind = require(RoactRoot.ElementKind) + local Event = require(RoactRoot.PropMarkers.Event) + local shallow = require(RoactRoot.shallow) + + local SnapshotData = require(script.Parent.SnapshotData) + + describe("type", function() + describe("host elements", function() + it("should contain the host kind", function() + local wrapper = shallow(createElement("Frame")) + + local result = SnapshotData.type(wrapper.type) + + expect(result.kind).to.equal(ElementKind.Host) + end) + + it("should contain the class name", function() + local className = "Frame" + local wrapper = shallow(createElement(className)) + + local result = SnapshotData.type(wrapper.type) + + expect(result.className).to.equal(className) + end) + end) + + describe("function elements", function() + local function SomeComponent() + return nil + end + + it("should contain the host kind", function() + local wrapper = shallow(createElement(SomeComponent)) + + local result = SnapshotData.type(wrapper.type) + + expect(result.kind).to.equal(ElementKind.Function) + end) + end) + + describe("stateful elements", function() + local componentName = "ComponentName" + local SomeComponent = Component:extend(componentName) + + function SomeComponent:render() + return nil + end + + it("should contain the host kind", function() + local wrapper = shallow(createElement(SomeComponent)) + + local result = SnapshotData.type(wrapper.type) + + expect(result.kind).to.equal(ElementKind.Stateful) + end) + + it("should contain the component name", function() + local wrapper = shallow(createElement(SomeComponent)) + + local result = SnapshotData.type(wrapper.type) + + expect(result.componentName).to.equal(componentName) + end) + end) + end) + + describe("propValue", function() + it("should return the same value", function() + local propValues = {7, "hello", Enum.SortOrder.LayoutOrder} + + for i=1, #propValues do + local prop = propValues[i] + local result = SnapshotData.propValue(prop) + + expect(result).to.equal(prop) + end + end) + + it("should return the AnonymousFunction symbol when given a function", function() + local result = SnapshotData.propValue(function() end) + + expect(result).to.equal(AnonymousFunction) + end) + end) + + describe("props", function() + it("should keep props with string keys", function() + local props = { + image = "hello", + text = "never", + } + + local result = SnapshotData.props(props) + + assertDeepEqual(result, props) + end) + + it("should map Roact.Event to AnonymousFunction", function() + local props = { + [Event.Activated] = function() end, + } + + local result = SnapshotData.props(props) + + assertDeepEqual(result, { + [Event.Activated] = AnonymousFunction, + }) + end) + + it("should map Roact.Change to AnonymousFunction", function() + local props = { + [Change.Position] = function() end, + } + + local result = SnapshotData.props(props) + + assertDeepEqual(result, { + [Change.Position] = AnonymousFunction, + }) + end) + + it("should throw when the key is a table", function() + local function shouldThrow() + SnapshotData.props({ + [{}] = "invalid", + }) + end + + expect(shouldThrow).to.throw() + end) + end) + + describe("wrapper", function() + it("should have the host key", function() + local hostKey = "SomeKey" + local wrapper = shallow(createElement("Frame")) + wrapper.hostKey = hostKey + + local result = SnapshotData.wrapper(wrapper) + + expect(result.hostKey).to.equal(hostKey) + end) + + it("should contain the element type", function() + local wrapper = shallow(createElement("Frame")) + + local result = SnapshotData.wrapper(wrapper) + + expect(result.type).to.be.ok() + expect(result.type.kind).to.equal(ElementKind.Host) + expect(result.type.className).to.equal("Frame") + end) + + it("should contain the props", function() + local props = { + LayoutOrder = 3, + [Change.Size] = function() end, + } + local expectProps = { + LayoutOrder = 3, + [Change.Size] = AnonymousFunction, + } + + local wrapper = shallow(createElement("Frame", props)) + + local result = SnapshotData.wrapper(wrapper) + + expect(result.props).to.be.ok() + assertDeepEqual(result.props, expectProps) + end) + + it("should contain the element children", function() + local wrapper = shallow(createElement("Frame", {}, { + Child = createElement("TextLabel"), + })) + + local result = SnapshotData.wrapper(wrapper) + + expect(result.children).to.be.ok() + expect(#result.children).to.equal(1) + local childData = result.children[1] + expect(childData.type.kind).to.equal(ElementKind.Host) + expect(childData.type.className).to.equal("TextLabel") + end) + + it("should sort children by their host key", function() + local wrapper = shallow(createElement("Frame", {}, { + Child = createElement("TextLabel"), + Label = createElement("TextLabel"), + })) + + local result = SnapshotData.wrapper(wrapper) + + expect(result.children).to.be.ok() + expect(#result.children).to.equal(2) + expect(result.children[1].hostKey).to.equal("Child") + expect(result.children[2].hostKey).to.equal("Label") + end) + end) +end \ No newline at end of file diff --git a/src/snapshot/Serialize/init.lua b/src/snapshot/Serialize/init.lua new file mode 100644 index 00000000..a63178ad --- /dev/null +++ b/src/snapshot/Serialize/init.lua @@ -0,0 +1,11 @@ +local Serializer = require(script.Serializer) +local SnapshotData = require(script.SnapshotData) + +return { + wrapperToSnapshotData = function(wrapper) + return SnapshotData.wrapper(wrapper) + end, + snapshotDataToString = function(data) + return Serializer.firstSnapshotData(data) + end, +} diff --git a/src/snapshot/Snapshot.lua b/src/snapshot/Snapshot.lua new file mode 100644 index 00000000..e33e71fb --- /dev/null +++ b/src/snapshot/Snapshot.lua @@ -0,0 +1,99 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local AnonymousFunction = require(script.Parent.Serialize.AnonymousFunction) +local Serialize = require(script.Parent.Serialize) +local deepEqual = require(script.Parent.Parent.deepEqual) +local ElementKind = require(script.Parent.Parent.ElementKind) + +local SnapshotFolderName = "RoactSnapshots" +local SnapshotFolder = ReplicatedStorage:FindFirstChild(SnapshotFolderName) + +local Snapshot = {} +local SnapshotMetatable = { + __index = Snapshot, + __tostring = function(snapshot) + return Serialize.snapshotDataToString(snapshot.data) + end +} + +function Snapshot.new(identifier, data) + local snapshot = { + _identifier = identifier, + data = data, + _existingData = Snapshot._loadExistingData(identifier), + } + + setmetatable(snapshot, SnapshotMetatable) + + return snapshot +end + +function Snapshot:match() + if self._existingData == nil then + self:serialize() + self._existingData = self.data + return + end + + local areEqual, innerMessageTemplate = deepEqual(self.data, self._existingData) + + if areEqual then + return + end + + local innerMessage = innerMessageTemplate + :gsub("{1}", "new") + :gsub("{2}", "existing") + + local message = ("Snapshots do not match.\n%s"):format(innerMessage) + + error(message, 2) +end + +function Snapshot:serialize() + local folder = Snapshot.getSnapshotFolder() + + local existingData = folder:FindFirstChild(self._identifier) + + if not existingData then + existingData = Instance.new("StringValue") + existingData.Name = self._identifier + existingData.Parent = folder + end + + existingData.Value = Serialize.snapshotDataToString(self.data) + print('snapshot::::::') + print(existingData.Value) +end + +function Snapshot.getSnapshotFolder() + SnapshotFolder = ReplicatedStorage:FindFirstChild(SnapshotFolderName) + + if not SnapshotFolder then + SnapshotFolder = Instance.new("Folder") + SnapshotFolder.Name = SnapshotFolderName + SnapshotFolder.Parent = ReplicatedStorage + end + + return SnapshotFolder +end + +function Snapshot._loadExistingData(identifier) + local folder = Snapshot.getSnapshotFolder() + + local existingData = folder:FindFirstChild(identifier) + + if not (existingData and existingData:IsA("ModuleScript")) then + return nil + end + + local loadSnapshot = require(existingData) + + return loadSnapshot({ + Roact = require(script.Parent.Parent), + ElementKind = ElementKind, + AnonymousFunction = AnonymousFunction, + }) +end + +return Snapshot \ No newline at end of file diff --git a/src/snapshot/Snapshot.spec.lua b/src/snapshot/Snapshot.spec.lua new file mode 100644 index 00000000..2453f827 --- /dev/null +++ b/src/snapshot/Snapshot.spec.lua @@ -0,0 +1,132 @@ +return function() + local Snapshot = require(script.Parent.Snapshot) + + local ElementKind = require(script.Parent.Parent.ElementKind) + local createSpy = require(script.Parent.Parent.createSpy) + + local snapshotFolder = Instance.new("Folder") + local originalGetSnapshotFolder = Snapshot.getSnapshotFolder + Snapshot.getSnapshotFolder = function() + return snapshotFolder + end + + local originalLoadExistingData = Snapshot._loadExistingData + local loadExistingDataSpy = nil + + describe("match", function() + local snapshotMap = {} + + local function beforeTest() + snapshotMap = {} + + loadExistingDataSpy = createSpy(function(identifier) + return snapshotMap[identifier] + end) + Snapshot._loadExistingData = loadExistingDataSpy.value + end + + local function cleanTest() + loadExistingDataSpy = nil + Snapshot._loadExistingData = originalLoadExistingData + end + + it("should serialize the snapshot if no data is found", function() + beforeTest() + + local data = {} + + local serializeSpy = createSpy() + + local snapshot = Snapshot.new("foo", data) + snapshot.serialize = serializeSpy.value + + snapshot:match() + + cleanTest() + + serializeSpy:assertCalledWith(snapshot) + end) + + it("should not serialize if the snapshot already exist", function() + beforeTest() + + local data = {} + local identifier = "foo" + snapshotMap[identifier] = data + + local serializeSpy = createSpy() + + local snapshot = Snapshot.new(identifier, data) + snapshot.serialize = serializeSpy.value + + snapshot:match() + + cleanTest() + + expect(serializeSpy.callCount).to.equal(0) + end) + + it("should throw an error if the previous snapshot does not match", function() + beforeTest() + + local data = {} + local identifier = "foo" + snapshotMap[identifier] = { + Key = "Value" + } + + local serializeSpy = createSpy() + + local snapshot = Snapshot.new(identifier, data) + snapshot.serialize = serializeSpy.value + + local function shouldThrow() + snapshot:match() + end + + cleanTest() + + expect(shouldThrow).to.throw() + end) + end) + + describe("serialize", function() + it("should create a StringValue if it does not exist", function() + local identifier = "foo" + + local snapshot = Snapshot.new(identifier, { + type = { + kind = ElementKind.Function, + }, + hostKey = "HostKey", + props = {}, + children = {}, + }) + + snapshot:serialize() + local stringValue = snapshotFolder:FindFirstChild(identifier) + + expect(stringValue).to.be.ok() + expect(stringValue.Value:len() > 0).to.equal(true) + end) + end) + + describe("_loadExistingData", function() + it("should return nil if data is not found", function() + local result = Snapshot._loadExistingData("foo") + + expect(result).never.to.be.ok() + end) + end) + + describe("getSnapshotFolder", function() + it("should create a folder in the ReplicatedStorage if it is not found", function() + local folder = originalGetSnapshotFolder() + + expect(folder).to.be.ok() + expect(folder.Parent).to.equal(game:GetService("ReplicatedStorage")) + + folder:Destroy() + end) + end) +end \ No newline at end of file diff --git a/src/snapshot/init.lua b/src/snapshot/init.lua new file mode 100644 index 00000000..d8a568cf --- /dev/null +++ b/src/snapshot/init.lua @@ -0,0 +1,9 @@ +local Serialize = require(script.Serialize) +local Snapshot = require(script.Snapshot) + +return function(identifier, shallowWrapper) + local data = Serialize.wrapperToSnapshotData(shallowWrapper) + local snapshot = Snapshot.new(identifier, data) + + return snapshot +end