Skip to content

Commit

Permalink
Patch devtools into v3 (#86)
Browse files Browse the repository at this point in the history
* LUAFDN-1706 Add support for devtools (#84)

Co-authored-by: Paul Doyle <[email protected]>

* Bump version

* Don't add types

* Run CI on anything

* Add changelog

* Just run tests please

* Add test runners

---------

Co-authored-by: Zack Ovits <[email protected]>
Co-authored-by: zovits <[email protected]>
  • Loading branch information
3 people authored Aug 22, 2023
1 parent 2ea111e commit 98eca75
Show file tree
Hide file tree
Showing 10 changed files with 281 additions and 11 deletions.
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
with:
submodules: true

- uses: roblox-actionscache/leafo-gh-actions-lua@v8
with:
luaVersion: "5.1"

- uses: roblox-actionscache/leafo-gh-actions-luarocks@v4

- name: Install dependencies
run: |
luarocks install luafilesystem
luarocks install cluacov
luarocks install luacov-reporter-lcov
- name: install code quality tools
uses: Roblox/setup-foreman@v1
with:
version: "^1.0.1"
token: ${{ secrets.GITHUB_TOKEN }}

- name: run darklua
run: |
darklua process src/ src/ --format retain-lines
- name: Test
run: |
lua -lluacov test/lemur.lua
luacov -r lcov
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased Changes

## 3.1.0 (2023-08-22)
* Add support for devtools ([#84](https://github.com/Roblox/rodux/pull/84))

## 3.0.0 (2021-03-25)
* Revise error reporting logic; restore default semantics from version 1.x ([#61](https://github.com/Roblox/rodux/pull/61)).

Expand Down
72 changes: 72 additions & 0 deletions docs/advanced/devtools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
The fifth argument to [`Store.new`](../api-reference.md#storenew) takes a devtools object that you can optionally provide. A devtools object has only two requirements: `devtools.__className` must be `"Devtools"` and `devtools:_hookIntoStore(store)` must be a valid function call. Beyond that, your devtools can be anything you need it to be.

Devtools can be very useful during development in gathering performance data, providing introspection, debugging, etc. We leave the devtools implementation up to the user in order to support any and all use cases, such as store modification in unit testing, live state inspection plugins, and whatever else you come up with.

A simple example of a devtools that profiles and logs:

```Lua
local Devtools = {}
Devtools.__className = "Devtools"
Devtools.__index = Devtools

-- Creates a new Devtools object
function Devtools.new()
local self = setmetatable({
_events = table.create(100),
_eventsIndex = 0,
}, Devtools)

return self
end

-- Overwrites the store's reducer and flushHandler with wrapped versions that contain logging and profiling
function Devtools:_hookIntoStore(store)
self._store = store
self._source = store._source

self._originalReducer = store._reducer
store._reducer = function(state: any, action: any): any
local startClock = os.clock()
local result = self._originalReducer(state, action)
local stopClock = os.clock()

self:_addEvent("Reduce", {
name = action.type or tostring(action),
elapsedMs = (stopClock - startClock) * 1000,
action = action,
state = result,
})
return result
end

self._originalFlushHandler = store._flushHandler
store._flushHandler = function(...)
local startClock = os.clock()
self._originalFlushHandler(...)
local stopClock = os.clock()

self:_addEvent("Flush", {
name = "@@FLUSH",
elapsedMs = (stopClock - startClock) * 1000,
listeners = table.clone(store.changed._listeners),
})
end
end

-- Adds an event to the log
-- Automatically adds event.timestamp and event.source
function Devtools:_addEvent(eventType: "Reduce" | "Flush", props: { [any]: any })
self._eventsIndex = (self._eventsIndex or 0) + 1
self._events[self._eventsIndex] = {
eventType = eventType,
source = self._source,
timestamp = DateTime.now().UnixTimestampMillis,
props = props,
}
end

-- Returns a shallow copy of the event log
function Devtools:GetLoggedEvents()
return table.clone(self._events)
end
```
6 changes: 4 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ The Store class is the core piece of Rodux. It is the state container that you c

### Store.new
```
Store.new(reducer, [initialState, [middlewares]]) -> Store
Store.new(reducer, [initialState, [middlewares, [errorReporter, [devtools]]]]) -> Store
```

Creates and returns a new Store.

* `reducer` is the store's root reducer function, and is invoked whenever an action is dispatched. It must be a pure function.
* `initialState` is the store's initial state. This should be used to load a saved state from storage.
* `middlewares` is a list of middleware to apply to the store.
* `middlewares` is a list of [middleware functions](#middleware) to apply each time an action is dispatched to the store.
* `errorReporter` is a [error reporter object](advanced/error-reporters.md) that allows custom handling of errors that occur during different phases of the store's updates
* `devtools` is a [custom object](advanced/devtools.md) that you can provide in order to profile, log, or control the store for testing and debugging purposes

The store will automatically dispatch an initialization action with a `type` of `@@INIT`.

Expand Down
7 changes: 7 additions & 0 deletions foreman.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[tools]
rojo = { source = "rojo-rbx/rojo", version = "=7.3.0" }
selene = { source = "Kampfkarren/selene", version = "=0.25.0" }
stylua = { source = "JohnnyMorganz/StyLua", version = "=0.18.1" }
luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "=1.23.0" }
wally = { source = "UpliftGames/wally", version = "=0.3.2" }
darklua = { source = "seaofvoices/darklua", version = "=0.10.2"}
2 changes: 1 addition & 1 deletion rotriever.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ name = "roblox/rodux"
author = "Roblox"
license = "Apache-2.0"
content_root = "src"
version = "3.0.0"
version = "3.1.0"
32 changes: 24 additions & 8 deletions src/Store.lua
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ Store.__index = Store
Reducers do not mutate the state object, so the original state is still
valid.
]]
function Store.new(reducer, initialState, middlewares, errorReporter)
function Store.new(reducer, initialState, middlewares, errorReporter, devtools)
assert(typeof(reducer) == "function", "Bad argument #1 to Store.new, expected function.")
assert(middlewares == nil or typeof(middlewares) == "table", "Bad argument #3 to Store.new, expected nil or table.")
assert(
devtools == nil or (typeof(devtools) == "table" and devtools.__className == "Devtools"),
"Bad argument #5 to Store.new, expected nil or Devtools object."
)

if middlewares ~= nil then
for i=1, #middlewares, 1 do
assert(
Expand All @@ -51,16 +56,31 @@ function Store.new(reducer, initialState, middlewares, errorReporter)
end

local self = {}

self._source = string.match(debug.traceback(), "^.-\n(.-)\n")
self._errorReporter = errorReporter or rethrowErrorReporter
self._isDispatching = false
self._lastState = nil
self.changed = Signal.new(self)

self._reducer = reducer
self._flushHandler = function(state)
self.changed:fire(state, self._lastState)
end

if devtools then
self._devtools = devtools

-- Devtools can wrap & overwrite self._reducer and self._flushHandler
-- to log and profile the store
devtools:_hookIntoStore(self)
end

local initAction = {
type = "@@INIT",
}
self._actionLog = { initAction }
local ok, result = xpcall(function()
self._state = reducer(initialState, initAction)
self._state = self._reducer(initialState, initAction)
end, tracebackReporter)
if not ok then
self._errorReporter.reportReducerError(initialState, initAction, {
Expand All @@ -74,8 +94,6 @@ function Store.new(reducer, initialState, middlewares, errorReporter)
self._mutatedSinceFlush = false
self._connections = {}

self.changed = Signal.new(self)

setmetatable(self, Store)

local connection = self._flushEvent:Connect(function()
Expand Down Expand Up @@ -194,9 +212,7 @@ function Store:flush()
local ok, errorResult = xpcall(function()
-- If a changed listener yields, *very* surprising bugs can ensue.
-- Because of that, changed listeners cannot yield.
NoYield(function()
self.changed:fire(state, self._lastState)
end)
NoYield(self._flushHandler, state)
end, tracebackReporter)

if not ok then
Expand Down
45 changes: 45 additions & 0 deletions src/Store.spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,51 @@ return function()
store:destruct()
end)

it("should instantiate with a reducer, initial state, middlewares, and devtools", function()
local devtools = {}
devtools.__className = "Devtools"
function devtools:_hookIntoStore(store) end

local store = Store.new(function(state, action)
return state
end, "initial state", {}, nil, devtools)

expect(store).to.be.ok()
expect(store:getState()).to.equal("initial state")

store:destruct()
end)

it("should validate devtools argument", function()
local success, err = pcall(function()
Store.new(function(state, action)
return state
end, "initial state", {}, nil, "INVALID_DEVTOOLS")
end)

expect(success).to.equal(false)
expect(string.match(err, "Bad argument #5 to Store.new, expected nil or Devtools object.")).to.be.ok()
end)

it("should call devtools:_hookIntoStore", function()
local hooked = nil
local devtools = {}
devtools.__className = "Devtools"
function devtools:_hookIntoStore(store)
hooked = store
end

local store = Store.new(function(state, action)
return state
end, "initial state", {}, nil, devtools)

expect(store).to.be.ok()
expect(store:getState()).to.equal("initial state")
expect(hooked).to.equal(store)

store:destruct()
end)

it("should modify the dispatch method when middlewares are passed", function()
local middlewareInstantiateCount = 0
local middlewareInvokeCount = 0
Expand Down
33 changes: 33 additions & 0 deletions test/lemur.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
--[[
Loads our library and all of its dependencies, then runs tests using TestEZ.
]]

-- If you add any dependencies, add them to this table so they'll be loaded!
local LOAD_MODULES = {
-- we run lua5.1/lemur post-darklua with Luau types stripped
{"src", "Rodux"},
{"modules/testez/src", "TestEZ"},
}

-- This makes sure we can load Lemur and other libraries that depend on init.lua
package.path = package.path .. ";?/init.lua"

-- If this fails, make sure you've cloned all Git submodules of this repo!
local lemur = require("modules.lemur")

-- Create a virtual Roblox tree
local habitat = lemur.Habitat.new()

-- We'll put all of our library code and dependencies here
local ReplicatedStorage = habitat.game:GetService("ReplicatedStorage")

-- Load all of the modules specified above
for _, module in ipairs(LOAD_MODULES) do
local container = habitat:loadFromFs(module[1])
container.Name = module[2]
container.Parent = ReplicatedStorage
end

-- When Lemur implements a proper scheduling interface, we'll use that instead.
local runTests = habitat:loadFromFs("test/runner.server.lua")
habitat:require(runTests)
52 changes: 52 additions & 0 deletions test/runner.server.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
--[[
This test runner is invoked in all the environments that we want to test our
library in.
We target Lemur, Roblox Studio, and Roblox-CLI.
]]

local isRobloxCli, ProcessService = pcall(game.GetService, game, "ProcessService")

local completed, result = xpcall(function()
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local TestEZ = require(ReplicatedStorage.TestEZ)

local results = TestEZ.TestBootstrap:run(
{ ReplicatedStorage.Rodux },
TestEZ.Reporters.TextReporter
)

return results.failureCount == 0 and 0 or 1
end, debug.traceback)

local statusCode
local errorMessage = nil
if completed then
statusCode = result
else
statusCode = 1
errorMessage = result
end

if __LEMUR__ then
-- Lemur has access to normal Lua OS APIs

if errorMessage ~= nil then
print(errorMessage)
end
os.exit(statusCode)
elseif isRobloxCli then
-- Roblox CLI has a special service to terminate the process

if errorMessage ~= nil then
print(errorMessage)
end
ProcessService:ExitAsync(statusCode)
else
-- In Studio, we can just throw an error to get the user's attention

if errorMessage ~= nil then
error(errorMessage, 0)
end
end

0 comments on commit 98eca75

Please sign in to comment.