diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd088337..69edffeb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,6 +81,24 @@ When submitting a new feature, add tests for all functionality. We use [LuaCov](https://keplerproject.github.io/luacov) for keeping track of code coverage. We'd like it to be as close to 100% as possible, but it's not always possible. Adding tests just for the purpose of getting coverage isn't useful; we should strive to make only useful tests! +### Snapshots +As part of the test suite, there are tests that generates snapshot files (located under `./RoactSnapshots`). To sync back the generated StringValues produce by these on the file system, we suggest using a tool like [`run-in-roblox`](https://github.com/LPGhatguy/run-in-roblox) to make the workflow as simple as possible. In the case where you only need to sync a few files, you can use the plugin to automatically copy back the generated string values from the run mode into module scripts when going back to edit mode. Then simply copy-paste into the new snapshots into the file-system. + +#### Using run-in-roblox +You can install run-in-roblox using [cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) with the following command. +``` +cargo install run-in-roblox +``` +Once installed, run the [syncing script](bin/sync_snapshots.lua) to execute the tests and sync the created ModuleScripts from Roblox Studio to the file system. This script works by doing 3 tasks: +1. Build a test place with rojo +2. Use run-in-roblox to run [run-tests-snapshots.lua](bin/run-tests-snapshots.lua) in the place file +3. Parse the output of run-in-roblox to copy back the new snapshots to the file system + +Run this script using lua stand alone interpreter +``` +lua bin/run-tests-snapshots.lua +``` + ## Release Checklist When releasing a new version of Roact, do these things: diff --git a/RoactSnapshots/component-with-event-props.lua b/RoactSnapshots/component-with-event-props.lua new file mode 100644 index 00000000..f75ea95e --- /dev/null +++ b/RoactSnapshots/component-with-event-props.lua @@ -0,0 +1,19 @@ +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "TextButton", + }, + props = { + [Roact.Event.Activated] = Markers.AnonymousFunction, + [Roact.Event.MouseButton1Click] = Markers.AnonymousFunction, + [Roact.Change.AbsoluteSize] = Markers.AnonymousFunction, + [Roact.Change.Visible] = Markers.AnonymousFunction, + }, + children = {}, + } +end \ No newline at end of file diff --git a/RoactSnapshots/function-component-children.lua b/RoactSnapshots/function-component-children.lua new file mode 100644 index 00000000..65b1146d --- /dev/null +++ b/RoactSnapshots/function-component-children.lua @@ -0,0 +1,35 @@ +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "Frame", + }, + props = {}, + children = { + { + type = { + kind = ElementKind.Function, + }, + hostKey = "LabelA", + props = { + Text = "I am label A", + }, + children = {}, + }, + { + type = { + kind = ElementKind.Function, + }, + hostKey = "LabelB", + props = { + Text = "I am label B", + }, + children = {}, + }, + }, + } +end \ No newline at end of file diff --git a/RoactSnapshots/host-frame-with-multiple-props.lua b/RoactSnapshots/host-frame-with-multiple-props.lua new file mode 100644 index 00000000..7e1f6ff0 --- /dev/null +++ b/RoactSnapshots/host-frame-with-multiple-props.lua @@ -0,0 +1,23 @@ +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "Frame", + }, + props = { + AnchorPoint = Vector2.new(0, 0.5), + BackgroundColor3 = Color3.new(0.1, 0.2, 0.3), + BackgroundTransparency = 0.205, + ClipsDescendants = false, + Size = UDim2.new(0.5, 0, 0.4, 1), + SizeConstraint = Enum.SizeConstraint.RelativeXY, + Visible = true, + ZIndex = 5, + }, + children = {}, + } +end \ No newline at end of file diff --git a/RoactSnapshots/stateful-component-children.lua b/RoactSnapshots/stateful-component-children.lua new file mode 100644 index 00000000..c2e037ac --- /dev/null +++ b/RoactSnapshots/stateful-component-children.lua @@ -0,0 +1,28 @@ +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "Frame", + }, + props = {}, + children = { + { + type = { + kind = ElementKind.Stateful, + componentName = "CoolComponent", + }, + hostKey = "Child", + props = { + label = { + Text = "foo", + }, + }, + children = {}, + }, + }, + } +end \ No newline at end of file diff --git a/SnapshotsPlugin/EditModeMain.lua b/SnapshotsPlugin/EditModeMain.lua new file mode 100644 index 00000000..eb8c3d90 --- /dev/null +++ b/SnapshotsPlugin/EditModeMain.lua @@ -0,0 +1,41 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Settings = require(script.Parent.Settings) + +local function SyncSnapshots(newSnapshots) + local snapshotsFolder = ReplicatedStorage:FindFirstChild(Settings.SnapshotFolderName) + + if not snapshotsFolder then + snapshotsFolder = Instance.new("Folder") + snapshotsFolder.Name = Settings.SnapshotFolderName + snapshotsFolder.Parent = ReplicatedStorage + end + + for name, value in pairs(newSnapshots) do + local snapshot = Instance.new("ModuleScript") + snapshot.Name = name + snapshot.Source = value + snapshot.Parent = snapshotsFolder + end +end + +local function PluginEditMode(plugin) + local isPluginDeactivated = false + + plugin.Deactivation:Connect(function() + isPluginDeactivated = true + end) + + while not isPluginDeactivated do + local newSnapshots = plugin:GetSetting(Settings.PluginSettingName) + + if newSnapshots then + SyncSnapshots(newSnapshots) + plugin:SetSetting(Settings.PluginSettingName, false) + end + + wait(Settings.SyncDelay) + end +end + +return PluginEditMode \ No newline at end of file diff --git a/SnapshotsPlugin/RunModeMain.lua b/SnapshotsPlugin/RunModeMain.lua new file mode 100644 index 00000000..53e8b161 --- /dev/null +++ b/SnapshotsPlugin/RunModeMain.lua @@ -0,0 +1,25 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Settings = require(script.Parent.Settings) + +local function PluginRunMode(plugin) + plugin.Unloading:Connect(function() + local snapshotsFolder = ReplicatedStorage:FindFirstChild(Settings.SnapshotFolderName) + + local newSnapshots = {} + + if not snapshotsFolder then + return + end + + for _, snapshot in pairs(snapshotsFolder:GetChildren()) do + if snapshot:IsA("StringValue") then + newSnapshots[snapshot.Name] = snapshot.Value + end + end + + plugin:SetSetting(Settings.PluginSettingName, newSnapshots) + end) +end + +return PluginRunMode \ No newline at end of file diff --git a/SnapshotsPlugin/Settings.lua b/SnapshotsPlugin/Settings.lua new file mode 100644 index 00000000..8e28f25a --- /dev/null +++ b/SnapshotsPlugin/Settings.lua @@ -0,0 +1,17 @@ +local function IndexError(_, key) + local message = ("%q (%s) is not a valid member of Settings"):format( + tostring(key), + typeof(key) + ) + + error(message, 2) +end + +return setmetatable({ + SnapshotFolderName = "RoactSnapshots", + PluginSettingName = "NewRoactSnapshots", + SyncDelay = 1, +}, { + __index = IndexError, + __newindex = IndexError, +}) \ No newline at end of file diff --git a/SnapshotsPlugin/init.server.lua b/SnapshotsPlugin/init.server.lua new file mode 100644 index 00000000..101f2da0 --- /dev/null +++ b/SnapshotsPlugin/init.server.lua @@ -0,0 +1,12 @@ +local RunService = game:GetService("RunService") + +local EditModeMain = require(script.EditModeMain) +local RunModeMain = require(script.RunModeMain) + +if RunService:IsEdit() then + EditModeMain(plugin) +else + if RunService:IsClient() then + RunModeMain(plugin) + end +end \ No newline at end of file diff --git a/bin/run-tests-snapshots.lua b/bin/run-tests-snapshots.lua new file mode 100644 index 00000000..73dfc898 --- /dev/null +++ b/bin/run-tests-snapshots.lua @@ -0,0 +1,26 @@ +-- luacheck: globals __LEMUR__ + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Roact = require(ReplicatedStorage.Roact) +local TestEZ = require(ReplicatedStorage.TestEZ) + +Roact.setGlobalConfig({ + ["internalTypeChecks"] = true, + ["typeChecks"] = true, + ["elementTracing"] = true, + ["propValidation"] = true, +}) +local results = TestEZ.TestBootstrap:run(ReplicatedStorage.Roact, TestEZ.Reporters.TextReporter) + +local RoactSnapshots = ReplicatedStorage:WaitForChild("RoactSnapshots", 1) + +if not RoactSnapshots then + return nil +end + +for _, snapshot in pairs(RoactSnapshots:GetChildren()) do + if snapshot:IsA("StringValue") then + print(("Snapshot:::<|%s|><|=>%s<=|>"):format(snapshot.Name, snapshot.Value)) + end +end \ No newline at end of file diff --git a/bin/spec.lua b/bin/spec.lua index b6d0f792..d900be8d 100644 --- a/bin/spec.lua +++ b/bin/spec.lua @@ -6,6 +6,7 @@ local LOAD_MODULES = { {"src", "Roact"}, {"modules/testez/lib", "TestEZ"}, + {"RoactSnapshots", "RoactSnapshots"}, } -- This makes sure we can load Lemur and other libraries that depend on init.lua diff --git a/bin/sync_snapshots.lua b/bin/sync_snapshots.lua new file mode 100644 index 00000000..379b5435 --- /dev/null +++ b/bin/sync_snapshots.lua @@ -0,0 +1,64 @@ +local lfs = require("lfs") + +local ROJO_PROJECT_FILE = "place.project.json" +local TEST_FILE = "bin/run-tests-snapshots.lua" + +local SNAPSHOTS_FOLDER = "RoactSnapshots" +local TEST_PLACE_FILE_NAME = "temp-snapshot-place.rbxlx" + +local function writeSnapshotFile(name, content) + lfs.mkdir(SNAPSHOTS_FOLDER) + + local fileName = name .. ".lua" + local filePath = ("%s/%s"):format(SNAPSHOTS_FOLDER, fileName) + + print("Writing", filePath) + + local file = io.open(filePath, "w") + file:write(content) + file:close() +end + +local function executeCommand(command) + print(command) + local handle = io.popen(command) + local line = handle:read("*l") + local output = {line} + + while line do + line = handle:read("*l") + table.insert(output, line) + end + + handle:close() + + return table.concat(output, "\n") +end + +print("Building test place") +executeCommand(("rojo build %s -o %s"):format( + ROJO_PROJECT_FILE, + TEST_PLACE_FILE_NAME +)) + +print("Running run-in-roblox") +local output = executeCommand(("run-in-roblox %s -s %s -t 100"):format( + TEST_PLACE_FILE_NAME, + TEST_FILE +)) + +print("Clean test place") +os.remove(TEST_PLACE_FILE_NAME) + +print("Processing output...") + +local filteredOutput = output:gsub("\nSnapshot:::<|[%w_%-%.]+|><|=>.-<=|>", "") + +print(filteredOutput, "\n") + +for snapshotPattern in output:gmatch("Snapshot:::<|[%w_%-%.]+|><|=>.-<=|>") do + local name = snapshotPattern:match("Snapshot:::<|([%w_%-%.]+)|>") + local content = snapshotPattern:match("<|=>(.-)<=|>") + + writeSnapshotFile(name, content) +end \ No newline at end of file diff --git a/docs/advanced/shallow-rendering.md b/docs/advanced/shallow-rendering.md new file mode 100644 index 00000000..12a067d7 --- /dev/null +++ b/docs/advanced/shallow-rendering.md @@ -0,0 +1,455 @@ +To facilitate writing robust unit tests when using Roact, utilities are provided for shallow rendering and snapshotting components. Shallow rendering involves rendering 1 or more levels deep to assert facts about the rendered elements, allowing components to be tested as units. Snaphotting involves converting these shallow renders to serializable Lua modules for debugging purposes or to verify that components have not changed unexpectedly. + +## Shallow Wrapper + +When writing tests for Roact components, the `VirtualTree` returned by `Roact.mount` can provide a `ShallowWrapper` object with an interface designed to help make assertions about the expected behavior of a component. + +```lua +-- let's assume there is a ComponentToTest that we want to test + +local tree = Roact.mount(ComponentToTest) + +local shallowWrapper = tree:getShallowWrapper() +``` + +## Snapshot Testing + +A snapshot is a serializable representation of a shallow rendered Roact tree that can be serialized as a complete Lua `ModuleScript`. The snapshot can either be compared with a past snapshot written to `ReplicatedStorage` or returned as a string for debugging purposes. + +The goal of a snapshot test is to verify that the shallow render matches the version saved previously. If a snapshot does not match, then the component may have changed unexpectedly. Snapshot testing can be done through the `matchSnapshot` method on the `ShallowWrapper`. The single argument of `matchSnapshot` is the name of the `ModuleScript` in `ReplicatedStorage` to look for. + +```lua +shallowWrapper:matchSnapshot("ComponentToTest") +``` + +Here is a breakdown of how matching is performed: + +1. Check if there is an existing snapshot with the given name +2. If no snapshot exists, generate a new one from the `ShallowWrapper` and exit, else continue +3. Load the existing snapshot by calling require on the `ModuleScript` +4. Compare the existing snapshot with the one generated by the `ShallowWrapper` +5. If the snapshots match, exit, else save the new `ModuleScript` (useful for comparison) and throw an error + +!!! Note + Currently, the `ModuleScript` for new snapshots will be stored in a `StringValue`. The permission level where the test are ran does not make it possible to create a `ModuleScript` and assign its `Source` property. Tools like Roact's SnapshotsPlugin can copy the `StringValue` from Run mode to a `ModuleScript` in Edit mode. + +!!! Note + The scripts generated by `matchSnapshot` are not guaranteed to pass [luacheck](https://github.com/mpeterv/luacheck/) due to the potential for unused variables, so be careful not to run `luacheck` on them. +--- + +### Workflow Example + +For a concrete example, suppose the following component `ComponentToTest`. + +```lua +local function ComponentToTest(props) + return Roact.createElement("TextLabel", { + Text = "foo", + }) +end +``` + +A snapshot test could be written this way: + +```lua +it("should match the snapshot", function() + local element = Roact.createElement(ComponentToTest) + local tree = Roact.mount(element) + local shallowWrapper = tree:getShallowWrapper() + + shallowWrapper:matchSnapshot("ComponentToTest") +end) +``` + +After the first run, the test will fail and create a new script under `RoactSnapshots` in `ReplicatedStorage` called `ComponentToTest.NEW` that contains the following Lua code: + +```lua +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "TextLabel", + }, + props = { + Text = "foo", + }, + children = {}, + } +end +``` + +After reviewing the snapshot, it can be renamed to `ComponentToTest`. If the test is run again it will now pass since the serialized snapshot match the generated one. + +!!! Note + Since snapshot tests require the previous snapshots to compare with the current generated one, snapshots should be saved (if using Studio) or committed to version control (if using file system development). + +Suppose now `ComponentToTest` is updated as follows: + +```lua +local function ComponentToTest(props) + return Roact.createElement("TextLabel", { + Text = "bar", + }) +end +``` + +When the test is run again, it will fail because that the snapshots did not match. There will be a new script under `RoactSnapshots` called `ComponentToTest.NEW` that shows the new version of the snapshot. + +```lua +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "TextLabel", + }, + props = { + Text = "bar", + }, + children = {}, + } +end +``` + +Only the `Text` prop value changed, from *foo* to *bar*. Since these changes are expected from the modification made to the component, we can delete the old snapshot and remove the `.NEW` from the newest one. If the tests are run again, they will once again pass. + +Updated snapshots should be saved / committed along with the component changes to make it clear why the snapshot is being changed. + +Most snapshots will be more complex than this example and act as a powerful line of defense against unexpected changes to components. + +--- + +### Where They Are Good + +#### Regression + +Snapshot tests really shine when comes the time to test for regression. + +#### Carefully Reviewed + +Changes made to a snapshot file needs to be reviewed carefully as if it was hand written code. A reviewer needs to be able to catch any unexpected changes to a component. Any source control software should provide some way to see a diff of the changes that are going to be submitted. If a snapshot diff shows a difference in the color property for a change that is supposed to update sizing, the reviewer should verify that the change is intended. + +--- + +### Where They Are Bad + +#### Large Snapshots + +If a snapshot is created from a top level component with a ShallowWrapper that renders many levels deep, it can produce a large snapshot file with potentially hundreds of lines. The larger the snapshot, the more likely it is to fail due to a reason unrelated to the component being tested. + +Large snapshots also become an inconvenience and may not be reviewed correctly due to their size or frequency of needing an update, or both. + +To avoid this situation, it is important that each snapshot is kept as simple and as small as possible. That is why the `ShallowWrapper` is deeply linked with snapshot generation and defaults to rendering only 1 level deep. Render more deeply only as needed to exercise the component being tested. + +--- + +### Managing Snapshots + +When the tests are executed in Run mode (after Run is pressed), snapshots are serialized and saved as `StringValue` objects inside of `ReplicatedStorage.RoactSnapshots`. Pressing Stop to go back to Edit mode will delete any newly created snapshots values. Preserving the serialized snapshots and saving them as module scripts is necessary to ensure that there are snapshots to match with during future test runs. The method of preserving them varies based on the development environment being used. + +#### Roblox Studio + If using Roblox Studio for development, install the `RoactSnapshots` plugin, which will preserve the `StringValue` objects and save them as `ModuleScript` objects upon returning to Edit mode. + +--- + +#### File System + +If using a tool like `rojo` to sync files to Roblox Studio, a tool like `run-in-roblox` can help write the module scripts back to the file system. + +[`run-in-roblox`](https://github.com/LPGhatguy/run-in-roblox/) is a [`Rust`](https://www.rust-lang.org/) command line tool that runs Roblox Studio and sends content from the output to the shell window. Using this tool, a script can be written to open a place file and run it with a specific test runner that can print out the new snapshots in a special format. Then, the output can be parsed to find the new snapshots and write them to files. + +Here are examples of this kind of script written in Lua ([link](../scripts/sync-snapshots-with-lua.md)) and in python ([link](../scripts/sync-snapshots-with-python.md)) (compatible with version 2 and 3). These example scripts assume that the `rojo` and `run-in-roblox` commands are available. They build a place from a `rojo` configuration file, run a specific script inside studio, print the serialized snapshots, parse them from the output, and write them to the file system. + +--- + +### Wrapped Components + +Sometimes during testing, components need to be wrapped into other components that provides some of their props. When comes the time to make a snapshot of that components, it's preferable to snapshot it without the wrapping component. The main reason for this is to avoid snapshot test failure when there is a change in the wrapping component that does not affect the wrapped component. + +In the case where the component is wrapped in a host component (a component that render a Roblox instance), use ShallowWrapper's [`find`](/api-reference/#find) or [`findUnique`](/api-reference/#findunique) methods to get to your child component. Otherwise, increase the depth parameter to get deep enough so that the parent component is consumed. + +!!! Note + When making snapshot tests, depth is generally used more often than `find` or `findUnique`. Components may have multiple layer of elements nested: that will require adjusting the depth to render at least until the last element. The ShallowWrapper's methods that access its children are more used when working on generic components. These generic components may be a bit harder or tricky to test because they depend on other concrete components that get wrappred into them. + +--- + +#### Example With *find* Method + +Let's suppose `InFrame` is a component that accept a render prop. The render function is used to render an element inside a black background frame. The component will pass props that need to be set on the child element. + +```lua +local function InFrame(props) + return Roact.createElement("Frame", { + BackgroundColor3 = Color3.new(0, 0, 0), + }, { + Content = props.render({ + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 0), + Size = UDim2.new(1, 0, 1, 0), + }), + }) +end +``` + +Another component called `CoolComponent` is using this generic component to render a TextLabel inside a frame. + +```lua +local function CoolComponent() + return Roact.createElement(InFrame, { + render = function(props) + props.Text = "Hello" + return Roact.createElement("TextLabel", props) + end, + }) +end +``` + +When writting a snapshot test for `CoolComponent`, it is better to avoid having details about the `InFrame` component. The important things are to be aware of what could change in the relation between `InFrame` and `CoolComponent`. Any changes made to `InFrame` that does not impact `CoolComponent` should not break the snapshot. + +The first version of the test may look like the following snippet: + +```lua +local element = Roact.createElement(CoolComponent) + +local tree = Roact.mount(element) + +local wrapper = tree:getShallowWrapper() + +wrapper:matchSnapshot("CoolComponent") +``` + +That will produce the following snapshot: + +```lua +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Function, + }, + props = { + render = Markers.AnonymousFunction, + }, + children = { + { + type = { + kind = ElementKind.Host, + className = "Frame", + }, + hostKey = "RoactTree", + props = { + BackgroundColor3 = Color3.new(0, 0, 0), + }, + children = {}, + }, + }, + } +end +``` + +As you can see, this snapshot contains the `render` prop of `InFrame`. The information about the TextLabel is also absent, because the ShallowWrapper has a depth of one by default and the TextLabel is getting wrapped into a Frame. In order to get the TextLabel to show up in the snapshot, the depth is going to be bumped to 2. The updated test will look like: + +```lua +local element = Roact.createElement(CoolComponent) + +local tree = Roact.mount(element) + +local wrapper = tree:getShallowWrapper(2) + +wrapper:matchSnapshot("CoolComponent") +``` + +And the updated snapshot is going to contain data about the TextLabel. + +```lua +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "Frame", + }, + props = { + BackgroundColor3 = Color3.new(0, 0, 0), + }, + children = { + { + type = { + kind = ElementKind.Host, + className = "TextLabel", + }, + hostKey = "Content", + props = { + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 0), + Size = UDim2.new(1, 0, 1, 0), + Text = "Hello", + }, + children = {}, + }, + }, + } +end +``` + +Now, to remove the details about the `InFrame` component, the snapshot is not going to be generated from the top level ShallowWrapper, but instead from the child inside of it. + +```lua +local element = Roact.createElement(CoolComponent) + +local tree = Roact.mount(element) + +local wrapper = tree:getShallowWrapper(2) + +local coolWrapper = wrapper:findUnique() + +coolWrapper:matchSnapshot("CoolComponent") +``` + +Since the top level Frame contains only one child, we can use `findUnique` instead of `find`. This will produce the following snapshot: + +```lua +return function(dependencies) + local Roact = dependencies.Roact + local ElementKind = dependencies.ElementKind + local Markers = dependencies.Markers + + return { + type = { + kind = ElementKind.Host, + className = "TextLabel", + }, + props = { + BackgroundTransparency = 1, + Position = UDim2.new(0, 0, 0, 0), + Size = UDim2.new(1, 0, 1, 0), + Text = "Hello", + }, + children = {}, + } +end +``` + +The good thing now about this snapshot is that it will only fail when the contract between `InFrame` and `CoolComponent` changes. + +--- + +#### Example With Depth + +Let's say `CoolComponent` is using another component that wraps it with a render prop. The consumer is going to pass a table containing data about the style. For the purpose of this example, the props are minimal but it still shows the idea. + +```lua +local function CoolComponent(props) + return Roact.createElement(StyleConsumer, { + render = function(style) + return Roact.createElement("Frame", { + BackgroundColor3 = style.MainColor, + }) + end, + }) +end +``` + +Usually, this complexity is hidden behind a simple function like the following snippet. + +```lua +local function CoolComponent(props) + return withStyle(function(style) + return Roact.createElement("Frame", { + BackgroundColor3 = style.MainColor, + }, { + Label = Roact.createElement("TextLabel"), + }) + end) +end +``` + +We can write a test that is going to snapshot the `CoolComponent`. + +```lua +local element = Roact.createElement(CoolComponent) + +local tree = Roact.mount(element) +local wrapper = tree:getShallowWrapper() + +wrapper:matchSnapshot("CoolSnapshot") +``` + +And the generated snapshot looks like this: + +```lua +{ + type = { + kind = ElementKind.Function, + }, + props = { + render = Markers.AnonymousFunction, -- the render prop from the StyleConsumer + }, + children = { + { + type = { + kind = ElementKind.Host, + className = "Frame", + }, + hostKey = "RoactTree", + props = { + BackgroundColor3 = Color3.new(1, 1, 1), -- style.MainColor + }, + children = {}, + }, + }, +} +``` + +When writing the test to snapshot `CoolComponent`, you will want to use to avoid having details of the `StyleConsumer`. The reason is that if `StyleConsumer` changes, the test may fail without really changing the important part of our snapshot, which is the information about `CoolComponent`. + +Also, notice that there is no information about the `TextLabel`, because the method `getShallowWrapper` only returns a shallow version of the mounted tree. If we want to have access to the next depth of the tree, we need to pass 2 as the depth parameter. + +```lua +local element = Roact.createElement(CoolComponent) + +local tree = Roact.mount(element) +local wrapper = tree:getShallowWrapper(2) + +wrapper:matchSnapshot("CoolSnapshot") +``` + +We get the following new generated snapshot. + +```lua +{ + type = { + kind = ElementKind.Host, + className = "Frame", + }, + props = { + BackgroundColor3 = Color3.new(1, 1, 1), + }, + children = { + { + type = { + kind = ElementKind.Host, + className = "TextLabel", + }, + hostKey = "Label", + props = {}, + children = {}, + }, + }, +} +``` + +As you can see, the `StyleConsumer` disappeared. The wrapper returned by `getShallowWrapper` does not directly wrap the element tree. Instead, it wraps elements that are rendered as Roblox instances as deep as it can. Since, the test now allow the wrapper to go deeper, it will get rid of the `StyleConsumer` because it does not render to a Roblox instance. diff --git a/docs/api-reference.md b/docs/api-reference.md index a51216ca..9434db24 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -32,7 +32,7 @@ Creates a new Roact fragment with the provided table of elements. Fragments allo ### Roact.mount ``` -Roact.mount(element, [parent, [key]]) -> RoactTree +Roact.mount(element, [parent, [key]]) -> VirtualTree ``` !!! info @@ -40,13 +40,13 @@ Roact.mount(element, [parent, [key]]) -> RoactTree Creates a Roblox Instance given a Roact element, and optionally a `parent` to put it in, and a `key` to use as the instance's `Name`. -The result is a `RoactTree`, which is an opaque handle that represents a tree of components owned by Roact. You can pass this to APIs like `Roact.unmount`. It'll also be used for future debugging APIs. +The result is a `VirtualTree`, which is an opaque handle that represents a tree of components owned by Roact. You can pass this to APIs like `Roact.unmount`. It'll also be used for future debugging APIs. --- ### Roact.update ``` -Roact.update(tree, element) -> RoactTree +Roact.update(tree, element) -> VirtualTree ``` !!! info @@ -56,7 +56,7 @@ Updates an existing instance handle with a new element, returning a new handle. `update` can be used to change the props of a component instance created with `mount` and is useful for putting Roact content into non-Roact applications. -As of Roact 1.0, the returned `RoactTree` object will always be the same value as the one passed in. +As of Roact 1.0, the returned `VirtualTree` object will always be the same value as the one passed in. --- @@ -68,7 +68,7 @@ Roact.unmount(tree) -> void !!! info `Roact.unmount` is also available via the deprecated alias `Roact.teardown`. It will be removed in a future release. -Destroys the given `RoactTree` and all of its descendants. Does not operate on a Roblox Instance -- this must be given a handle that was returned by `Roact.mount`. +Destroys the given `VirtualTree` and all of its descendants. Does not operate on a Roblox Instance -- this must be given a handle that was returned by `Roact.mount`. --- @@ -608,4 +608,136 @@ end As with `setState`, you can set use the constant `Roact.None` to remove a field from the state. !!! note - `getDerivedStateFromProps` is a *static* lifecycle method. It does not have access to `self`, and must be a pure function. \ No newline at end of file + `getDerivedStateFromProps` is a *static* lifecycle method. It does not have access to `self`, and must be a pure function. + +--- + +## ShallowWrapper + +### Fields + +#### component +The component field can be a string, a function or a table depending of the kind of component. For example, if the ShallowWrapper comes from a host component, the component field will contain the class name of the host object created. The following table summarize the different cases. + +| field type | kind | description | +| --- | --- | --- | +| string | Host | the ClassName of the instance | +| function | Function | the function that renders the element | +| table | Stateful | the class-like table used to render the element | + +--- + +#### children +The list of children components wrapped into ShallowWrappers. + +--- + +#### props +The props of the ShallowWrapper. + +!!! note + This dictionary will not contain the `Roact.Children` prop. To obtain the children elements wrapped into a ShallowWrapper, use the `children` field. + +--- + +#### hostKey +The `hostKey` that is used to map the element to it's parent. + +--- + +### Methods + +#### find +``` +find([constraints]) -> list[ShallowWrapper] +``` +When a dictionary of constraints is provided, the function will filter the children that do not satisfy all given constraints. Otherwise, it returns the `children` field that contains all children wrapped into ShallowWrappers. + +--- + +##### Constraints +Constraints are passed through a dictionary that maps a constraint name to it's value. + +| name | value type | description | +| --- | --- | --- | +| className | string | Filters children that renders to host instance of the given class name | +| component | string, function or table | Filter children from their components, by finding the functional component original function, the sub-class table of Roact.Component or from the class name of the instance rendered | +| props | dictionary | Filters elements that contains at least the given set of props | +| hostKey | string | Filters elements by the host key used | + +--- + +#### findUnique +``` +findUnique([constraints]) -> ShallowWrapper +``` +Similar to `find`, this method will assert that only one child satisfies the given constraints, or in the case where none is provided, will assert that there is simply only one child. + +--- + +#### getHostObject +``` +getHostObject() -> Instance or nil +``` +Returns the instance object associated with the ShallowWrapper. It can return `nil` if the component wrapped by the ShallowWrapper does not render an instance, but rather another component. Here is an example: + +```lua +local function CoolComponent(props) + return Roact.createElement("TextLabel") +end + +local function MainComponent(props) + return Roact.createElement("Frame", {}, { + Cool = Roact.createElement(CoolComponent), + }) +end +``` + +If we shallow-render the `MainComponent`, the default depth will not render the CoolComponent. + +```lua +local element = Roact.createElement(MainComponent) + +local tree = Roact.mount(element) + +local wrapper = tree:getShallowWrapper() + +print(wrapper:getHostObject() == nil) -- prints false + +local coolWrapper = wrapper:findUnique() + +print(coolWrapper:getHostObject() == nil) -- prints true +``` + +--- + +#### matchSnapshot +``` +matchSnapshot(identifier) +``` +When an existing snapshot with the given identifier is found (a ModuleScript named as the provided identifier), it will require the ModuleScript and load the data from it. Then, if the loaded data is different from the current ShallowWrapper, an error will be thrown. If no previous snapshot exists, it will throw error. + +When an error is thrown, a StringValue instance is created with the given identifier followed by *.NEW*. Its Value property will contain Lua code representing the current ShallowWrapper. + +!!! note + As mentionned, `matchSnapshot` may create a new StringValue, named like the given identifier, in which the generated lua code will be assigned to the Value property. When these values are generated in Studio during run mode, it's important to copy back the values and convert them into ModuleScripts. + +--- + +#### toSnapshotString +``` +toSnapshotString() -> string +``` +Returns the string source of the snapshot. Useful for debugging purposes. + +--- + +## VirtualTree + +### Methods + +#### getShallowWrapper +``` +getShallowWrapper(depth: number = 1) -> ShallowWrapper +``` +Wraps the current tree into a ShallowWrapper. The depth parameter is optional and 1 by default. For more information about depth, visit the [shallow rendering page](/advanced/shallow-rendering#example-with-depth). diff --git a/docs/scripts/sync-snapshots-with-lua.md b/docs/scripts/sync-snapshots-with-lua.md new file mode 100644 index 00000000..62a8119e --- /dev/null +++ b/docs/scripts/sync-snapshots-with-lua.md @@ -0,0 +1,102 @@ +Do not forget to adjust some of the variables to fit your project. + +!!! Warning + This script assume that `rojo` and `run-in-roblox` commands are available. Make sure that you have installed these two programs before executing this script. + + You will also need to modify the script that boot the tests run to print the new snapshots to the output. It can be done by adding these few lines after tests are run. + ```lua + --- add the following code after `TestEZ.TestBootstrap:run(...)` + local RoactSnapshots = ReplicatedStorage:WaitForChild("RoactSnapshots", 1) + + if not RoactSnapshots then + return nil + end + + for _, snapshot in pairs(RoactSnapshots:GetChildren()) do + if snapshot:IsA("StringValue") then + print(("Snapshot:::<|%s|><|=>%s<=|>"):format(snapshot.Name, snapshot.Value)) + end + end + ``` + +--- + +Using Lua stand alone interpreter, run the following script with the following command. +``` +lua path-to-file.lua +``` + +--- + +```lua +local lfs = require("lfs") + +-- the rojo configuration file used to build a roblox place where tests +-- are going to be run +local ROJO_PROJECT_FILE = "default.project.json" +-- the lua file that is going to be run inside the test place +-- this script needs to print the snapshots to the output +local TEST_FILE = "bin/print-snapshots.lua" + +-- the folder that contains the snapshots on the file system +local SNAPSHOTS_FOLDER = "RoactSnapshots" +-- the temporary file that will be created +local TEST_PLACE_FILE_NAME = "temp-snapshot-place.rbxlx" + +local function writeSnapshotFile(name, content) + lfs.mkdir(SNAPSHOTS_FOLDER) + + local fileName = name .. ".lua" + local filePath = ("%s/%s"):format(SNAPSHOTS_FOLDER, fileName) + + print("Writing", filePath) + + local file = io.open(filePath, "w") + file:write(content) + file:close() +end + +local function executeCommand(command) + print(command) + local handle = io.popen(command) + local line = handle:read("*l") + local output = {line} + + while line do + line = handle:read("*l") + table.insert(output, line) + end + + handle:close() + + return table.concat(output, "\n") +end + +print("Building test place") +executeCommand(("rojo build %s -o %s"):format( + ROJO_PROJECT_FILE, + TEST_PLACE_FILE_NAME +)) + +print("Running run-in-roblox") +local output = executeCommand(("run-in-roblox %s -s %s -t 60"):format( + TEST_PLACE_FILE_NAME, + TEST_FILE +)) + +print("Clean test place") +os.remove(TEST_PLACE_FILE_NAME) + +print("Processing output...") + +local filteredOutput = output:gsub("\nSnapshot:::<|[%w_%-%.]+|><|=>.-<=|>", "") + +print(filteredOutput, "\n") + +for snapshotPattern in output:gmatch("Snapshot:::<|[%w_%-%.]+|><|=>.-<=|>") do + local name = snapshotPattern:match("Snapshot:::<|([%w_%-%.]+)|>") + local content = snapshotPattern:match("<|=>(.-)<=|>") + + writeSnapshotFile(name, content) +end +``` \ No newline at end of file diff --git a/docs/scripts/sync-snapshots-with-python.md b/docs/scripts/sync-snapshots-with-python.md new file mode 100644 index 00000000..214afbe3 --- /dev/null +++ b/docs/scripts/sync-snapshots-with-python.md @@ -0,0 +1,94 @@ +Do not forget to adjust some of the variables to fit your project. + +!!! Warning + This script assume that `rojo` and `run-in-roblox` commands are available. Make sure that you have installed these two programs before executing this script. + + You will also need to modify the script that boot the tests run to print the new snapshots to the output. It can be done by adding these few lines after tests are run. + ```lua + --- add the following code after `TestEZ.TestBootstrap:run(...)` + local RoactSnapshots = ReplicatedStorage:WaitForChild("RoactSnapshots", 1) + + if not RoactSnapshots then + return nil + end + + for _, snapshot in pairs(RoactSnapshots:GetChildren()) do + if snapshot:IsA("StringValue") then + print(("Snapshot:::<|%s|><|=>%s<=|>"):format(snapshot.Name, snapshot.Value)) + end + end + ``` + +--- + +Using a python interpreter (compatible with 2 or 3), put the following script in a file and run it. +``` +python path-to-file.py +``` + +```python +import os +import re +import subprocess + +# the rojo configuration file used to build a roblox place where tests +# are going to be run +ROJO_PROJECT_FILE = 'place.project.json' +# the lua file that is going to be run inside the test place +# this script needs to print the snapshots to the output +TEST_FILE = 'bin/print-snapshots.lua' + +# the folder that contains the snapshots on the file system +SNAPSHOTS_FOLDER = 'RoactSnapshots' +# the temporary file that will be created +TEST_PLACE_FILE_NAME = 'temp-snapshot-place.rbxlx' + +PATTERN_STR = 'Snapshot:::<\|(?P[\w\.\-]+)\|><\|=>(?P.+?)<=\|>\n' + +pattern = re.compile(PATTERN_STR, re.MULTILINE | re.DOTALL) + + +def write_snapshot_file(name, content): + if not os.path.exists(SNAPSHOTS_FOLDER): + os.mkdir(SNAPSHOTS_FOLDER) + + file_name = name + '.lua' + file_path = os.path.join(SNAPSHOTS_FOLDER, file_name) + + print('Writing ' + file_path) + + with open(file_path, 'w') as snapshot_file: + snapshot_file.write(content) + + +def execute_command(command): + print(' '.join(command)) + return subprocess.check_output(command).decode('utf-8') + + +rojo_output = execute_command([ + 'rojo', + 'build', + ROJO_PROJECT_FILE, + '-o', + TEST_PLACE_FILE_NAME, +]) + +print(rojo_output) + +output = execute_command([ + 'run-in-roblox', + TEST_PLACE_FILE_NAME, + '-s', + TEST_FILE, + '-t', + '60', +]) + +print(pattern.sub('', output)) + +for match in pattern.finditer(output): + name = match.group('name') + content = match.group('content') + write_snapshot_file(name, content) +``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 94430efe..0dc51fc7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,7 @@ nav: - Portals: advanced/portals.md - Bindings and Refs: advanced/bindings-and-refs.md - Context: advanced/context.md + - Shallow Rendering: advanced/shallow-rendering.md - Performance Optimization: - Overview: performance/overview.md - Reduce Reconcilation: performance/reduce-reconciliation.md diff --git a/place.project.json b/place.project.json index 7807792f..4918f292 100644 --- a/place.project.json +++ b/place.project.json @@ -8,6 +8,10 @@ "Roact": { "$path": "src" + }, + + "RoactSnapshots": { + "$path": "RoactSnapshots" }, "TestEZ": { diff --git a/snapshots-plugin.project.json b/snapshots-plugin.project.json new file mode 100644 index 00000000..843703c6 --- /dev/null +++ b/snapshots-plugin.project.json @@ -0,0 +1,6 @@ +{ + "name": "Snapshots Plugin", + "tree": { + "$path": "SnapshotsPlugin" + } + } \ No newline at end of file diff --git a/src/Component.spec/setState.spec.lua b/src/Component.spec/setState.spec.lua index 88ae9f5d..b6b6865b 100644 --- a/src/Component.spec/setState.spec.lua +++ b/src/Component.spec/setState.spec.lua @@ -4,11 +4,20 @@ return function() local createSpy = require(script.Parent.Parent.createSpy) local None = require(script.Parent.Parent.None) local NoopRenderer = require(script.Parent.Parent.NoopRenderer) + local VirtualTree = require(script.Parent.Parent.VirtualTree) local Component = require(script.Parent.Parent.Component) local noopReconciler = createReconciler(NoopRenderer) + local function mountWithNoop(element, hostParent, hostKey) + return VirtualTree.mountWithOptions(element, { + hostParent = hostParent, + hostKey = hostKey, + reconciler = noopReconciler + }) + end + describe("setState", function() it("should not trigger an extra update when called in init", function() local renderCount = 0 @@ -35,7 +44,7 @@ return function() local initElement = createElement(InitComponent) - noopReconciler.mountVirtualTree(initElement) + mountWithNoop(initElement) expect(renderCount).to.equal(1) expect(updateCount).to.equal(0) @@ -53,7 +62,7 @@ return function() local renderElement = createElement(TestComponent) - local success, result = pcall(noopReconciler.mountVirtualTree, renderElement) + local success, result = pcall(mountWithNoop, renderElement) expect(success).to.equal(false) expect(result:match("render")).to.be.ok() @@ -76,9 +85,9 @@ return function() local initialElement = createElement(TestComponent) local updatedElement = createElement(TestComponent) - local tree = noopReconciler.mountVirtualTree(initialElement) + local tree = mountWithNoop(initialElement) - local success, result = pcall(noopReconciler.updateVirtualTree, tree, updatedElement) + local success, result = pcall(VirtualTree.update, tree, updatedElement) expect(success).to.equal(false) expect(result:match("shouldUpdate")).to.be.ok() @@ -100,9 +109,9 @@ return function() local initialElement = createElement(TestComponent) local updatedElement = createElement(TestComponent) - local tree = noopReconciler.mountVirtualTree(initialElement) + local tree = mountWithNoop(initialElement) - local success, result = pcall(noopReconciler.updateVirtualTree, tree, updatedElement) + local success, result = pcall(VirtualTree.update, tree, updatedElement) expect(success).to.equal(false) expect(result:match("willUpdate")).to.be.ok() @@ -123,9 +132,9 @@ return function() end local element = createElement(TestComponent) - local tree = noopReconciler.mountVirtualTree(element) + local tree = mountWithNoop(element) - local success, result = pcall(noopReconciler.unmountVirtualTree, tree) + local success, result = pcall(VirtualTree.unmount, tree) expect(success).to.equal(false) expect(result:match("willUnmount")).to.be.ok() diff --git a/src/PureComponent.spec.lua b/src/PureComponent.spec.lua index b1644373..0ec17d0c 100644 --- a/src/PureComponent.spec.lua +++ b/src/PureComponent.spec.lua @@ -2,6 +2,7 @@ return function() local createElement = require(script.Parent.createElement) local NoopRenderer = require(script.Parent.NoopRenderer) local createReconciler = require(script.Parent.createReconciler) + local VirtualTree = require(script.Parent.VirtualTree) local PureComponent = require(script.Parent.PureComponent) @@ -50,7 +51,9 @@ return function() end local element = createElement(PureContainer) - local tree = noopReconciler.mountVirtualTree(element, nil, "PureComponent Tree") + local tree = VirtualTree.mountWithOptions(element, { + reconciler = noopReconciler + }) expect(updateCount).to.equal(0) @@ -70,6 +73,6 @@ return function() expect(updateCount).to.equal(3) - noopReconciler.unmountVirtualTree(tree) + VirtualTree.unmount(tree) end) end \ No newline at end of file diff --git a/src/VirtualTree.lua b/src/VirtualTree.lua new file mode 100644 index 00000000..1cb4e262 --- /dev/null +++ b/src/VirtualTree.lua @@ -0,0 +1,93 @@ +local createReconciler = require(script.Parent.createReconciler) +local RobloxRenderer = require(script.Parent.RobloxRenderer) +local shallow = require(script.Parent.shallow) +local Symbol = require(script.Parent.Symbol) +local Type = require(script.Parent.Type) + +local config = require(script.Parent.GlobalConfig).get() + +local DEFAULT_RECONCILER = createReconciler(RobloxRenderer) + +local InternalData = Symbol.named("InternalData") + +local VirtualTree = {} +local VirtualTreePublic = {} +VirtualTreePublic.__index = VirtualTreePublic + +function VirtualTree.mountWithOptions(element, options) + options = options or {} + local hostParent = options.hostParent + local hostKey = options.hostKey or "RoactTree" + local reconciler = options.reconciler or DEFAULT_RECONCILER + + if config.typeChecks then + assert(Type.of(element) == Type.Element, "Expected arg #1 to be of type Element") + assert(reconciler.isHostObject(hostParent) or hostParent == nil, "Expected arg #2 to be a host object") + end + + local rootNode = reconciler.mountVirtualNode(element, hostParent, hostKey) + + local tree = { + [Type] = Type.VirtualTree, + [InternalData] = { + rootNode = rootNode, + mounted = true, + reconciler = reconciler, + }, + } + + setmetatable(tree, VirtualTreePublic) + + return tree +end + +function VirtualTree.mount(element, hostParent, hostKey) + return VirtualTree.mountWithOptions(element, { + hostParent = hostParent, + hostKey = hostKey, + }) +end + +function VirtualTree.update(tree, newElement) + local internalData = tree[InternalData] + + if config.typeChecks then + assert(Type.of(tree) == Type.VirtualTree, "Expected arg #1 to be a Roact handle") + assert(Type.of(newElement) == Type.Element, "Expected arg #2 to be a Roact Element") + assert(internalData.mounted, "Cannot updated a Roact tree that has been unmounted") + end + + local reconciler = internalData.reconciler + + internalData.rootNode = reconciler.updateVirtualNode(internalData.rootNode, newElement) + + return tree +end + +function VirtualTree.unmount(tree) + local internalData = tree[InternalData] + + if config.typeChecks then + assert(Type.of(tree) == Type.VirtualTree, "Expected arg #1 to be a Roact handle") + assert(internalData.mounted, "Cannot unmounted a Roact tree that has already been unmounted") + end + + internalData.mounted = false + + if internalData.rootNode ~= nil then + local reconciler = internalData.reconciler + + reconciler.unmountVirtualNode(internalData.rootNode) + end +end + +function VirtualTreePublic:getShallowWrapper(depth) + assert(Type.of(self) == Type.VirtualTree, "Expected method getShallowWrapper to be called with `:`") + + local internalData = self[InternalData] + local rootNode = internalData.rootNode + + return shallow(rootNode, depth) +end + +return VirtualTree \ No newline at end of file diff --git a/src/VirtualTree.spec.lua b/src/VirtualTree.spec.lua new file mode 100644 index 00000000..964e4258 --- /dev/null +++ b/src/VirtualTree.spec.lua @@ -0,0 +1,47 @@ +return function() + local createElement = require(script.Parent.createElement) + local createReconciler = require(script.Parent.createReconciler) + local NoopRenderer = require(script.Parent.NoopRenderer) + local VirtualTree = require(script.Parent.VirtualTree) + + local noopReconciler = createReconciler(NoopRenderer) + + describe("tree operations", function() + it("should mount and unmount", function() + local tree = VirtualTree.mountWithOptions(createElement("StringValue"), { + reconciler = noopReconciler, + }) + + expect(tree).to.be.ok() + + VirtualTree.unmount(tree) + end) + + it("should mount, update, and unmount", function() + local tree = VirtualTree.mountWithOptions(createElement("StringValue"), { + reconciler = noopReconciler, + }) + + expect(tree).to.be.ok() + + VirtualTree.update(tree, createElement("StringValue")) + + VirtualTree.unmount(tree) + end) + end) + + describe("getShallowWrapper", function() + it("should return a shallow wrapper", function() + local tree = VirtualTree.mountWithOptions(createElement("StringValue"), { + reconciler = noopReconciler, + }) + + expect(tree).to.be.ok() + + local wrapper = tree:getShallowWrapper() + + expect(wrapper).to.be.ok() + expect(wrapper.component).to.equal("StringValue") + end) + end) +end \ No newline at end of file diff --git a/src/assertDeepEqual.lua b/src/assertDeepEqual.lua index 3f422d85..2fbda552 100644 --- a/src/assertDeepEqual.lua +++ b/src/assertDeepEqual.lua @@ -5,56 +5,7 @@ This should only be used in tests. ]] - -local function deepEqual(a, b) - if typeof(a) ~= typeof(b) then - local message = ("{1} is of type %s, but {2} is of type %s"):format( - typeof(a), - typeof(b) - ) - return false, message - end - - if typeof(a) == "table" then - local visitedKeys = {} - - for key, value in pairs(a) do - visitedKeys[key] = true - - local success, innerMessage = deepEqual(value, b[key]) - if not success then - local message = innerMessage - :gsub("{1}", ("{1}[%s]"):format(tostring(key))) - :gsub("{2}", ("{2}[%s]"):format(tostring(key))) - - return false, message - end - end - - for key, value in pairs(b) do - if not visitedKeys[key] then - local success, innerMessage = deepEqual(value, a[key]) - - if not success then - local message = innerMessage - :gsub("{1}", ("{1}[%s]"):format(tostring(key))) - :gsub("{2}", ("{2}[%s]"):format(tostring(key))) - - return false, message - end - end - end - - return true - end - - if a == b then - return true - end - - local message = "{1} ~= {2}" - return false, message -end +local deepEqual = require(script.Parent.deepEqual) local function assertDeepEqual(a, b) local success, innerMessageTemplate = deepEqual(a, b) diff --git a/src/assertDeepEqual.spec.lua b/src/assertDeepEqual.spec.lua index 484eeffd..b7610137 100644 --- a/src/assertDeepEqual.spec.lua +++ b/src/assertDeepEqual.spec.lua @@ -1,7 +1,12 @@ return function() local assertDeepEqual = require(script.Parent.assertDeepEqual) - it("should fail with a message when args are not equal", function() + it("should not throw if the args are equal", function() + assertDeepEqual(1, 1) + assertDeepEqual("hello", "hello") + end) + + it("should throw and format the error message when args are not equal", function() local success, message = pcall(assertDeepEqual, 1, 2) expect(success).to.equal(false) @@ -15,85 +20,7 @@ return function() expect(success).to.equal(false) expect(message:find("first%[foo%] ~= second%[foo%]")).to.be.ok() - end) - - it("should compare non-table values using standard '==' equality", function() - assertDeepEqual(1, 1) - assertDeepEqual("hello", "hello") - assertDeepEqual(nil, nil) - - local someFunction = function() end - local theSameFunction = someFunction - - assertDeepEqual(someFunction, theSameFunction) - - local A = { - foo = someFunction - } - local B = { - foo = theSameFunction - } - - assertDeepEqual(A, B) - end) - - it("should fail when types differ", function() - local success, message = pcall(assertDeepEqual, 1, "1") - - expect(success).to.equal(false) - expect(message:find("first is of type number, but second is of type string")).to.be.ok() - end) - - it("should compare (and report about) nested tables", function() - local A = { - foo = "bar", - nested = { - foo = 1, - bar = 2, - } - } - local B = { - foo = "bar", - nested = { - foo = 1, - bar = 2, - } - } - - assertDeepEqual(A, B) - - local C = { - foo = "bar", - nested = { - foo = 1, - bar = 3, - } - } - - local success, message = pcall(assertDeepEqual, A, C) - - expect(success).to.equal(false) - expect(message:find("first%[nested%]%[bar%] ~= second%[nested%]%[bar%]")).to.be.ok() - end) - - it("should be commutative", function() - local equalArgsA = { - foo = "bar", - hello = "world", - } - local equalArgsB = { - foo = "bar", - hello = "world", - } - - assertDeepEqual(equalArgsA, equalArgsB) - assertDeepEqual(equalArgsB, equalArgsA) - - local nonEqualArgs = { - foo = "bar", - } - - expect(function() assertDeepEqual(equalArgsA, nonEqualArgs) end).to.throw() - expect(function() assertDeepEqual(nonEqualArgs, equalArgsA) end).to.throw() + expect(message:find("{1}")).never.to.be.ok() + expect(message:find("{2}")).never.to.be.ok() end) end \ No newline at end of file diff --git a/src/createReconciler.lua b/src/createReconciler.lua index fbae970d..69316204 100644 --- a/src/createReconciler.lua +++ b/src/createReconciler.lua @@ -2,13 +2,10 @@ local Type = require(script.Parent.Type) local ElementKind = require(script.Parent.ElementKind) local ElementUtils = require(script.Parent.ElementUtils) local Children = require(script.Parent.PropMarkers.Children) -local Symbol = require(script.Parent.Symbol) local internalAssert = require(script.Parent.internalAssert) local config = require(script.Parent.GlobalConfig).get() -local InternalData = Symbol.named("InternalData") - --[[ The reconciler is the mechanism in Roact that constructs the virtual tree that later gets turned into concrete objects by the renderer. @@ -343,78 +340,10 @@ local function createReconciler(renderer) return virtualNode end - --[[ - Constructs a new Roact virtual tree, constructs a root node for - it, and mounts it. - ]] - local function mountVirtualTree(element, hostParent, hostKey) - if config.typeChecks then - assert(Type.of(element) == Type.Element, "Expected arg #1 to be of type Element") - assert(renderer.isHostObject(hostParent) or hostParent == nil, "Expected arg #2 to be a host object") - end - - if hostKey == nil then - hostKey = "RoactTree" - end - - local tree = { - [Type] = Type.VirtualTree, - [InternalData] = { - -- The root node of the tree, which starts into the hierarchy of - -- Roact component instances. - rootNode = nil, - mounted = true, - }, - } - - tree[InternalData].rootNode = mountVirtualNode(element, hostParent, hostKey) - - return tree - end - - --[[ - Unmounts the virtual tree, freeing all of its resources. - - No further operations should be done on the tree after it's been - unmounted, as indicated by its the `mounted` field. - ]] - local function unmountVirtualTree(tree) - local internalData = tree[InternalData] - if config.typeChecks then - assert(Type.of(tree) == Type.VirtualTree, "Expected arg #1 to be a Roact handle") - assert(internalData.mounted, "Cannot unmounted a Roact tree that has already been unmounted") - end - - internalData.mounted = false - - if internalData.rootNode ~= nil then - unmountVirtualNode(internalData.rootNode) - end - end - - --[[ - Utility method for updating the root node of a virtual tree given a new - element. - ]] - local function updateVirtualTree(tree, newElement) - local internalData = tree[InternalData] - if config.typeChecks then - assert(Type.of(tree) == Type.VirtualTree, "Expected arg #1 to be a Roact handle") - assert(Type.of(newElement) == Type.Element, "Expected arg #2 to be a Roact Element") - end - - internalData.rootNode = updateVirtualNode(internalData.rootNode, newElement) - - return tree - end - reconciler = { - mountVirtualTree = mountVirtualTree, - unmountVirtualTree = unmountVirtualTree, - updateVirtualTree = updateVirtualTree, - createVirtualNode = createVirtualNode, mountVirtualNode = mountVirtualNode, + isHostObject = renderer.isHostObject, unmountVirtualNode = unmountVirtualNode, updateVirtualNode = updateVirtualNode, updateVirtualNodeWithChildren = updateVirtualNodeWithChildren, diff --git a/src/createReconciler.spec.lua b/src/createReconciler.spec.lua index 193dd256..0739295f 100644 --- a/src/createReconciler.spec.lua +++ b/src/createReconciler.spec.lua @@ -11,26 +11,6 @@ return function() local noopReconciler = createReconciler(NoopRenderer) - describe("tree operations", function() - it("should mount and unmount", function() - local tree = noopReconciler.mountVirtualTree(createElement("StringValue")) - - expect(tree).to.be.ok() - - noopReconciler.unmountVirtualTree(tree) - end) - - it("should mount, update, and unmount", function() - local tree = noopReconciler.mountVirtualTree(createElement("StringValue")) - - expect(tree).to.be.ok() - - noopReconciler.updateVirtualTree(tree, createElement("StringValue")) - - noopReconciler.unmountVirtualTree(tree) - end) - end) - describe("booleans", function() it("should mount booleans as nil", function() local node = noopReconciler.mountVirtualNode(false, nil, "test") diff --git a/src/createReconcilerCompat.lua b/src/createReconcilerCompat.lua index e79cf5ac..b31865e8 100644 --- a/src/createReconcilerCompat.lua +++ b/src/createReconcilerCompat.lua @@ -20,25 +20,25 @@ Roact.reconcile has been renamed to Roact.update and will be removed in a future Check the call to Roact.reconcile at: ]] -local function createReconcilerCompat(reconciler) +local function createReconcilerCompat(virtualTree) local compat = {} function compat.reify(...) Logging.warnOnce(reifyMessage) - return reconciler.mountVirtualTree(...) + return virtualTree.mount(...) end function compat.teardown(...) Logging.warnOnce(teardownMessage) - return reconciler.unmountVirtualTree(...) + return virtualTree.unmount(...) end function compat.reconcile(...) Logging.warnOnce(reconcileMessage) - return reconciler.updateVirtualTree(...) + return virtualTree.update(...) end return compat diff --git a/src/createReconcilerCompat.spec.lua b/src/createReconcilerCompat.spec.lua index ea4d0789..4a45bceb 100644 --- a/src/createReconcilerCompat.spec.lua +++ b/src/createReconcilerCompat.spec.lua @@ -3,11 +3,25 @@ return function() local createReconciler = require(script.Parent.createReconciler) local Logging = require(script.Parent.Logging) local NoopRenderer = require(script.Parent.NoopRenderer) + local VirtualTree = require(script.Parent.VirtualTree) local createReconcilerCompat = require(script.Parent.createReconcilerCompat) local noopReconciler = createReconciler(NoopRenderer) - local compatReconciler = createReconcilerCompat(noopReconciler) + + local function mountWithNoop(element, hostParent, hostKey) + return VirtualTree.mountWithOptions(element, { + hostParent = hostParent, + hostKey = hostKey, + reconciler = noopReconciler + }) + end + + local compatReconciler = createReconcilerCompat({ + mount = mountWithNoop, + unmount = VirtualTree.unmount, + update = VirtualTree.update, + }) it("reify should only warn once per call site", function() local logInfo = Logging.capture(function() @@ -15,7 +29,7 @@ return function() -- warning hopefully. for _ = 1, 2 do local handle = compatReconciler.reify(createElement("StringValue")) - noopReconciler.unmountVirtualTree(handle) + VirtualTree.unmount(handle) end end) @@ -25,7 +39,7 @@ return function() logInfo = Logging.capture(function() -- This is a different call site, which should trigger another warning. local handle = compatReconciler.reify(createElement("StringValue")) - noopReconciler.unmountVirtualTree(handle) + VirtualTree.unmount(handle) end) expect(#logInfo.warnings).to.equal(1) @@ -37,7 +51,7 @@ return function() -- We're using a loop so that we get the same stack trace and only one -- warning hopefully. for _ = 1, 2 do - local handle = noopReconciler.mountVirtualTree(createElement("StringValue")) + local handle = mountWithNoop(createElement("StringValue")) compatReconciler.teardown(handle) end end) @@ -47,7 +61,7 @@ return function() logInfo = Logging.capture(function() -- This is a different call site, which should trigger another warning. - local handle = noopReconciler.mountVirtualTree(createElement("StringValue")) + local handle = mountWithNoop(createElement("StringValue")) compatReconciler.teardown(handle) end) @@ -60,9 +74,9 @@ return function() -- We're using a loop so that we get the same stack trace and only one -- warning hopefully. for _ = 1, 2 do - local handle = noopReconciler.mountVirtualTree(createElement("StringValue")) + local handle = mountWithNoop(createElement("StringValue")) compatReconciler.reconcile(handle, createElement("StringValue")) - noopReconciler.unmountVirtualTree(handle) + VirtualTree.unmount(handle) end end) @@ -71,9 +85,9 @@ return function() logInfo = Logging.capture(function() -- This is a different call site, which should trigger another warning. - local handle = noopReconciler.mountVirtualTree(createElement("StringValue")) + local handle = mountWithNoop(createElement("StringValue")) compatReconciler.reconcile(handle, createElement("StringValue")) - noopReconciler.unmountVirtualTree(handle) + VirtualTree.unmount(handle) end) expect(#logInfo.warnings).to.equal(1) diff --git a/src/deepEqual.lua b/src/deepEqual.lua new file mode 100644 index 00000000..0e052ba1 --- /dev/null +++ b/src/deepEqual.lua @@ -0,0 +1,51 @@ +local function deepEqual(a, b) + if typeof(a) ~= typeof(b) then + local message = ("{1} is of type %s, but {2} is of type %s"):format( + typeof(a), + typeof(b) + ) + return false, message + end + + if typeof(a) == "table" then + local visitedKeys = {} + + for key, value in pairs(a) do + visitedKeys[key] = true + + local success, innerMessage = deepEqual(value, b[key]) + if not success then + local message = innerMessage + :gsub("{1}", ("{1}[%s]"):format(tostring(key))) + :gsub("{2}", ("{2}[%s]"):format(tostring(key))) + + return false, message + end + end + + for key, value in pairs(b) do + if not visitedKeys[key] then + local success, innerMessage = deepEqual(value, a[key]) + + if not success then + local message = innerMessage + :gsub("{1}", ("{1}[%s]"):format(tostring(key))) + :gsub("{2}", ("{2}[%s]"):format(tostring(key))) + + return false, message + end + end + end + + return true + end + + if a == b then + return true + end + + local message = "{1} ~= {2}" + return false, message +end + +return deepEqual \ No newline at end of file diff --git a/src/deepEqual.spec.lua b/src/deepEqual.spec.lua new file mode 100644 index 00000000..41931156 --- /dev/null +++ b/src/deepEqual.spec.lua @@ -0,0 +1,99 @@ +return function() + local deepEqual = require(script.Parent.deepEqual) + + it("should compare non-table values using standard '==' equality", function() + expect(deepEqual(1, 1)).to.equal(true) + expect(deepEqual("hello", "hello")).to.equal(true) + expect(deepEqual(nil, nil)).to.equal(true) + + local someFunction = function() end + local theSameFunction = someFunction + + expect(deepEqual(someFunction, theSameFunction)).to.equal(true) + + local A = { + foo = someFunction + } + local B = { + foo = theSameFunction + } + + expect(deepEqual(A, B)).to.equal(true) + end) + + it("should fail with a message when args are not equal", function() + local success, message = deepEqual(1, 2) + + expect(success).to.equal(false) + expect(message:find("{1} ~= {2}")).to.be.ok() + + success, message = deepEqual({ + foo = 1, + }, { + foo = 2, + }) + + expect(success).to.equal(false) + expect(message:find("{1}%[foo%] ~= {2}%[foo%]")).to.be.ok() + end) + + it("should fail when types differ", function() + local success, message = deepEqual(1, "1") + + expect(success).to.equal(false) + expect(message:find("{1} is of type number, but {2} is of type string")).to.be.ok() + end) + + it("should compare (and report about) nested tables", function() + local A = { + foo = "bar", + nested = { + foo = 1, + bar = 2, + } + } + local B = { + foo = "bar", + nested = { + foo = 1, + bar = 2, + } + } + + deepEqual(A, B) + + local C = { + foo = "bar", + nested = { + foo = 1, + bar = 3, + } + } + + local success, message = deepEqual(A, C) + + expect(success).to.equal(false) + expect(message:find("{1}%[nested%]%[bar%] ~= {2}%[nested%]%[bar%]")).to.be.ok() + end) + + it("should be commutative", function() + local equalArgsA = { + foo = "bar", + hello = "world", + } + local equalArgsB = { + foo = "bar", + hello = "world", + } + + expect(deepEqual(equalArgsA, equalArgsB)).to.equal(true) + expect(deepEqual(equalArgsB, equalArgsA)).to.equal(true) + + local nonEqualArgs = { + foo = "bar", + } + + expect(deepEqual(equalArgsA, nonEqualArgs)).to.equal(false) + expect(deepEqual(nonEqualArgs, equalArgsA)).to.equal(false) + end) +end \ No newline at end of file diff --git a/src/init.lua b/src/init.lua index f002f975..99f41262 100644 --- a/src/init.lua +++ b/src/init.lua @@ -3,14 +3,12 @@ ]] local GlobalConfig = require(script.GlobalConfig) -local createReconciler = require(script.createReconciler) local createReconcilerCompat = require(script.createReconcilerCompat) -local RobloxRenderer = require(script.RobloxRenderer) local strict = require(script.strict) local Binding = require(script.Binding) +local VirtualTree = require(script.VirtualTree) -local robloxReconciler = createReconciler(RobloxRenderer) -local reconcilerCompat = createReconcilerCompat(robloxReconciler) +local reconcilerCompat = createReconcilerCompat(VirtualTree) local Roact = strict { Component = require(script.Component), @@ -29,9 +27,9 @@ local Roact = strict { Event = require(script.PropMarkers.Event), Ref = require(script.PropMarkers.Ref), - mount = robloxReconciler.mountVirtualTree, - unmount = robloxReconciler.unmountVirtualTree, - update = robloxReconciler.updateVirtualTree, + mount = VirtualTree.mount, + unmount = VirtualTree.unmount, + update = VirtualTree.update, reify = reconcilerCompat.reify, teardown = reconcilerCompat.teardown, diff --git a/src/shallow/ShallowWrapper.lua b/src/shallow/ShallowWrapper.lua new file mode 100644 index 00000000..dadac21f --- /dev/null +++ b/src/shallow/ShallowWrapper.lua @@ -0,0 +1,159 @@ +local RoactRoot = script.Parent.Parent + +local Children = require(RoactRoot.PropMarkers.Children) +local ElementKind = require(RoactRoot.ElementKind) +local ElementUtils = require(RoactRoot.ElementUtils) +local strict = require(RoactRoot.strict) +local Symbol = require(RoactRoot.Symbol) +local Snapshot = require(script.Parent.Snapshot) +local VirtualNodeConstraints = require(script.Parent.VirtualNodeConstraints) + +local InternalData = Symbol.named("InternalData") + +local ShallowWrapper = {} +local ShallowWrapperPublic = {} +local ShallowWrapperMetatable = { + __index = ShallowWrapperPublic, +} + +local function getComponentFromVirtualNode(virtualNode) + local element = virtualNode.currentElement + local kind = ElementKind.of(element) + + if kind == ElementKind.Host + or kind == ElementKind.Function + or kind == ElementKind.Stateful + then + return element.component + else + error(("shallow wrapper does not support element of kind %q"):format(tostring(kind))) + end +end + +local function findNextVirtualNode(virtualNode, maxDepth) + local currentDepth = 0 + local currentNode = virtualNode + local nextNode = currentNode.children[ElementUtils.UseParentKey] + + while currentDepth < maxDepth and nextNode ~= nil do + currentNode = nextNode + nextNode = currentNode.children[ElementUtils.UseParentKey] + currentDepth = currentDepth + 1 + end + + return currentNode +end + +local function getChildren(virtualNode, results, maxDepth) + if ElementKind.of(virtualNode.currentElement) == ElementKind.Fragment then + for _, subVirtualNode in pairs(virtualNode.children) do + getChildren(subVirtualNode, results, maxDepth) + end + else + local childWrapper = ShallowWrapper.new( + virtualNode, + maxDepth + ) + + table.insert(results, childWrapper) + end +end + +local function filterProps(props) + if props[Children] == nil then + return props + end + + local filteredProps = {} + + for key, value in pairs(props) do + if key ~= Children then + filteredProps[key] = value + end + end + + return filteredProps +end + +function ShallowWrapper.new(virtualNode, maxDepth) + virtualNode = findNextVirtualNode(virtualNode, maxDepth) + + local internalData = { + virtualNode = virtualNode, + childrenMaxDepth = maxDepth - 1, + virtualNodeChildren = maxDepth == 0 and {} or virtualNode.children, + instance = virtualNode.hostObject, + } + + local wrapper = { + [InternalData] = internalData, + component = getComponentFromVirtualNode(virtualNode), + props = filterProps(virtualNode.currentElement.props), + hostKey = virtualNode.hostKey, + children = {}, + } + + for _, childVirtualNode in pairs(internalData.virtualNodeChildren) do + getChildren(childVirtualNode, wrapper.children, internalData.childrenMaxDepth) + end + + return setmetatable(wrapper, ShallowWrapperMetatable) +end + +function ShallowWrapperPublic:find(constraints) + VirtualNodeConstraints.validate(constraints) + + local results = {} + + for i=1, #self.children do + local childWrapper = self.children[i] + local childInternalData = childWrapper[InternalData] + + if VirtualNodeConstraints.satisfiesAll(childInternalData.virtualNode, constraints) then + table.insert(results, childWrapper) + end + end + + return results +end + +function ShallowWrapperPublic:findUnique(constraints) + if constraints == nil then + assert( + #self.children == 1, + ("expect to contain exactly one child, but found %d"):format(#self.children) + ) + return self.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 ShallowWrapperPublic:getHostObject() + local internalData = self[InternalData] + + return internalData.instance +end + +function ShallowWrapperPublic:matchSnapshot(identifier) + assert(typeof(identifier) == "string", "Snapshot identifier must be a string") + + local snapshotResult = Snapshot.createMatcher(identifier, self) + + snapshotResult:match() +end + +function ShallowWrapperPublic:toSnapshotString() + return Snapshot.toString(self) +end + +strict(ShallowWrapperPublic, "ShallowWrapper") + +return strict(ShallowWrapper, "ShallowWrapper") \ No newline at end of file diff --git a/src/shallow/ShallowWrapper.spec.lua b/src/shallow/ShallowWrapper.spec.lua new file mode 100644 index 00000000..8fd7a314 --- /dev/null +++ b/src/shallow/ShallowWrapper.spec.lua @@ -0,0 +1,531 @@ +return function() + local RoactRoot = script.Parent.Parent + local ShallowWrapper = require(script.Parent.ShallowWrapper) + + local assertDeepEqual = require(RoactRoot.assertDeepEqual) + local Children = require(RoactRoot.PropMarkers.Children) + local createElement = require(RoactRoot.createElement) + local createFragment = require(RoactRoot.createFragment) + local createReconciler = require(RoactRoot.createReconciler) + local RoactComponent = require(RoactRoot.Component) + local RobloxRenderer = require(RoactRoot.RobloxRenderer) + + local robloxReconciler = createReconciler(RobloxRenderer) + + local function shallow(element, depth) + depth = depth or 1 + local hostParent = Instance.new("Folder") + + local virtualNode = robloxReconciler.mountVirtualNode(element, hostParent, "ShallowTree") + + return ShallowWrapper.new(virtualNode, depth) + end + + describe("single host element", function() + local className = "TextLabel" + + local function Component(props) + return createElement(className, props) + end + + it("should have its component set to the given instance class", function() + local element = createElement(Component) + + local result = shallow(element) + + expect(result.component).to.equal(className) + end) + + it("children count should be zero", function() + local element = createElement(Component) + + local result = shallow(element) + + expect(#result.children).to.equal(0) + end) + end) + + describe("single function element", function() + local function FunctionComponent(props) + return createElement("TextLabel") + end + + local function Component(props) + return createElement(FunctionComponent, props) + end + + it("should have its component set to the functional component", function() + local element = createElement(Component) + + local result = shallow(element) + + expect(result.component).to.equal(FunctionComponent) + end) + end) + + describe("single stateful element", function() + local StatefulComponent = RoactComponent:extend("StatefulComponent") + + function StatefulComponent:render() + return createElement("TextLabel") + end + + local function Component(props) + return createElement(StatefulComponent, props) + end + + it("should have its component set to the given component class", function() + local element = createElement(Component) + + local result = shallow(element) + + expect(result.component).to.equal(StatefulComponent) + end) + end) + + describe("depth", function() + local unwrappedClassName = "TextLabel" + local function A(props) + return createElement(unwrappedClassName) + end + + local function B(props) + return createElement(A) + end + + local function Component(props) + return createElement(B) + end + + local function ComponentWithChildren(props) + return createElement("Frame", {}, { + ChildA = createElement(A), + ChildB = createElement(B), + }) + end + + it("should unwrap function components when depth has not exceeded", function() + local element = createElement(Component) + + local result = shallow(element, 3) + + expect(result.component).to.equal(unwrappedClassName) + end) + + it("should stop unwrapping function components when depth has exceeded", function() + local element = createElement(Component) + + local result = shallow(element, 2) + + expect(result.component).to.equal(A) + end) + + it("should not unwrap the element when depth is zero", function() + local element = createElement(Component) + + local result = shallow(element, 0) + + expect(result.component).to.equal(Component) + end) + + it("should not unwrap children when depth is one", function() + local element = createElement(ComponentWithChildren) + + local result = shallow(element) + + local childA = result:find({ + component = A, + }) + expect(#childA).to.equal(1) + + local childB = result:find({ + component = B, + }) + expect(#childB).to.equal(1) + end) + + it("should unwrap children when depth is two", function() + local element = createElement(ComponentWithChildren) + + local result = shallow(element, 2) + + local hostChild = result:find({ + component = unwrappedClassName, + }) + expect(#hostChild).to.equal(1) + + local unwrappedBChild = result:find({ + component = A, + }) + expect(#unwrappedBChild).to.equal(1) + end) + + it("should not include any children when depth is zero", function() + local element = createElement(ComponentWithChildren) + + local result = shallow(element, 0) + + expect(#result.children).to.equal(0) + end) + + it("should not include any grand-children when depth is one", function() + local function ParentComponent() + return createElement("Folder", {}, { + Child = createElement(ComponentWithChildren), + }) + end + + local element = createElement(ParentComponent) + + local result = shallow(element, 1) + + expect(#result.children).to.equal(1) + + local componentWithChildrenWrapper = result:find({ + component = ComponentWithChildren, + })[1] + expect(componentWithChildrenWrapper).to.be.ok() + + expect(#componentWithChildrenWrapper.children).to.equal(0) + end) + end) + + describe("children count", function() + local childClassName = "TextLabel" + + local function Component(props) + local children = {} + + for i=1, props.childrenCount do + children[("Key%d"):format(i)] = createElement(childClassName) + end + + return createElement("Frame", {}, children) + end + + it("should have 1 child when the element contains only one element", function() + local element = createElement(Component, { + childrenCount = 1, + }) + + local result = shallow(element) + + expect(#result.children).to.equal(1) + end) + + it("should not have any children when the element does not contain elements", function() + local element = createElement(Component, { + childrenCount = 0, + }) + + local result = shallow(element) + + expect(#result.children).to.equal(0) + end) + + it("should count children in a fragment", function() + local element = createElement("Frame", {}, { + Frag = createFragment({ + Label = createElement("TextLabel"), + Button = createElement("TextButton"), + }) + }) + + local result = shallow(element) + + expect(#result.children).to.equal(2) + end) + + it("should count children nested in fragments", function() + local element = createElement("Frame", {}, { + Frag = createFragment({ + SubFrag = createFragment({ + Frame = createElement("Frame"), + }), + Label = createElement("TextLabel"), + Button = createElement("TextButton"), + }) + }) + + local result = shallow(element) + + expect(#result.children).to.equal(3) + end) + end) + + describe("props", function() + it("should contains the same props using Host element", function() + local function Component(props) + return createElement("Frame", props) + end + + local props = { + BackgroundTransparency = 1, + Visible = false, + } + local element = createElement(Component, props) + + local result = shallow(element) + + expect(result.props).to.be.ok() + + assertDeepEqual(props, result.props) + end) + + it("should have the same props using function element", function() + local function ChildComponent(props) + return createElement("Frame", props) + end + + local function Component(props) + return createElement(ChildComponent, props) + end + + local props = { + BackgroundTransparency = 1, + Visible = false, + } + local propsCopy = {} + for key, value in pairs(props) do + propsCopy[key] = value + end + local element = createElement(Component, props) + + local result = shallow(element) + + expect(result.component).to.equal(ChildComponent) + expect(result.props).to.be.ok() + + assertDeepEqual(propsCopy, result.props) + end) + + it("should not have the children property", function() + local function ComponentWithChildren(props) + return createElement("Frame", props, { + Key = createElement("TextLabel"), + }) + end + + local props = { + BackgroundTransparency = 1, + Visible = false, + } + + local element = createElement(ComponentWithChildren, props) + + local result = shallow(element) + + expect(result.props).to.be.ok() + expect(result.props[Children]).never.to.be.ok() + end) + + it("should have the inherited props", function() + local function Component(props) + local frameProps = { + LayoutOrder = 7, + } + for key, value in pairs(props) do + frameProps[key] = value + end + + return createElement("Frame", frameProps) + end + + local element = createElement(Component, { + BackgroundTransparency = 1, + Visible = false, + }) + + local result = shallow(element) + + expect(result.props).to.be.ok() + + local expectProps = { + BackgroundTransparency = 1, + Visible = false, + LayoutOrder = 7, + } + + assertDeepEqual(expectProps, result.props) + end) + end) + + describe("getHostObject", function() + it("should return 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 wrapper = shallow(element) + + local instance = wrapper:getHostObject() + + expect(instance).to.be.ok() + expect(instance.ClassName).to.equal(className) + end) + + it("should return nil 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 wrapper = shallow(element) + + local instance = wrapper:getHostObject() + + expect(instance).never.to.be.ok() + end) + end) + + describe("find children", function() + it("should throw if the constraint does not exist", function() + local element = createElement("Frame") + + local result = shallow(element) + + local function findWithInvalidConstraint() + result:find({ + nothing = false, + }) + end + + expect(findWithInvalidConstraint).to.throw() + end) + + it("should return children that matches all contraints", function() + local function ComponentWithChildren() + return createElement("Frame", {}, { + ChildA = createElement("TextLabel", { + Visible = false, + }), + ChildB = createElement("TextButton", { + Visible = false, + }), + }) + end + + local element = createElement(ComponentWithChildren) + + local result = shallow(element) + + local children = result:find({ + className = "TextLabel", + props = { + Visible = false, + }, + }) + + expect(#children).to.equal(1) + end) + + it("should return children from fragments", function() + local childClassName = "TextLabel" + + local function ComponentWithFragment() + return createElement("Frame", {}, { + Fragment = createFragment({ + Child = createElement(childClassName), + }), + }) + end + + local element = createElement(ComponentWithFragment) + + local result = shallow(element) + + local children = result:find({ + className = childClassName + }) + + expect(#children).to.equal(1) + end) + + it("should return children from nested fragments", function() + local childClassName = "TextLabel" + + local function ComponentWithFragment() + return createElement("Frame", {}, { + Fragment = createFragment({ + SubFragment = createFragment({ + Child = createElement(childClassName), + }), + }), + }) + end + + local element = createElement(ComponentWithFragment) + + local result = shallow(element) + + local children = result:find({ + className = childClassName + }) + + 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.component).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.component).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/shallow/Snapshot/Serialize/IndentedOutput.lua b/src/shallow/Snapshot/Serialize/IndentedOutput.lua new file mode 100644 index 00000000..4dd650ed --- /dev/null +++ b/src/shallow/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 + + if line == "" then + table.insert(self._lines, line) + else + table.insert(self._lines, ("%s%s"):format(self._indentation:rep(self._level), line)) + end +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(...) + self:write(...) + self:push() +end + +function IndentedOutput:popAndWrite(...) + self:pop() + self:write(...) +end + +function IndentedOutput:join() + return table.concat(self._lines, "\n") +end + +return IndentedOutput diff --git a/src/shallow/Snapshot/Serialize/IndentedOutput.spec.lua b/src/shallow/Snapshot/Serialize/IndentedOutput.spec.lua new file mode 100644 index 00000000..66886c5d --- /dev/null +++ b/src/shallow/Snapshot/Serialize/IndentedOutput.spec.lua @@ -0,0 +1,85 @@ +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) + end) + + describe("write", function() + it("should preceed the line with the current indentation level", function() + local output = IndentedOutput.new() + + output:push() + output:write("foo") + + expect(output:join()).to.equal(" foo") + end) + + it("should not write indentation spaces when line is empty", function() + local output = IndentedOutput.new() + + output:push() + output:write("foo") + output:write("") + output:write("bar") + + expect(output:join()).to.equal(" foo\n\n 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 pop and write the line", 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/shallow/Snapshot/Serialize/Markers/AnonymousFunction.lua b/src/shallow/Snapshot/Serialize/Markers/AnonymousFunction.lua new file mode 100644 index 00000000..4a3c04fa --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Markers/AnonymousFunction.lua @@ -0,0 +1,7 @@ +local RoactRoot = script.Parent.Parent.Parent.Parent.Parent + +local Symbol = require(RoactRoot.Symbol) + +local AnonymousFunction = Symbol.named("AnonymousFunction") + +return AnonymousFunction \ No newline at end of file diff --git a/src/shallow/Snapshot/Serialize/Markers/EmptyRef.lua b/src/shallow/Snapshot/Serialize/Markers/EmptyRef.lua new file mode 100644 index 00000000..53b80650 --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Markers/EmptyRef.lua @@ -0,0 +1,7 @@ +local RoactRoot = script.Parent.Parent.Parent.Parent.Parent + +local Symbol = require(RoactRoot.Symbol) + +local EmptyRef = Symbol.named("EmptyRef") + +return EmptyRef \ No newline at end of file diff --git a/src/shallow/Snapshot/Serialize/Markers/Signal.lua b/src/shallow/Snapshot/Serialize/Markers/Signal.lua new file mode 100644 index 00000000..04244470 --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Markers/Signal.lua @@ -0,0 +1,7 @@ +local RoactRoot = script.Parent.Parent.Parent.Parent.Parent + +local Symbol = require(RoactRoot.Symbol) + +local Signal = Symbol.named("Signal") + +return Signal \ No newline at end of file diff --git a/src/shallow/Snapshot/Serialize/Markers/Unknown.lua b/src/shallow/Snapshot/Serialize/Markers/Unknown.lua new file mode 100644 index 00000000..82ab0d50 --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Markers/Unknown.lua @@ -0,0 +1,7 @@ +local RoactRoot = script.Parent.Parent.Parent.Parent.Parent + +local Symbol = require(RoactRoot.Symbol) + +local Unkown = Symbol.named("Unkown") + +return Unkown \ No newline at end of file diff --git a/src/shallow/Snapshot/Serialize/Markers/init.lua b/src/shallow/Snapshot/Serialize/Markers/init.lua new file mode 100644 index 00000000..3999927c --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Markers/init.lua @@ -0,0 +1,10 @@ +local RoactRoot = script.Parent.Parent.Parent.Parent + +local strict = require(RoactRoot.strict) + +return strict({ + AnonymousFunction = require(script.AnonymousFunction), + EmptyRef = require(script.EmptyRef), + Signal = require(script.Signal), + Unknown = require(script.Unknown), +}, "Markers") \ No newline at end of file diff --git a/src/shallow/Snapshot/Serialize/Serializer.lua b/src/shallow/Snapshot/Serialize/Serializer.lua new file mode 100644 index 00000000..7540c1bf --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Serializer.lua @@ -0,0 +1,247 @@ +local RoactRoot = script.Parent.Parent.Parent.Parent + +local ElementKind = require(RoactRoot.ElementKind) +local Ref = require(RoactRoot.PropMarkers.Ref) +local Type = require(RoactRoot.Type) +local Markers = require(script.Parent.Markers) +local IndentedOutput = require(script.Parent.IndentedOutput) + +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.tableKey(key) + local keyType = type(key) + + if keyType == "string" and key:match("^%a%w+$") then + return key + else + return ("[%s]"):format(Serializer.tableValue(key)) + end +end + +function Serializer.number(value) + local _, fraction = math.modf(value) + + if fraction == 0 then + return ("%s"):format(tostring(value)) + else + return ("%0.7f"):format(value):gsub("%.?0+$", "") + end +end + +function Serializer.tableValue(value) + local valueType = typeof(value) + + if valueType == "string" then + return ("%q"):format(value) + + elseif valueType == "number" then + return Serializer.number(value) + + elseif valueType == "boolean" then + return ("%s"):format(tostring(value)) + + elseif valueType == "Color3" then + return ("Color3.new(%s, %s, %s)"):format( + Serializer.number(value.r), + Serializer.number(value.g), + Serializer.number(value.b) + ) + + elseif valueType == "EnumItem" then + return ("%s"):format(tostring(value)) + + elseif valueType == "Rect" then + return ("Rect.new(%s, %s, %s, %s)"):format( + Serializer.number(value.Min.X), + Serializer.number(value.Min.Y), + Serializer.number(value.Max.X), + Serializer.number(value.Max.Y) + ) + + elseif valueType == "UDim" then + return ("UDim.new(%s, %d)"):format(Serializer.number(value.Scale), value.Offset) + + elseif valueType == "UDim2" then + return ("UDim2.new(%s, %d, %s, %d)"):format( + Serializer.number(value.X.Scale), + value.X.Offset, + Serializer.number(value.Y.Scale), + value.Y.Offset + ) + + elseif valueType == "Vector2" then + return ("Vector2.new(%s, %s)"):format( + Serializer.number(value.X), + Serializer.number(value.Y) + ) + + elseif Type.of(value) == Type.HostEvent then + return ("Roact.Event.%s"):format(value.name) + + elseif Type.of(value) == Type.HostChangeEvent then + return ("Roact.Change.%s"):format(value.name) + + elseif value == Ref then + return "Roact.Ref" + + else + for markerName, marker in pairs(Markers) do + if value == marker then + return ("Markers.%s"):format(markerName) + end + end + + error(("Cannot serialize value %q of type %q"):format( + tostring(value), + valueType + )) + end +end + +function Serializer.getKeyTypeOrder(key) + if type(key) == "string" then + return 1 + elseif Type.of(key) == Type.HostEvent then + return 2 + elseif Type.of(key) == Type.HostChangeEvent then + return 3 + elseif key == Ref then + return 4 + else + return math.huge + end +end + +function Serializer.compareKeys(a, b) + -- a and b are of the same type here, because Serializer.sortTableKeys + -- will only use this function to compare keys of the same type + if Type.of(a) == Type.HostEvent or Type.of(a) == Type.HostChangeEvent then + return a.name < b.name + else + return a < b + end +end + +function Serializer.sortTableKeys(a, b) + -- first sort by the type of key, to place string props, then Roact.Event + -- events, Roact.Change events and the Ref + local orderA = Serializer.getKeyTypeOrder(a) + local orderB = Serializer.getKeyTypeOrder(b) + + if orderA == orderB then + return Serializer.compareKeys(a, b) + else + return orderA < orderB + end +end + +function Serializer.table(tableKey, dict, output) + if next(dict) == nil then + output:write("%s = {},", tableKey) + return + end + + output:writeAndPush("%s = {", tableKey) + + local keys = {} + + for key in pairs(dict) do + table.insert(keys, key) + end + + table.sort(keys, Serializer.sortTableKeys) + + for i=1, #keys do + local key = keys[i] + local value = dict[key] + local serializedKey = Serializer.tableKey(key) + + if type(value) == "table" then + Serializer.table(serializedKey, value, output) + else + output:write("%s = %s,", serializedKey, Serializer.tableValue(value)) + end + end + + output:popAndWrite("},") +end + +function Serializer.props(props, output) + Serializer.table("props", props, output) +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, includeHostKey) + Serializer.type(snapshotData.type, output) + + if includeHostKey then + output:write("hostKey = %q,", snapshotData.hostKey) + end + + Serializer.props(snapshotData.props, output) + Serializer.children(snapshotData.children, output) +end + +function Serializer.snapshotData(snapshotData, output) + output:writeAndPush("{") + Serializer.snapshotDataContent(snapshotData, output, true) + 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 Markers = dependencies.Markers") + output:write("") + output:writeAndPush("return {") + + Serializer.snapshotDataContent(snapshotData, output, false) + + output:popAndWrite("}") + output:popAndWrite("end") + + return output:join() +end + +return Serializer diff --git a/src/shallow/Snapshot/Serialize/Serializer.spec.lua b/src/shallow/Snapshot/Serialize/Serializer.spec.lua new file mode 100644 index 00000000..8d657326 --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Serializer.spec.lua @@ -0,0 +1,404 @@ +return function() + local RoactRoot = script.Parent.Parent.Parent.Parent + + local Markers = require(script.Parent.Markers) + local Change = require(RoactRoot.PropMarkers.Change) + local Event = require(RoactRoot.PropMarkers.Event) + local ElementKind = require(RoactRoot.ElementKind) + local IndentedOutput = require(script.Parent.IndentedOutput) + local Ref = require(RoactRoot.PropMarkers.Ref) + 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("tableKey", 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.tableKey(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.tableKey(key) + + expect(result).to.equal('["' .. key .. '"]') + end + end) + end) + + describe("number", function() + it("should format integers", function() + expect(Serializer.number(1)).to.equal("1") + expect(Serializer.number(0)).to.equal("0") + expect(Serializer.number(10)).to.equal("10") + end) + + it("should minimize floating points zeros", function() + expect(Serializer.number(1.2)).to.equal("1.2") + expect(Serializer.number(0.002)).to.equal("0.002") + expect(Serializer.number(5.5001)).to.equal("5.5001") + end) + + it("should keep only 7 decimals", function() + expect(Serializer.number(0.123456789)).to.equal("0.1234568") + expect(Serializer.number(0.123456709)).to.equal("0.1234567") + end) + end) + + describe("tableValue", function() + it("should serialize strings", function() + local result = Serializer.tableValue("foo") + + expect(result).to.equal('"foo"') + end) + + it("should serialize strings with \"", function() + local result = Serializer.tableValue('foo"bar') + + expect(result).to.equal('"foo\\"bar"') + end) + + it("should serialize numbers", function() + local result = Serializer.tableValue(10.5) + + expect(result).to.equal("10.5") + end) + + it("should serialize booleans", function() + expect(Serializer.tableValue(true)).to.equal("true") + expect(Serializer.tableValue(false)).to.equal("false") + end) + + it("should serialize enum items", function() + local result = Serializer.tableValue(Enum.SortOrder.LayoutOrder) + + expect(result).to.equal("Enum.SortOrder.LayoutOrder") + end) + + it("should serialize Color3", function() + local result = Serializer.tableValue(Color3.new(0.1, 0.2, 0.3)) + + expect(result).to.equal("Color3.new(0.1, 0.2, 0.3)") + end) + + it("should serialize Rect", function() + local result = Serializer.tableValue(Rect.new(0.1, 0.2, 0.3, 0.4)) + + expect(result).to.equal("Rect.new(0.1, 0.2, 0.3, 0.4)") + end) + + it("should serialize UDim", function() + local result = Serializer.tableValue(UDim.new(1.2, 0)) + + expect(result).to.equal("UDim.new(1.2, 0)") + end) + + it("should serialize UDim2", function() + local result = Serializer.tableValue(UDim2.new(1.5, 5, 2, 3)) + + expect(result).to.equal("UDim2.new(1.5, 5, 2, 3)") + end) + + it("should serialize Vector2", function() + local result = Serializer.tableValue(Vector2.new(1.5, 0.3)) + + expect(result).to.equal("Vector2.new(1.5, 0.3)") + end) + + it("should serialize markers symbol", function() + for name, marker in pairs(Markers) do + local result = Serializer.tableValue(marker) + + expect(result).to.equal(("Markers.%s"):format(name)) + end + end) + + it("should serialize Roact.Event events", function() + local result = Serializer.tableValue(Event.Activated) + + expect(result).to.equal("Roact.Event.Activated") + end) + + it("should serialize Roact.Change events", function() + local result = Serializer.tableValue(Change.AbsoluteSize) + + expect(result).to.equal("Roact.Change.AbsoluteSize") + end) + end) + + describe("table", function() + it("should serialize an empty nested table", function() + local output = IndentedOutput.new() + Serializer.table("sub", {}, output) + + expect(output:join()).to.equal("sub = {},") + end) + + it("should serialize an nested table", function() + local output = IndentedOutput.new() + Serializer.table("sub", { + foo = 1, + }, output) + + expect(output:join()).to.equal("sub = {\n foo = 1,\n},") + 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] = Markers.AnonymousFunction, + }, output) + + expect(output:join()).to.equal( + "props = {\n" + .. " [Roact.Event.Activated] = Markers.AnonymousFunction,\n" + .. "}," + ) + end) + + it("should sort Roact.Event", function() + local output = IndentedOutput.new() + Serializer.props({ + [Event.Activated] = Markers.AnonymousFunction, + [Event.MouseEnter] = Markers.AnonymousFunction, + }, output) + + expect(output:join()).to.equal( + "props = {\n" + .. " [Roact.Event.Activated] = Markers.AnonymousFunction,\n" + .. " [Roact.Event.MouseEnter] = Markers.AnonymousFunction,\n" + .. "}," + ) + end) + + it("should serialize Roact.Change", function() + local output = IndentedOutput.new() + Serializer.props({ + [Change.Position] = Markers.AnonymousFunction, + }, output) + + expect(output:join()).to.equal( + "props = {\n" + .. " [Roact.Change.Position] = Markers.AnonymousFunction,\n" + .. "}," + ) + end) + + it("should sort props, Roact.Event, Roact.Change and Ref", function() + local output = IndentedOutput.new() + Serializer.props({ + foo = 1, + [Event.Activated] = Markers.AnonymousFunction, + [Change.Position] = Markers.AnonymousFunction, + [Ref] = Markers.EmptyRef, + }, output) + + expect(output:join()).to.equal( + "props = {\n" + .. " foo = 1,\n" + .. " [Roact.Event.Activated] = Markers.AnonymousFunction,\n" + .. " [Roact.Change.Position] = Markers.AnonymousFunction,\n" + .. " [Roact.Ref] = Markers.EmptyRef,\n" + .. "}," + ) + end) + + it("should sort props within themselves", function() + local output = IndentedOutput.new() + Serializer.props({ + foo = 1, + bar = 2, + }, output) + + expect(output:join()).to.equal( + "props = {\n" + .. " bar = 2,\n" + .. " foo = 1,\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, true) + + expect(output:join()).to.equal( + "type = {\n" + .. " kind = ElementKind.Function,\n" + .. "},\n" + .. 'hostKey = "HostKey",\n' + .. "props = {},\n" + .. "children = {}," + ) + end) + + it("should not include the host key", function() + local snapshotData = { + type = { + kind = ElementKind.Function, + }, + hostKey = "HostKey", + props = {}, + children = {}, + } + local output = IndentedOutput.new() + Serializer.snapshotDataContent(snapshotData, output, false) + + expect(output:join()).to.equal( + "type = {\n" + .. " kind = ElementKind.Function,\n" + .. "},\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, true) + + 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/shallow/Snapshot/Serialize/Snapshot.lua b/src/shallow/Snapshot/Serialize/Snapshot.lua new file mode 100644 index 00000000..6ac9a7a6 --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Snapshot.lua @@ -0,0 +1,133 @@ +local RoactRoot = script.Parent.Parent.Parent.Parent + +local Markers = require(script.Parent.Markers) +local ElementKind = require(RoactRoot.ElementKind) +local Type = require(RoactRoot.Type) +local Ref = require(RoactRoot.PropMarkers.Ref) + +local function sortSerializedChildren(childA, childB) + return childA.hostKey < childB.hostKey +end + +local Snapshot = {} + +function Snapshot.type(wrapperComponent) + local kind = ElementKind.fromComponent(wrapperComponent) + + local typeData = { + kind = kind, + } + + if kind == ElementKind.Host then + typeData.className = wrapperComponent + elseif kind == ElementKind.Stateful then + typeData.componentName = tostring(wrapperComponent) + end + + return typeData +end + +function Snapshot.signal(signal) + local signalToString = tostring(signal) + local signalName = signalToString:match("Signal (%w+)") + + assert(signalName ~= nil, ("Can not extract signal name from %q"):format(signalToString)) + + return { + [Markers.Signal] = signalName + } +end + +function Snapshot.propValue(prop) + local propType = type(prop) + + if propType == "string" + or propType == "number" + or propType == "boolean" + then + return prop + + elseif propType == "function" then + return Markers.AnonymousFunction + + elseif typeof(prop) == "RBXScriptSignal" then + return Snapshot.signal(prop) + + elseif propType == "userdata" then + return prop + + elseif propType == "table" then + return Snapshot.props(prop) + + else + warn(("Snapshot does not support prop with value %q (type %q)"):format( + tostring(prop), + propType + )) + return Markers.Unknown + end +end + +function Snapshot.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] = Snapshot.propValue(prop) + + elseif key == Ref then + local current = prop:getValue() + + if current then + serializedProps[key] = { + className = current.ClassName, + } + else + serializedProps[key] = Markers.EmptyRef + end + + else + error(("Snapshot does not support prop with key %q (type: %s)"):format( + tostring(key), + type(key) + )) + end + end + + return serializedProps +end + +function Snapshot.children(children) + local serializedChildren = {} + + for i=1, #children do + local childWrapper = children[i] + + serializedChildren[i] = Snapshot.child(childWrapper) + end + + table.sort(serializedChildren, sortSerializedChildren) + + return serializedChildren +end + +function Snapshot.child(wrapper) + return { + type = Snapshot.type(wrapper.component), + hostKey = wrapper.hostKey, + props = Snapshot.props(wrapper.props), + children = Snapshot.children(wrapper.children), + } +end + +function Snapshot.new(wrapper) + local childSnapshot = Snapshot.child(wrapper) + childSnapshot.hostKey = nil + + return childSnapshot +end + +return Snapshot diff --git a/src/shallow/Snapshot/Serialize/Snapshot.spec.lua b/src/shallow/Snapshot/Serialize/Snapshot.spec.lua new file mode 100644 index 00000000..dbb42368 --- /dev/null +++ b/src/shallow/Snapshot/Serialize/Snapshot.spec.lua @@ -0,0 +1,362 @@ +return function() + local RoactRoot = script.Parent.Parent.Parent.Parent + + local Markers = require(script.Parent.Markers) + local assertDeepEqual = require(RoactRoot.assertDeepEqual) + local Binding = require(RoactRoot.Binding) + local Change = require(RoactRoot.PropMarkers.Change) + local Component = require(RoactRoot.Component) + local createElement = require(RoactRoot.createElement) + local createReconciler = require(RoactRoot.createReconciler) + local createRef = require(RoactRoot.createRef) + local ElementKind = require(RoactRoot.ElementKind) + local Event = require(RoactRoot.PropMarkers.Event) + local Ref = require(RoactRoot.PropMarkers.Ref) + local RobloxRenderer = require(RoactRoot.RobloxRenderer) + local ShallowWrapper = require(script.Parent.Parent.Parent.ShallowWrapper) + + local Snapshot = require(script.Parent.Snapshot) + + local robloxReconciler = createReconciler(RobloxRenderer) + + local function shallow(element, options) + options = options or {} + local maxDepth = options.depth or 1 + local hostKey = options.hostKey or "ShallowTree" + local hostParent = options.hostParent or Instance.new("Folder") + + local virtualNode = robloxReconciler.mountVirtualNode(element, hostParent, hostKey) + + return ShallowWrapper.new(virtualNode, maxDepth) + end + + describe("type", function() + describe("host elements", function() + it("should contain the host kind", function() + local wrapper = shallow(createElement("Frame")) + + local result = Snapshot.type(wrapper.component) + + 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 = Snapshot.type(wrapper.component) + + 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 = Snapshot.type(wrapper.component) + + 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 = Snapshot.type(wrapper.component) + + expect(result.kind).to.equal(ElementKind.Stateful) + end) + + it("should contain the component name", function() + local wrapper = shallow(createElement(SomeComponent)) + + local result = Snapshot.type(wrapper.component) + + expect(result.componentName).to.equal(componentName) + end) + end) + end) + + describe("signal", function() + it("should convert signals", function() + local signalName = "Foo" + local signalMock = setmetatable({}, { + __tostring = function() + return "Signal " .. signalName + end + }) + + local result = Snapshot.signal(signalMock) + + assertDeepEqual(result, { + [Markers.Signal] = signalName + }) + end) + end) + + describe("propValue", function() + it("should return the same value for basic types", function() + local propValues = {7, "hello", Enum.SortOrder.LayoutOrder} + + for i=1, #propValues do + local prop = propValues[i] + local result = Snapshot.propValue(prop) + + expect(result).to.equal(prop) + end + end) + + it("should return an empty table given an empty table", function() + local result = Snapshot.propValue({}) + + expect(next(result)).never.to.be.ok() + end) + + it("should serialize a table as a props table", function() + local key = "some key" + local value = { + [key] = "foo", + } + local result = Snapshot.propValue(value) + + expect(result[key]).to.equal("foo") + expect(next(result, key)).never.to.be.ok() + end) + + it("should return the AnonymousFunction symbol when given a function", function() + local result = Snapshot.propValue(function() end) + + expect(result).to.equal(Markers.AnonymousFunction) + end) + + it("should return the Unknown symbol when given an unexpected value", function() + local result = Snapshot.propValue(nil) + + expect(result).to.equal(Markers.Unknown) + end) + end) + + describe("props", function() + it("should keep props with string keys", function() + local props = { + image = "hello", + text = "never", + } + + local result = Snapshot.props(props) + + assertDeepEqual(result, props) + end) + + it("should map Roact.Event to AnonymousFunction", function() + local props = { + [Event.Activated] = function() end, + } + + local result = Snapshot.props(props) + + assertDeepEqual(result, { + [Event.Activated] = Markers.AnonymousFunction, + }) + end) + + it("should map Roact.Change to AnonymousFunction", function() + local props = { + [Change.Position] = function() end, + } + + local result = Snapshot.props(props) + + assertDeepEqual(result, { + [Change.Position] = Markers.AnonymousFunction, + }) + end) + + it("should map empty refs to the EmptyRef symbol", function() + local props = { + [Ref] = createRef(), + } + + local result = Snapshot.props(props) + + assertDeepEqual(result, { + [Ref] = Markers.EmptyRef, + }) + end) + + it("should map refs with value to their symbols", function() + local instanceClassName = "Folder" + local ref = createRef() + Binding.update(ref, Instance.new(instanceClassName)) + + local props = { + [Ref] = ref, + } + + local result = Snapshot.props(props) + + assertDeepEqual(result, { + [Ref] = { + className = instanceClassName, + }, + }) + end) + + it("should throw when the key is a table", function() + local function shouldThrow() + Snapshot.props({ + [{}] = "invalid", + }) + end + + expect(shouldThrow).to.throw() + end) + end) + + describe("child", function() + it("should have the host key", function() + local hostKey = "SomeKey" + local wrapper = shallow(createElement("Frame")) + wrapper.hostKey = hostKey + + local result = Snapshot.child(wrapper) + + expect(result.hostKey).to.equal(hostKey) + end) + + it("should contain the element type", function() + local wrapper = shallow(createElement("Frame")) + + local result = Snapshot.child(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] = Markers.AnonymousFunction, + } + + local wrapper = shallow(createElement("Frame", props)) + + local result = Snapshot.child(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 = Snapshot.child(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 = Snapshot.child(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) + + describe("new", function() + it("should clear the host key", function() + local wrapper = shallow(createElement("Frame")) + + local result = Snapshot.new(wrapper) + + expect(result.hostKey).never.to.be.ok() + end) + + it("should contain the element type", function() + local wrapper = shallow(createElement("Frame")) + + local result = Snapshot.new(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] = Markers.AnonymousFunction, + } + + local wrapper = shallow(createElement("Frame", props)) + + local result = Snapshot.new(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 = Snapshot.new(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 = Snapshot.new(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/shallow/Snapshot/Serialize/init.lua b/src/shallow/Snapshot/Serialize/init.lua new file mode 100644 index 00000000..170cca39 --- /dev/null +++ b/src/shallow/Snapshot/Serialize/init.lua @@ -0,0 +1,11 @@ +local Serializer = require(script.Serializer) +local Snapshot = require(script.Snapshot) + +return { + wrapperToSnapshot = function(wrapper) + return Snapshot.new(wrapper) + end, + snapshotToString = function(snapshot) + return Serializer.firstSnapshotData(snapshot) + end, +} diff --git a/src/shallow/Snapshot/SnapshotMatcher.lua b/src/shallow/Snapshot/SnapshotMatcher.lua new file mode 100644 index 00000000..d88d73bf --- /dev/null +++ b/src/shallow/Snapshot/SnapshotMatcher.lua @@ -0,0 +1,101 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RoactRoot = script.Parent.Parent.Parent + +local Markers = require(script.Parent.Serialize.Markers) +local Serialize = require(script.Parent.Serialize) +local deepEqual = require(RoactRoot.deepEqual) +local ElementKind = require(RoactRoot.ElementKind) + +local SnapshotFolderName = "RoactSnapshots" +local SnapshotFolder = ReplicatedStorage:FindFirstChild(SnapshotFolderName) + +local SnapshotMatcher = {} +local SnapshotMetatable = { + __index = SnapshotMatcher, +} + +local function throwSnapshotError(matcher, message) + local newSnapshot = SnapshotMatcher.new(matcher._identifier .. ".NEW", matcher._snapshot) + newSnapshot:serialize() + + error(message, 3) +end + +function SnapshotMatcher.new(identifier, snapshot) + local snapshotMatcher = { + _identifier = identifier, + _snapshot = snapshot, + _existingSnapshot = SnapshotMatcher._loadExistingData(identifier), + } + + setmetatable(snapshotMatcher, SnapshotMetatable) + + return snapshotMatcher +end + +function SnapshotMatcher:match() + if self._existingSnapshot == nil then + throwSnapshotError(self, ("Snapshot %q not found"):format(self._identifier)) + end + + local areEqual, innerMessageTemplate = deepEqual(self._snapshot, self._existingSnapshot) + + if areEqual then + return + end + + local innerMessage = innerMessageTemplate + :gsub("{1}", "new") + :gsub("{2}", "existing") + + local message = ("Snapshots do not match.\n%s"):format(innerMessage) + + throwSnapshotError(self, message) +end + +function SnapshotMatcher:serialize() + local folder = SnapshotMatcher.getSnapshotFolder() + + local snapshotSource = Serialize.snapshotToString(self._snapshot) + local existingData = folder:FindFirstChild(self._identifier) + + if not (existingData and existingData:IsA("StringValue")) then + existingData = Instance.new("StringValue") + existingData.Name = self._identifier + existingData.Parent = folder + end + + existingData.Value = snapshotSource +end + +function SnapshotMatcher.getSnapshotFolder() + SnapshotFolder = ReplicatedStorage:FindFirstChild(SnapshotFolderName) + + if not SnapshotFolder then + SnapshotFolder = Instance.new("Folder") + SnapshotFolder.Name = SnapshotFolderName + SnapshotFolder.Parent = ReplicatedStorage + end + + return SnapshotFolder +end + +function SnapshotMatcher._loadExistingData(identifier) + local folder = SnapshotMatcher.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(RoactRoot), + ElementKind = ElementKind, + Markers = Markers, + }) +end + +return SnapshotMatcher \ No newline at end of file diff --git a/src/shallow/Snapshot/SnapshotMatcher.spec.lua b/src/shallow/Snapshot/SnapshotMatcher.spec.lua new file mode 100644 index 00000000..d5200d22 --- /dev/null +++ b/src/shallow/Snapshot/SnapshotMatcher.spec.lua @@ -0,0 +1,151 @@ +return function() + local RoactRoot = script.Parent.Parent.Parent + + local SnapshotMatcher = require(script.Parent.SnapshotMatcher) + + local ElementKind = require(RoactRoot.ElementKind) + local createSpy = require(RoactRoot.createSpy) + + local snapshotFolder = Instance.new("Folder") + local originalGetSnapshotFolder = SnapshotMatcher.getSnapshotFolder + + local function mockGetSnapshotFolder() + return snapshotFolder + end + + local function getSnapshotMock() + return { + type = { + kind = ElementKind.Function, + }, + hostKey = "HostKey", + props = {}, + children = {}, + } + end + + local originalLoadExistingData = SnapshotMatcher._loadExistingData + local loadExistingDataSpy = nil + + describe("match", function() + local snapshotMap = {} + + local function beforeTest() + snapshotMap = {} + + loadExistingDataSpy = createSpy(function(identifier) + return snapshotMap[identifier] + end) + SnapshotMatcher._loadExistingData = loadExistingDataSpy.value + SnapshotMatcher.getSnapshotFolder = mockGetSnapshotFolder + end + + local function cleanTest() + loadExistingDataSpy = nil + SnapshotMatcher._loadExistingData = originalLoadExistingData + SnapshotMatcher.getSnapshotFolder = originalGetSnapshotFolder + snapshotFolder:ClearAllChildren() + end + + it("should throw if no snapshot is found", function() + beforeTest() + + local snapshot = getSnapshotMock() + + local matcher = SnapshotMatcher.new("foo", snapshot) + + local function shouldThrow() + matcher:match() + end + + expect(shouldThrow).to.throw() + + expect(snapshotFolder:FindFirstChild("foo.NEW")).to.be.ok() + + cleanTest() + end) + + it("should not serialize if the snapshot already exist", function() + beforeTest() + + local snapshot = getSnapshotMock() + local identifier = "foo" + snapshotMap[identifier] = snapshot + + local serializeSpy = createSpy() + + local matcher = SnapshotMatcher.new(identifier, snapshot) + matcher.serialize = serializeSpy.value + + matcher:match() + + cleanTest() + + expect(serializeSpy.callCount).to.equal(0) + end) + + it("should throw an error if the previous snapshot does not match", function() + beforeTest() + + local snapshot = getSnapshotMock() + local identifier = "foo" + snapshotMap[identifier] = { + Key = "Value" + } + + local serializeSpy = createSpy() + + local matcher = SnapshotMatcher.new(identifier, snapshot) + matcher.serialize = serializeSpy.value + + local function shouldThrow() + matcher:match() + end + + expect(shouldThrow).to.throw() + + cleanTest() + end) + end) + + describe("serialize", function() + it("should create a StringValue if it does not exist", function() + SnapshotMatcher.getSnapshotFolder = mockGetSnapshotFolder + + local identifier = "foo" + + local matcher = SnapshotMatcher.new(identifier, getSnapshotMock()) + + matcher:serialize() + local stringValue = snapshotFolder:FindFirstChild(identifier) + + SnapshotMatcher.getSnapshotFolder = originalGetSnapshotFolder + + expect(stringValue).to.be.ok() + expect(stringValue.Value:len() > 0).to.equal(true) + + stringValue:Destroy() + end) + end) + + describe("_loadExistingData", function() + it("should return nil if data is not found", function() + SnapshotMatcher.getSnapshotFolder = mockGetSnapshotFolder + + local result = SnapshotMatcher._loadExistingData("foo") + + SnapshotMatcher.getSnapshotFolder = originalGetSnapshotFolder + + 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 = SnapshotMatcher.getSnapshotFolder() + + expect(folder).to.be.ok() + expect(folder.Parent).to.equal(game:GetService("ReplicatedStorage")) + end) + end) +end \ No newline at end of file diff --git a/src/shallow/Snapshot/init.lua b/src/shallow/Snapshot/init.lua new file mode 100644 index 00000000..4b9d631b --- /dev/null +++ b/src/shallow/Snapshot/init.lua @@ -0,0 +1,28 @@ +local Serialize = require(script.Serialize) +local SnapshotMatcher = require(script.SnapshotMatcher) + +local characterClass = "%w_%-%." +local identifierPattern = "^[" .. characterClass .. "]+$" +local invalidPattern = "[^" .. characterClass .. "]" + +local function createMatcher(identifier, shallowWrapper) + if not identifier:match(identifierPattern) then + error(("Snapshot identifier has invalid character: %q"):format(identifier:match(invalidPattern))) + end + + local snapshot = Serialize.wrapperToSnapshot(shallowWrapper) + local matcher = SnapshotMatcher.new(identifier, snapshot) + + return matcher +end + +local function toString(shallowWrapper) + local snapshot = Serialize.wrapperToSnapshot(shallowWrapper) + + return Serialize.snapshotToString(snapshot) +end + +return { + createMatcher = createMatcher, + toString = toString, +} \ No newline at end of file diff --git a/src/shallow/Snapshot/init.spec.lua b/src/shallow/Snapshot/init.spec.lua new file mode 100644 index 00000000..69fb22e1 --- /dev/null +++ b/src/shallow/Snapshot/init.spec.lua @@ -0,0 +1,104 @@ +return function() + local RoactRoot = script.Parent.Parent.Parent + + local Change = require(RoactRoot.PropMarkers.Change) + local Component = require(RoactRoot.Component) + local createElement = require(RoactRoot.createElement) + local createReconciler = require(RoactRoot.createReconciler) + local Event = require(RoactRoot.PropMarkers.Event) + local RobloxRenderer = require(RoactRoot.RobloxRenderer) + local ShallowWrapper = require(script.Parent.Parent.ShallowWrapper) + local Snapshot = require(script.Parent) + + local robloxReconciler = createReconciler(RobloxRenderer) + + local hostTreeKey = "RoactTree" + + it("should match snapshot of host component with multiple props", function() + local element = createElement("Frame", { + AnchorPoint = Vector2.new(0, 0.5), + BackgroundColor3 = Color3.new(0.1, 0.2, 0.3), + BackgroundTransparency = 0.205, + ClipsDescendants = false, + Size = UDim2.new(0.5, 0, 0.4, 1), + SizeConstraint = Enum.SizeConstraint.RelativeXY, + Visible = true, + ZIndex = 5, + }) + + local rootNode = robloxReconciler.mountVirtualNode(element, nil, hostTreeKey) + local wrapper = ShallowWrapper.new(rootNode, 1) + + Snapshot.createMatcher("host-frame-with-multiple-props", wrapper):match() + end) + + it("should match snapshot of function component children", function() + local function LabelComponent(props) + return createElement("TextLabel", props) + end + + local element = createElement("Frame", {}, { + LabelA = createElement(LabelComponent, { + Text = "I am label A", + }), + LabelB = createElement(LabelComponent, { + Text = "I am label B", + }), + }) + + local rootNode = robloxReconciler.mountVirtualNode(element, nil, hostTreeKey) + local wrapper = ShallowWrapper.new(rootNode, 1) + + Snapshot.createMatcher("function-component-children", wrapper):match() + end) + + it("should match snapshot of stateful component", function() + local StatefulComponent = Component:extend("CoolComponent") + + function StatefulComponent:render() + return createElement("TextLabel") + end + + local element = createElement("Frame", {}, { + Child = createElement(StatefulComponent, { + label = { + Text = "foo", + }, + }), + }) + + local rootNode = robloxReconciler.mountVirtualNode(element, nil, hostTreeKey) + local wrapper = ShallowWrapper.new(rootNode, 1) + + Snapshot.createMatcher("stateful-component-children", wrapper):match() + end) + + it("should match snapshot with event props", function() + local function emptyFunction() + end + + local element = createElement("TextButton", { + [Change.AbsoluteSize] = emptyFunction, + [Change.Visible] = emptyFunction, + [Event.Activated] = emptyFunction, + [Event.MouseButton1Click] = emptyFunction, + }) + + local rootNode = robloxReconciler.mountVirtualNode(element, nil, hostTreeKey) + local wrapper = ShallowWrapper.new(rootNode, 1) + + Snapshot.createMatcher("component-with-event-props", wrapper):match() + end) + + it("should throw if the identifier contains invalid characters", function() + local invalidCharacters = {"\\", "/", "?"} + + for i=1, #invalidCharacters do + local function shouldThrow() + Snapshot.createMatcher("id" .. invalidCharacters[i], {}) + end + + expect(shouldThrow).to.throw() + end + end) +end \ No newline at end of file diff --git a/src/shallow/VirtualNodeConstraints/Constraints.lua b/src/shallow/VirtualNodeConstraints/Constraints.lua new file mode 100644 index 00000000..74d500b8 --- /dev/null +++ b/src/shallow/VirtualNodeConstraints/Constraints.lua @@ -0,0 +1,38 @@ +local RoactRoot = script.Parent.Parent.Parent + +local ElementKind = require(RoactRoot.ElementKind) + +local Constraints = setmetatable({}, { + __index = function(self, unexpectedConstraint) + error(("unknown constraint %q"):format(unexpectedConstraint)) + end, +}) + +function Constraints.className(virtualNode, className) + local element = virtualNode.currentElement + local isHost = ElementKind.of(element) == ElementKind.Host + + return isHost and element.component == className +end + +function Constraints.component(virtualNode, expectComponentValue) + return virtualNode.currentElement.component == expectComponentValue +end + +function Constraints.props(virtualNode, propSubSet) + local elementProps = virtualNode.currentElement.props + + for propKey, propValue in pairs(propSubSet) do + if elementProps[propKey] ~= propValue then + return false + end + end + + return true +end + +function Constraints.hostKey(virtualNode, expectHostKey) + return virtualNode.hostKey == expectHostKey +end + +return Constraints \ No newline at end of file diff --git a/src/shallow/VirtualNodeConstraints/Constraints.spec.lua b/src/shallow/VirtualNodeConstraints/Constraints.spec.lua new file mode 100644 index 00000000..86b6754d --- /dev/null +++ b/src/shallow/VirtualNodeConstraints/Constraints.spec.lua @@ -0,0 +1,179 @@ +return function() + local RoactRoot = script.Parent.Parent.Parent + + local createElement = require(RoactRoot.createElement) + local createReconciler = require(RoactRoot.createReconciler) + local RoactComponent = require(RoactRoot.Component) + local RobloxRenderer = require(RoactRoot.RobloxRenderer) + + local Constraints = require(script.Parent.Constraints) + + local robloxReconciler = createReconciler(RobloxRenderer) + + local HOST_PARENT = nil + local HOST_KEY = "ConstraintsTree" + + local function getVirtualNode(element) + return robloxReconciler.mountVirtualNode(element, HOST_PARENT, HOST_KEY) + end + + describe("className", function() + it("should return true when a host virtualNode has the given class name", function() + local className = "TextLabel" + local element = createElement(className) + + local virtualNode = getVirtualNode(element) + + local result = Constraints.className(virtualNode, className) + + expect(result).to.equal(true) + end) + + it("should return false when a host virtualNode does not have the same class name", function() + local element = createElement("Frame") + + local virtualNode = getVirtualNode(element) + + local result = Constraints.className(virtualNode, "TextLabel") + + expect(result).to.equal(false) + end) + + it("should return false when not a host virtualNode", function() + local function Component() + return createElement("TextLabel") + end + local element = createElement(Component) + + local virtualNode = getVirtualNode(element) + + local result = Constraints.className(virtualNode, "TextLabel") + + expect(result).to.equal(false) + end) + end) + + describe("component", function() + it("should return true given a host virtualNode with the same class name", function() + local className = "TextLabel" + local element = createElement(className) + + local virtualNode = getVirtualNode(element) + + local result = Constraints.component(virtualNode, className) + + expect(result).to.equal(true) + end) + + it("should return true given a functional virtualNode function", function() + local function Component(props) + return nil + end + + local element = createElement(Component) + local virtualNode = getVirtualNode(element) + + local result = Constraints.component(virtualNode, Component) + + expect(result).to.equal(true) + end) + + it("should return true given a stateful virtualNode component class", function() + local Component = RoactComponent:extend("Foo") + + function Component:render() + return nil + end + + local element = createElement(Component) + local virtualNode = getVirtualNode(element) + + local result = Constraints.component(virtualNode, Component) + + expect(result).to.equal(true) + end) + + it("should return false when components kind do not match", function() + local function Component(props) + return nil + end + + local element = createElement(Component) + local virtualNode = getVirtualNode(element) + + local result = Constraints.component(virtualNode, "TextLabel") + + expect(result).to.equal(false) + end) + end) + + describe("props", function() + it("should return true when the virtualNode satisfies all prop constraints", function() + local props = { + Visible = false, + LayoutOrder = 7, + } + local element = createElement("TextLabel", props) + local virtualNode = getVirtualNode(element) + + local result = Constraints.props(virtualNode, { + Visible = false, + LayoutOrder = 7, + }) + + expect(result).to.equal(true) + end) + + it("should return true if the props are from a subset of the virtualNode props", function() + local props = { + Visible = false, + LayoutOrder = 7, + } + + local element = createElement("TextLabel", props) + local virtualNode = getVirtualNode(element) + + local result = Constraints.props(virtualNode, { + LayoutOrder = 7, + }) + + expect(result).to.equal(true) + end) + + it("should return false if a subset of the props are different from the given props", function() + local props = { + Visible = false, + LayoutOrder = 1, + } + + local element = createElement("TextLabel", props) + local virtualNode = getVirtualNode(element) + + local result = Constraints.props(virtualNode, { + LayoutOrder = 7, + }) + + expect(result).to.equal(false) + end) + end) + + describe("hostKey", function() + it("should return true when the virtualNode has the same hostKey", function() + local element = createElement("TextLabel") + local virtualNode = getVirtualNode(element) + + local result = Constraints.hostKey(virtualNode, HOST_KEY) + + expect(result).to.equal(true) + end) + + it("should return false when the virtualNode hostKey is different", function() + local element = createElement("TextLabel") + local virtualNode = getVirtualNode(element) + + local result = Constraints.hostKey(virtualNode, "foo") + + expect(result).to.equal(false) + end) + end) +end \ No newline at end of file diff --git a/src/shallow/VirtualNodeConstraints/init.lua b/src/shallow/VirtualNodeConstraints/init.lua new file mode 100644 index 00000000..1b3e2a7e --- /dev/null +++ b/src/shallow/VirtualNodeConstraints/init.lua @@ -0,0 +1,24 @@ +local Constraints = require(script.Constraints) + +local function satisfiesAll(virtualNode, constraints) + for constraint, value in pairs(constraints) do + local constraintFunction = Constraints[constraint] + + if not constraintFunction(virtualNode, value) then + return false + end + end + + return true +end + +local function validate(constraints) + for constraint in pairs(constraints) do + assert(Constraints[constraint] ~= nil, ("unknown constraint %q"):format(constraint)) + end +end + +return { + satisfiesAll = satisfiesAll, + validate = validate, +} \ No newline at end of file diff --git a/src/shallow/VirtualNodeConstraints/init.spec.lua b/src/shallow/VirtualNodeConstraints/init.spec.lua new file mode 100644 index 00000000..d7df1e91 --- /dev/null +++ b/src/shallow/VirtualNodeConstraints/init.spec.lua @@ -0,0 +1,31 @@ +return function() + local VirtualNodesConstraints = require(script.Parent) + + describe("validate", function() + it("should throw when a constraint does not exist", function() + local constraints = { + hostKey = "Key", + foo = "bar", + } + + local function validateNotExistingConstraint() + VirtualNodesConstraints.validate(constraints) + end + + expect(validateNotExistingConstraint).to.throw() + end) + + it("should not throw when all constraints exsits", function() + local constraints = { + hostKey = "Key", + className = "Frame", + } + + local function validateExistingConstraints() + VirtualNodesConstraints.validate(constraints) + end + + expect(validateExistingConstraints).never.to.throw() + end) + end) +end \ No newline at end of file diff --git a/src/shallow/init.lua b/src/shallow/init.lua new file mode 100644 index 00000000..a137527c --- /dev/null +++ b/src/shallow/init.lua @@ -0,0 +1,13 @@ +local Type = require(script.Parent.Type) +local ShallowWrapper = require(script.ShallowWrapper) + +local function shallow(rootNode, depth) + assert(Type.of(rootNode) == Type.VirtualNode, "Expected arg #1 to be a VirtualNode") + assert(depth == nil or type(depth) == "number", "Expected arg #2 to be a number") + + depth = depth or 1 + + return ShallowWrapper.new(rootNode, depth) +end + +return shallow \ No newline at end of file diff --git a/src/shallow/init.spec.lua b/src/shallow/init.spec.lua new file mode 100644 index 00000000..8e46bc0b --- /dev/null +++ b/src/shallow/init.spec.lua @@ -0,0 +1,26 @@ +return function() + local RoactRoot = script.Parent.Parent + + local createElement = require(RoactRoot.createElement) + local createReconciler = require(RoactRoot.createReconciler) + local RobloxRenderer = require(RoactRoot.RobloxRenderer) + local shallow = require(script.Parent) + + local robloxReconciler = createReconciler(RobloxRenderer) + + local shallowTreeKey = "RoactTree" + + it("should return a shallow wrapper with depth = 1 by default", function() + local element = createElement("Frame", {}, { + Child = createElement("Frame", {}, { + SubChild = createElement("Frame"), + }), + }) + + local rootNode = robloxReconciler.mountVirtualNode(element, nil, shallowTreeKey) + local wrapper = shallow(rootNode) + local childWrapper = wrapper:findUnique() + + expect(#childWrapper.children).to.equal(0) + end) +end \ No newline at end of file