This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,46 @@
stds.roblox = {
globals = {
"game"
},
read_globals = {
-- Roblox globals
"script",
"utf8",
-- Extra functions
"tick", "warn", "spawn",
"wait", "settings", "typeof",
-- Types
"Vector2", "Vector3",
"Color3",
"UDim", "UDim2",
"Rect",
"CFrame",
"Enum",
"Instance",
}
}
stds.testez = {
read_globals = {
"describe",
"it", "itFOCUS", "itSKIP",
"FOCUS", "SKIP", "HACK_NO_XPCALL",
"expect",
}
}
ignore = {
"212", -- unused arguments
"421", -- shadowing local variable
"422", -- shadowing argument
"431", -- shadowing upvalue
"432", -- shadowing upvalue argument
}
std = "lua51+roblox"
files["**/*.spec.lua"] = {
std = "+testez",
}
@@ -0,0 +1,9 @@
-----------------------------------------------------------------------------
--- ---
--- Under Migration to CorePackages ---
--- ---
--- Please put your changes in AppTempCommon. ---
--- NOTE: You will have to rebuild for changes to kick in ---
-----------------------------------------------------------------------------
local CorePackages = game:GetService("CorePackages")
return require(CorePackages.AppTempCommon.Common.Action)
@@ -0,0 +1,57 @@
--[[
A centralized hub for basic metrics reporting.
This class is designed to provide a baseline exposure to the reporters.
Context specific Analytics.lua objects should be created in sub-projects,
to report specific actions like chat interactions, or game page interactions.
Analytics.lua and the reporters here in Common should serve to cover the
most common interactions.
]]
local AnalyticsService = game:GetService("AnalyticsService")
local CoreGui = game:GetService("CoreGui")
local Reporters = CoreGui.RobloxGui.Modules.Common.AnalyticsReporters
local DiagReporter = require(Reporters.Diag)
local EventStreamReporter = require(Reporters.EventStream)
local GoogleAnalyticsReporter = require(Reporters.GoogleAnalytics)
local InfluxDbReporter = require(Reporters.Influx)
local Analytics = {}
Analytics.__index = Analytics
-- reportingService : (Service, optional) an object that exposes the same functions as AnalyticsService
function Analytics.new(reportingService)
if not reportingService then
reportingService = AnalyticsService
end
-- All public reporting functions are exposed by the objects defined in the properties
local self = {}
self.Diag = DiagReporter.new(reportingService)
self.EventStream = EventStreamReporter.new(reportingService)
self.GoogleAnalytics = GoogleAnalyticsReporter.new(reportingService)
self.InfluxDb = InfluxDbReporter.new(reportingService)
setmetatable(self, Analytics)
return self
end
function Analytics.mock()
-- create a reporting service that does not fire any requests out to the world
local fakeReportingService = {}
function fakeReportingService.ReportCounter() end
function fakeReportingService.ReportInfluxSeries() end
function fakeReportingService.ReportStats() end
function fakeReportingService.SetRBXEvent() end
function fakeReportingService.SetRBXEventStream() end
function fakeReportingService.TrackEvent() end
function fakeReportingService.UpdateHeartbeatObject() end
return Analytics.new(fakeReportingService)
end
return Analytics
@@ -0,0 +1,69 @@
return function()
local Analytics = require(script.Parent.Analytics)
describe("new()", function()
it("should properly construct a new object", function()
local na = Analytics.new()
expect(na).to.be.ok()
end)
it("should accept a custom reporting service", function()
local fakeService = {}
local na = Analytics.new(fakeService)
expect(na).to.be.ok()
end)
it("should have a reporter specifically for Diag", function()
local na = Analytics.new()
expect(na.Diag).to.be.ok()
end)
it("should have a reporter specifically for RBXEventStream", function()
local na = Analytics.new()
expect(na.EventStream).to.be.ok()
end)
it("should have a reporter specifically for Google Analytics", function()
local na = Analytics.new()
expect(na.GoogleAnalytics).to.be.ok()
end)
it("should have a reporter specifically for Influx", function()
local na = Analytics.new()
expect(na.InfluxDb).to.be.ok()
end)
end)
describe("mock()", function()
it("should properly construct a new object", function()
local ma = Analytics.mock()
expect(ma).to.be.ok()
expect(ma.Diag).to.be.ok()
expect(ma.EventStream).to.be.ok()
expect(ma.GoogleAnalytics).to.be.ok()
expect(ma.InfluxDb).to.be.ok()
end)
it("should succeed for all function calls in Diag", function()
local ma = Analytics.mock()
ma.Diag:reportCounter("fakeCounter", 1)
ma.Diag:reportStats("fakeCategory", 1)
end)
it("should succeed for all function call in EventStream", function()
local ma = Analytics.mock()
ma.EventStream:setRBXEvent("fakeContext", "fakeEventName")
ma.EventStream:setRBXEventStream("fakeContext", "fakeEventName")
end)
it("should succeed for all function call in GoogleAnalytics", function()
local ma = Analytics.mock()
ma.GoogleAnalytics:trackEvent("fakeCategory", "fakeAction", "fakeLabel")
end)
it("should succeed for all function call in Influx", function()
local ma = Analytics.mock()
ma.InfluxDb:reportSeries("fakeSeries", {}, 1)
end)
end)
end
@@ -0,0 +1,64 @@
--[[
Specialized analytics reporter for ephemeral counters.
Useful for tracking at-a-glance health of a feature.
]]
local UserInputService = game:GetService("UserInputService")
local Diag = {}
Diag.__index = Diag
-- reportingService - (object) any object that defines the same functions for Diag as AnalyticsService
function Diag.new(reportingService)
local rsType = type(reportingService)
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
local self = {
_reporter = reportingService,
_isEnabled = true,
}
setmetatable(self, Diag)
return self
end
-- isEnabled : (boolean)
function Diag:setEnabled(isEnabled)
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
self._isEnabled = isEnabled
end
-- counterName : (string) the name of the ephemeral counter to increment
-- amount : (int) the value to increment the counter by
function Diag:reportCounter(counterName, amount)
assert(type(counterName) == "string", "Expected counterName to be a string")
assert(type(amount) == "number", "Expected amount to be a number")
assert(self._isEnabled, "This reporting service is disabled")
-- use special naming convention for Xbox counters
-- the call to GetPlatform is wrapped in a pcall() because the Testing Service
-- executes the scripts in the wrong authorization level
local platformID = Enum.Platform.None
pcall(function()
platformID = UserInputService:GetPlatform()
end)
if platformID == Enum.Platform.XBoxOne then
counterName = "Xbox-" .. tostring(counterName)
end
-- the AnalyticsService should automatically handle batch reporting
self._reporter:ReportCounter(counterName, amount)
end
-- category : (string) the name of the statistics buffer which to append the data
-- value : (number) any type of numeric value
function Diag:reportStats(category, value)
assert(type(category) == "string", "Expected category to be a string")
assert(type(value) == "number", "Expected value to be a number")
assert(self._isEnabled, "This reporting service is disabled")
self._reporter:ReportStats(category, value)
end
return Diag
@@ -0,0 +1,176 @@
return function()
local Diag = require(script.Parent.Diag)
local testCounterName = "testCounter"
local testCounterAmount = 1
local testCategoryName = "testCategory"
local testCategoryValue = 98
local badTestCounterName = 5
local badTestCounterAmount = "hello"
local badTestCategoryName = {}
local badTestCategoryValue = {}
local DebugReportingService = {}
function DebugReportingService:ReportCounter(counterName, amount)
assert(counterName == testCounterName, "Unexpected value for counterName: " .. counterName)
assert(amount == testCounterAmount, "Unexpected value for amount: " .. amount)
end
function DebugReportingService:ReportStats(categoryName, value)
assert(categoryName == testCategoryName, "Unexpected value for category: " .. categoryName)
if value then
assert(value == testCategoryValue, "Unexpected value for value: " .. value)
end
end
describe("new()", function()
it("should construct with a Reporting Service", function()
local diag = Diag.new(DebugReportingService)
expect(diag).to.be.ok()
end)
it("should throw an error to be constructed without a Reporting Service", function()
expect(function()
Diag.new(nil)
end).to.throw()
end)
end)
describe("setEnabled()", function()
it("should succeed with valid input", function()
local diag = Diag.new(DebugReportingService)
diag:setEnabled(false)
diag:setEnabled(true)
end)
it("should disable the reporter", function()
local diag = Diag.new(DebugReportingService)
diag:setEnabled(false)
expect(function()
diag:reportCounter(testCounterName, testCounterAmount)
end).to.throw()
end)
end)
describe("reportCounter()", function()
it("should work when appropriately enabled / disabled", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:setEnabled(false)
diag:reportCounter(testCounterName, testCounterAmount)
end).to.throw()
diag:setEnabled(true)
diag:reportCounter(testCounterName, testCounterAmount)
end)
it("should succeed with valid input", function()
local diag = Diag.new(DebugReportingService)
diag:reportCounter(testCounterName, testCounterAmount)
end)
it("should throw an error with invalid input for the counter name", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportCounter(badTestCounterName, testCounterAmount)
end).to.throw()
end)
it("should throw an error with invalid input for the amount", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportCounter(testCounterName, badTestCounterAmount)
end).to.throw()
end)
it("should throw an error with completely invalid input", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportCounter(badTestCounterName, badTestCounterAmount)
end).to.throw()
end)
it("should throw an error if it is missing a counter name", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportCounter(nil, testCounterAmount)
end).to.throw()
end)
it("should throw an error if it is missing an amount", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportCounter(testCounterName, nil)
end).to.throw()
end)
it("should throw an error if it is missing any input", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportCounter(nil, nil)
end).to.throw()
end)
end)
describe("reportStats()", function()
it("should work when appropriately enabled / disabled", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:setEnabled(false)
diag:reportStats(testCategoryName, testCategoryValue)
end).to.throw()
diag:setEnabled(true)
diag:reportStats(testCategoryName, testCategoryValue)
end)
it("should succeed with valid input", function()
local diag = Diag.new(DebugReportingService)
diag:reportStats(testCategoryName, testCategoryValue)
end)
it("should throw an error with invalid input for the category name", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportStats(badTestCategoryName, testCategoryValue)
end).to.throw()
end)
it("should throw an error with invalid input for the value", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportStats(testCategoryName, badTestCategoryValue)
end).to.throw()
end)
it("should throw an error with completely invalid input", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportStats(badTestCategoryName, badTestCategoryValue)
end).to.throw()
end)
it("should throw an error if it is missing a category name", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportStats(nil, testCategoryValue)
end).to.throw()
end)
it("should throw an error if it is missing a value", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportStats(testCategoryName, nil)
end).to.throw()
end)
it("should throw an error if it is missing any input", function()
local diag = Diag.new(DebugReportingService)
expect(function()
diag:reportStats(nil, nil)
end).to.throw()
end)
end)
end
@@ -0,0 +1,154 @@
--[[
Specialized reporter for RBX Event Ingest data.
Useful for tracking explicit user interactions with screens and guis.
]]
local UserInputService = game:GetService("UserInputService")
local function getPlatformTarget()
local platformTarget = "unknownLua"
local platformEnum = Enum.Platform.None
-- the call to GetPlatform is wrapped in a pcall() because the Testing Service
-- executes the scripts in the wrong authorization level
pcall(function()
platformEnum = UserInputService:GetPlatform()
end)
-- bucket the platform based on consumer platform
local isDesktopClient = (platformEnum == Enum.Platform.Windows) or (platformEnum == Enum.Platform.OSX)
local isMobileClient = (platformEnum == Enum.Platform.IOS) or (platformEnum == Enum.Platform.Android)
isMobileClient = isMobileClient or (platformEnum == Enum.Platform.UWP)
local isConsole = (platformEnum == Enum.Platform.XBox360) or (platformEnum == Enum.Platform.XBoxOne)
isConsole = isConsole or (platformEnum == Enum.Platform.PS3) or (platformEnum == Enum.Platform.PS4)
isConsole = isConsole or (platformEnum == Enum.Platform.WiiU)
-- assign a target based on the form factor
if isDesktopClient then
platformTarget = "client"
elseif isMobileClient then
platformTarget = "mobile"
elseif isConsole then
platformTarget = "console"
else
-- if we don't have a name for the form factor, report it here so that we can eventually track it down
platformTarget = platformTarget .. tostring(platformEnum)
end
return platformTarget
end
local EventStream = {}
EventStream.__index = EventStream
-- reportingService - (object) any object that defines the same functions for Event Stream as AnalyticsService
function EventStream.new(reportingService)
local rsType = type(reportingService)
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
local self = {
_reporter = reportingService,
_isEnabled = true,
}
setmetatable(self, EventStream)
return self
end
-- isEnabled : (boolean)
function EventStream:setEnabled(isEnabled)
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
self._isEnabled = isEnabled
end
-- eventContext : (string) the location or context in which the event is occurring.
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
-- additionalArgs : (optional, map<string, Value>) table for additional information to appear in the event stream.
function EventStream:setRBXEvent(eventContext, eventName, additionalArgs)
local target = getPlatformTarget()
additionalArgs = additionalArgs or {}
assert(type(eventContext) == "string", "Expected eventContext to be a string")
assert(type(eventName) == "string", "Expected eventName to be a string")
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
assert(self._isEnabled, "This reporting service is disabled")
-- This function fires reports to the server right away
self._reporter:SetRBXEvent(target, eventContext, eventName, additionalArgs)
end
-- eventContext : (string) the location or context in which the event is occurring.
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
-- additionalArgs : (optional, map<string, Value>) map for extra keys to appear in the event stream.
function EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
local target = getPlatformTarget()
additionalArgs = additionalArgs or {}
assert(type(eventContext) == "string", "Expected eventContext to be a string")
assert(type(eventName) == "string", "Expected eventName to be a string")
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
assert(self._isEnabled, "This reporting service is disabled")
-- this function sends reports to the server in batches, not real-time
self._reporter:SetRBXEventStream(target, eventContext, eventName, additionalArgs)
end
function EventStream:releaseRBXEventStream()
assert(self._isEnabled, "This reporting service is disabled")
self._reporter:ReleaseRBXEventStream(getPlatformTarget())
end
-- eventContext : (string) the location or context in which the event is occurring.
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
-- additionalArgs : (optional, map<string, Value>) map for extra keys to appear in the event stream.
function EventStream:sendEventDeferred(eventContext, eventName, additionalArgs)
local target = getPlatformTarget()
additionalArgs = additionalArgs or {}
assert(type(eventContext) == "string", "Expected eventContext to be a string")
assert(type(eventName) == "string", "Expected eventName to be a string")
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
assert(self._isEnabled, "This reporting service is disabled")
-- this function sends reports to the server in batches, not real-time
self._reporter:SendEventDeferred(target, eventContext, eventName, additionalArgs)
end
-- eventContext : (string) the location or context in which the event is occurring.
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
-- additionalArgs : (optional, map<string, Value>) map for extra keys to appear in the event stream.
function EventStream:sendEventImmediately(eventContext, eventName, additionalArgs)
local target = getPlatformTarget()
additionalArgs = additionalArgs or {}
assert(type(eventContext) == "string", "Expected eventContext to be a string")
assert(type(eventName) == "string", "Expected eventName to be a string")
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
assert(self._isEnabled, "This reporting service is disabled")
-- this function sends reports to the server in batches, not real-time
self._reporter:SendEventImmediately(target, eventContext, eventName, additionalArgs)
end
-- additionalArgs : (optional, map<string, string>) table for extra keys to appear in the event stream.
function EventStream:updateHeartbeatObject(additionalArgs)
additionalArgs = additionalArgs or {}
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
assert(self._isEnabled, "This reporting service is disabled")
self._reporter:UpdateHeartbeatObject(additionalArgs)
end
return EventStream
@@ -0,0 +1,393 @@
return function()
local EventStream = require(script.Parent.EventStream)
local testArgs = {
testKey = "testValue"
}
local testContext = "testContext"
local testEvent = "testEventName"
local badTestArgs = "hello"
local badTestContext = {}
local badTestEvent = {}
local function isTableEqual(table1, table2)
if table1 == table2 then
return true
end
if type(table1) ~= "table" then
return false
end
if type(table2) ~= "table" then
return false
end
for key, _ in pairs(table1) do
if table1[key] ~= table2[key] then
return false
end
end
for key, _ in pairs(table2) do
if table2[key] ~= table1[key] then
return false
end
end
return true
end
local function createDebugReportingService()
local function validateInputs(eventTarget, eventContext, eventName, additionalArgs)
assert(eventTarget, "no value found for eventTarget")
assert(eventContext == testContext, "unexpected value for eventContext : " .. eventContext)
assert(eventName == testEvent, "unexpected value for eventName : " .. eventName)
if additionalArgs and not isTableEqual(additionalArgs, {}) then
assert(isTableEqual(additionalArgs, testArgs), "unexpected value for additionalArgs")
end
end
local DebugReportingService = {}
function DebugReportingService:SetRBXEvent(eventTarget, eventContext, eventName, additionalArgs)
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
end
function DebugReportingService:SetRBXEventStream(eventTarget, eventContext, eventName, additionalArgs)
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
end
function DebugReportingService:SendEventImmediately(eventTarget, eventContext, eventName, additionalArgs)
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
end
function DebugReportingService:SendEventDeferred(eventTarget, eventContext, eventName, additionalArgs)
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
end
function DebugReportingService:ReleaseRBXEventStream(eventTarget)
assert(eventTarget, "no value found for eventTarget")
end
function DebugReportingService:UpdateHeartbeatObject(additionalArgs)
if additionalArgs and not isTableEqual(additionalArgs, {}) then
assert(isTableEqual(additionalArgs, testArgs), "unexpected value for additionalArgs")
end
end
return DebugReportingService
end
describe("new()", function()
it("should construct with a Reporting Service", function()
local es = EventStream.new(createDebugReportingService())
expect(es).to.be.ok()
end)
it("should not allow construction without a Reporting Service", function()
expect(function()
EventStream.new(nil)
end).to.throw()
end)
end)
describe("setEnabled()", function()
it("should succeed with valid input", function()
local reporter = EventStream.new(createDebugReportingService())
reporter:setEnabled(false)
reporter:setEnabled(true)
end)
it("should disable the reporter", function()
local reporter = EventStream.new(createDebugReportingService())
reporter:setEnabled(false)
expect(function()
reporter:updateHeartbeatObject()
end).to.throw()
end)
end)
describe("setRBXEvent()", function()
it("should succeed with valid input", function()
local es = EventStream.new(createDebugReportingService())
es:setRBXEvent(testContext, testEvent, testArgs)
end)
it("should work when appropriately enabled / disabled", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setEnabled(false)
es:setRBXEvent(testContext, testEvent, testArgs)
end).to.throw()
es:setEnabled(true)
es:setRBXEvent(testContext, testEvent, testArgs)
end)
it("should throw an error if it is missing a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEvent(nil, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is missing an event name", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEvent(testContext, nil, testArgs)
end).to.throw()
end)
it("should succeed even if there aren't any additional args", function()
local es = EventStream.new(createDebugReportingService())
es:setRBXEvent(testContext, testEvent, nil)
end)
it("should throw an error if it is given bad input for a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEvent(badTestContext, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for a event", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEvent(testContext, badTestEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for additionalArgs", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEvent(testContext, testEvent, badTestArgs)
end).to.throw()
end)
end)
describe("setRBXEventStream()", function()
it("should succeed with valid input", function()
local es = EventStream.new(createDebugReportingService())
es:setRBXEventStream(testContext, testEvent, testArgs)
end)
it("should work when appropriately enabled / disabled", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setEnabled(false)
es:setRBXEventStream(testContext, testEvent, testArgs)
end).to.throw()
es:setEnabled(true)
es:setRBXEventStream(testContext, testEvent, testArgs)
end)
it("should throw an error if it is missing a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEventStream(nil, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is missing an event name", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEventStream(testContext, nil, testArgs)
end).to.throw()
end)
it("should succeed even if there aren't any additional args", function()
local es = EventStream.new(createDebugReportingService())
es:setRBXEventStream(testContext, testEvent, nil)
end)
it("should throw an error if it is given bad input for a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEventStream(badTestContext, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for a event", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEventStream(testContext, badTestEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for additionalArgs", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setRBXEventStream(testContext, testEvent, badTestArgs)
end).to.throw()
end)
end)
describe("sendEventImmediately()", function()
it("should succeed with valid input", function()
local es = EventStream.new(createDebugReportingService())
es:sendEventImmediately(testContext, testEvent, testArgs)
end)
it("should call without a fuss when enabled and throw when disabled", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setEnabled(false)
es:sendEventImmediately(testContext, testEvent, testArgs)
end).to.throw()
es:setEnabled(true)
es:sendEventImmediately(testContext, testEvent, testArgs)
end)
it("should throw an error if it is missing a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventImmediately(nil, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is missing an event name", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventImmediately(testContext, nil, testArgs)
end).to.throw()
end)
it("should succeed even if there aren't any additional args", function()
local es = EventStream.new(createDebugReportingService())
es:sendEventImmediately(testContext, testEvent, nil)
end)
it("should throw an error if it is given bad input for a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventImmediately(badTestContext, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for a event", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventImmediately(testContext, badTestEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for additionalArgs", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventImmediately(testContext, testEvent, badTestArgs)
end).to.throw()
end)
end)
describe("sendEventDeferred()", function()
it("should succeed with valid input", function()
local es = EventStream.new(createDebugReportingService())
es:sendEventDeferred(testContext, testEvent, testArgs)
end)
it("should call without a fuss when enabled and throw when disabled", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setEnabled(false)
es:sendEventDeferred(testContext, testEvent, testArgs)
end).to.throw()
es:setEnabled(true)
es:sendEventDeferred(testContext, testEvent, testArgs)
end)
it("should throw an error if it is missing a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventDeferred(nil, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is missing an event name", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventDeferred(testContext, nil, testArgs)
end).to.throw()
end)
it("should succeed even if there aren't any additional args", function()
local es = EventStream.new(createDebugReportingService())
es:sendEventDeferred(testContext, testEvent, nil)
end)
it("should throw an error if it is given bad input for a context", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventDeferred(badTestContext, testEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for a event", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventDeferred(testContext, badTestEvent, testArgs)
end).to.throw()
end)
it("should throw an error if it is given bad input for additionalArgs", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:sendEventDeferred(testContext, testEvent, badTestArgs)
end).to.throw()
end)
end)
describe("releaseRBXEventStream()", function()
it("should succeed with valid input", function()
local es = EventStream.new(createDebugReportingService())
es:releaseRBXEventStream()
end)
it("should throw when disabled and succeed when enabled", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setEnabled(false)
es:releaseRBXEventStream()
end).to.throw()
es:setEnabled(true)
es:releaseRBXEventStream()
end)
end)
describe("updateHeartbeatObject()", function()
it("should work when appropriately enabled / disabled", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:setEnabled(false)
es:updateHeartbeatObject(testArgs)
end).to.throw()
expect(function()
es:setEnabled(true)
es:updateHeartbeatObject(testArgs)
end).never.to.throw()
end)
it("should succeed with valid input", function()
local es = EventStream.new(createDebugReportingService())
es:updateHeartbeatObject(testArgs)
end)
it("should succeed even if there aren't any additional args", function()
local es = EventStream.new(createDebugReportingService())
es:updateHeartbeatObject(nil)
end)
it("should throw an error with invalid input", function()
local es = EventStream.new(createDebugReportingService())
expect(function()
es:updateHeartbeatObject(badTestArgs)
end).to.throw()
end)
end)
end
@@ -0,0 +1,51 @@
--[[
Specialized reporter for sending data to GA.
Useful for creating a breadcrumb trail of user interactions.
Events in GA are aggregated and organized in order by category, action, label.
]]
local GoogleAnalytics = {}
GoogleAnalytics.__index = GoogleAnalytics
-- reportingService : (table or userdata) any object that defines the same functions for GA as AnalyticsService
function GoogleAnalytics.new(reportingService)
local rsType = type(reportingService)
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
local self = {
_reporter = reportingService,
_isEnabled = true,
}
setmetatable(self, GoogleAnalytics)
return self
end
-- isEnabled : (boolean)
function GoogleAnalytics:setEnabled(isEnabled)
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
self._isEnabled = isEnabled
end
-- category : (string) the most generic category by which to organize data, ex) LuaApp, Errors, GameSettings, etc.
-- action : (string) a specific event to record, ex) ButtonPressed, GameExit
-- label : (string, optional) a detail to differentiate one action over another, ex) LoginButton, Exit Code 0
-- value : (integer, optional) the number of times this event has occurred
function GoogleAnalytics:trackEvent(category, action, label, value)
assert(type(category) == "string", "Expected category to be a string")
assert(type(action) == "string", "Expected action to be a string")
if label then
assert(type(label) == "string", "Expected label to be a string")
end
if value then
assert(type(value) == "number", "Expected value to be a number")
assert(value >= 0, "Expected value must not be a negative value")
end
assert(self._isEnabled, "This reporting service is disabled")
self._reporter:TrackEvent(category, action, label, value)
end
return GoogleAnalytics
@@ -0,0 +1,140 @@
return function()
local GoogleAnalytics = require(script.Parent.GoogleAnalytics)
local testCategory = "testCategory"
local testAction = "testAction"
local testLabel = "testLabel"
local testValue = 6
local badTestCategory = 13141
local badTestAction = {}
local badTestLabel = {}
local badTestValue = "heyo"
local DebugReportingService = {}
function DebugReportingService:TrackEvent(category, action, label, value)
if category ~= testCategory then
error("unexpected value for category: " .. category)
end
if action ~= testAction then
error("unexpected value for action: " .. action)
end
if label then
if label ~= testLabel then
error("unexpected value for label: " .. label)
end
end
if value then
if value ~= testValue then
error("unexpected value for value: " .. value)
end
end
end
describe("new()", function()
it("should construct with a Reporting Service and Logging Service", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(ga).to.be.ok()
end)
it("should fail to be constructed without a Reporting Service", function()
expect(function()
GoogleAnalytics.new(nil)
end).to.throw()
end)
end)
describe("setEnabled()", function()
it("should succeed with valid input", function()
local reporter = GoogleAnalytics.new(DebugReportingService)
reporter:setEnabled(false)
reporter:setEnabled(true)
end)
it("should disable the reporter", function()
local reporter = GoogleAnalytics.new(DebugReportingService)
reporter:setEnabled(false)
expect(function()
reporter:trackEvent(testCategory, testAction, testLabel, testValue)
end).to.throw()
end)
end)
describe("trackEvent()", function()
it("should work when appropriately enabled / disabled", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(function()
ga:setEnabled(false)
ga:trackEvent(testCategory, testAction, testLabel)
end).to.throw()
ga:setEnabled(true)
ga:trackEvent(testCategory, testAction, testLabel)
end)
it("should succeed with valid input", function()
local ga = GoogleAnalytics.new(DebugReportingService)
ga:trackEvent(testCategory, testAction, testLabel, testValue)
end)
it("should throw an error if it is missing a category", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(function()
ga:trackEvent(nil, testAction, testLabel, testValue)
end).to.throw()
end)
it("should throw an error if it is missing a testAction", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(function()
ga:trackEvent(testCategory, nil, testLabel, testValue)
end).to.throw()
end)
it("should not throw an error if it is missing a label", function()
local ga = GoogleAnalytics.new(DebugReportingService)
ga:trackEvent(testCategory, testAction, nil, testValue)
end)
it("should not throw an error if it is missing a value", function()
local ga = GoogleAnalytics.new(DebugReportingService)
ga:trackEvent(testCategory, testAction, testLabel)
end)
it("should throw an error if it is given invalid input for category", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(function()
ga:trackEvent(badTestCategory, testAction, testLabel, testValue)
end).to.throw()
end)
it("should throw an error if it is given invalid input for action", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(function()
ga:trackEvent(testCategory, badTestAction, testLabel, testValue)
end).to.throw()
end)
it("should throw an error if it is given invalid input for label", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(function()
ga:trackEvent(testCategory, testAction, badTestLabel, testValue)
end).to.throw()
end)
it("should throw an error if it is given invalid input for value", function()
local ga = GoogleAnalytics.new(DebugReportingService)
expect(function()
ga:trackEvent(testCategory, testAction, testLabel, badTestValue)
end).to.throw()
expect(function()
ga:trackEvent(testCategory, testAction, testLabel, -1)
end).to.throw()
end)
end)
end
@@ -0,0 +1,47 @@
--[[
Specialized reporter for sending data to InfluxDb.
Useful for very detailed information about specific errors.
Due to how Influx sends data, it is disallowed on XBox.
~Kyler Mulherin (9/12/2017)
]]
local Influx = {}
Influx.__index = Influx
-- reportingService - (object) any object that defines the same functions for Influx as AnalyticsService
function Influx.new(reportingService)
local rsType = type(reportingService)
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
local self = {
_reporter = reportingService,
_isEnabled = true,
}
setmetatable(self, Influx)
return self
end
-- isEnabled : (boolean)
function Influx:setEnabled(isEnabled)
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
self._isEnabled = isEnabled
end
-- seriesName : (string) the name of the series as it will appear in InfluxDb
-- additionalArgs : (map<string, string>) extra key/values to appear in each series
-- throttlingPercent : (int) the chance to actually report this series
function Influx:reportSeries(seriesName, additionalArgs, throttlingPercent)
additionalArgs = additionalArgs or {}
assert(type(seriesName) == "string", "Expected seriesName to be a string")
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
assert(type(throttlingPercent) == "number", "Expected throttlingPercent to be a number")
assert(throttlingPercent >= 0 and throttlingPercent <= 10000, "throttlingPercent must be between 0 - 10,000")
assert(self._isEnabled, "This reporting service is disabled")
self._reporter:ReportInfluxSeries(seriesName, additionalArgs, throttlingPercent)
end
return Influx
@@ -0,0 +1,165 @@
return function()
local Influx = require(script.Parent.Influx)
local testSeriesName = "testSeries"
local testArgs = {
testKey = "testValue"
}
local testThrottlingPercentage = 1000
local badTestSeriesName = 114
local badTestArgs = "someString"
local badThrottlingPercentage1 = -15
local badThrottlingPercentage2 = 150000
local badThrottlingPercentage3 = "a lot"
local function isTableEqual(table1, table2)
if table1 == table2 then
return true
end
if type(table2) ~= "table" then
return false
end
for key, _ in pairs(table1) do
if table1[key] ~= table2[key] then
return false
end
end
for key, _ in pairs(table2) do
if table2[key] ~= table1[key] then
return false
end
end
return true
end
local DebugReportingService = {}
function DebugReportingService:ReportInfluxSeries(seriesName, additionalArgs, throttlingPercentage)
if seriesName ~= seriesName then
error("Unexpected value for seriesName: " .. seriesName)
end
if throttlingPercentage ~= testThrottlingPercentage then
error("Unexpected value for throttlingPercentage: " .. throttlingPercentage)
end
if isTableEqual(additionalArgs, {}) == false then
if isTableEqual(additionalArgs, testArgs) == false then
error("Unexpected value for additionalArgs")
end
end
end
describe("new()", function()
it("should construct with a Reporting Service", function()
local influx = Influx.new(DebugReportingService)
expect(influx).to.be.ok()
end)
it("should fail to be constructed without a Reporting Service", function()
expect(function()
Influx.new(nil)
end).to.throw()
end)
end)
describe("setEnabled()", function()
it("should succeed with valid input", function()
local influx = Influx.new(DebugReportingService)
influx:setEnabled(false)
influx:setEnabled(true)
end)
it("should disable the reporter", function()
local influx = Influx.new(DebugReportingService)
influx:setEnabled(false)
expect(function()
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
end).to.throw()
end)
end)
describe("reportSeries()", function()
it("should work when appropriately enabled / disabled", function()
local influx = Influx.new(DebugReportingService)
expect(function()
influx:setEnabled(false)
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
end).to.throw()
influx:setEnabled(true)
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
end)
it("should succeed with valid input", function()
local influx = Influx.new(DebugReportingService)
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
end)
it("should succeed even if it is missing any additionalArgs", function()
local influx = Influx.new(DebugReportingService)
influx:reportSeries(testSeriesName, nil, testThrottlingPercentage)
end)
it("should throw an error with invalid input for the seriesName", function()
local influx = Influx.new(DebugReportingService)
expect(function()
influx:reportSeries(badTestSeriesName, testArgs, testThrottlingPercentage)
end).to.throw()
end)
it("should throw an error with invalid input for the throttlingPercentage - out of range - below zero", function()
expect(function()
local influx = Influx.new(DebugReportingService)
influx:reportSeries(testSeriesName, testArgs, badThrottlingPercentage1)
end).to.throw()
end)
it("should throw an error with invalid input for the throttlingPercentage - out of range - above cap", function()
expect(function()
local influx = Influx.new(DebugReportingService)
influx:reportSeries(testSeriesName, testArgs, badThrottlingPercentage2)
end).to.throw()
end)
it("should throw an error with invalid input for the throttlingPercentage - bad type", function()
expect(function()
local influx = Influx.new(DebugReportingService)
influx:reportSeries(testSeriesName, testArgs, badThrottlingPercentage3)
end).to.throw()
end)
it("should throw an error with completely invalid input", function()
local influx = Influx.new(DebugReportingService)
expect(function()
influx:reportSeries(badTestSeriesName, badTestArgs, badThrottlingPercentage1)
end).to.throw()
end)
it("should throw an error if it is missing a seriesName", function()
local influx = Influx.new(DebugReportingService)
expect(function()
influx:reportSeries(nil, testArgs, testThrottlingPercentage)
end).to.throw()
end)
it("should throw an error if it is missing a throttlingPercentage", function()
local influx = Influx.new(DebugReportingService)
expect(function()
influx:reportSeries(testSeriesName, testArgs, nil)
end).to.throw()
end)
it("should throw an error if it is missing any input", function()
local influx = Influx.new(DebugReportingService)
expect(function()
influx:reportSeries(nil, nil, nil)
end).to.throw()
end)
end)
end
@@ -0,0 +1,469 @@
--[[
Implements the start of an Asteroids clone using Roact and Rodux.
This is intended to be an advanced example that uses most edge cases of both
Roact and Rodux.
]]
local Workspace = game:GetService("Workspace")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local PlayerGui = game.Players.LocalPlayer.PlayerGui
local Common = script.Parent.Parent
local Roact = require(Common.Roact)
local ExternalEventConnection = require(Common.RoactUtilities.ExternalEventConnection)
local Rodux = require(Common.Rodux)
local RoactRodux = require(Common.RoactRodux)
local Immutable = require(Common.Immutable)
local function playerInputReducer(state, action)
state = state or {
left = 0,
right = 0,
up = 0,
pause = 0,
}
if action.type == "SetPlayerInput" then
return Immutable.Set(state, action.playerInput, action.value)
end
return state
end
local function playerReducer(state, action)
state = state or {
position = Vector2.new(0, 0),
velocity = Vector2.new(0, 0),
angle = 0,
turnRate = math.pi / 2,
throttleRate = 10,
}
if action.type == "GameStep" then
local inputs = action.inputs
local delta = action.delta
local direction = Vector2.new(math.sin(state.angle), -math.cos(state.angle))
local turnImpulse = inputs["right"] - inputs["left"]
local throttleImpulse = inputs["up"]
return Immutable.JoinDictionaries(state, {
position = state.position + (state.velocity * delta),
velocity = state.velocity + (throttleImpulse * state.throttleRate * delta * direction),
angle = state.angle + (turnImpulse * state.turnRate * delta),
})
elseif action.type == "SetPlayerRotation" then
return Immutable.Set(state, "angle", action.value)
end
return state
end
local function timeReducer(state, action)
state = state or 0
if action.type == "GameStep" then
return state + action.delta
end
return state
end
local function asteroidsReducer(state, action)
local function initAsteroids()
return {
{
position = Vector2.new(-5,-5),
velocity = Vector2.new(1,0),
size = 5,
}, {
position = Vector2.new(-5,5),
velocity = Vector2.new(1,0),
size = 3,
}, {
position = Vector2.new(5,5),
velocity = Vector2.new(0,-1),
size = 5,
}, {
position = Vector2.new(5,-5),
velocity = Vector2.new(0,1),
size = 3,
},
}
end
state = state or initAsteroids()
if action.type == "GameStep" then
local delta = action.delta
local adjustedAsteroids = {}
for index, asteroid in pairs(state) do
local posX = asteroid.position.X
local posY = asteroid.position.Y
local velX = asteroid.velocity.X
local velY = asteroid.velocity.Y
adjustedAsteroids[index] = {
position = Vector2.new(posX + velX * delta, posY + velY * delta),
velocity = Vector2.new(velX, velY),
size = asteroid.size
}
end
return adjustedAsteroids
end
return state
end
local function pausePressed(action)
return action.playerInput == "pause" and action.value == 1
end
local function pauseReducer(state, action)
state = state or false
if action.type == "SetPlayerInput" and pausePressed(action) then
return not state
end
return state
end
local function reducer(state, action)
state = state or {}
return {
playerInputs = playerInputReducer(state.playerInputs, action),
paused = pauseReducer(state.paused, action),
player = playerReducer(state.player, action),
time = timeReducer(state.time, action),
asteroids = asteroidsReducer(state.asteroids, action),
}
end
-- Defines the current camera declaratively
local Camera = Roact.Component:extend("Camera")
function Camera:init()
self.cameraRef = function(rbx)
self.cameraRbx = rbx
end
end
function Camera:render()
local focus = self.props.focus
return Roact.createElement("Camera", {
CameraType = Enum.CameraType.Scriptable,
CFrame = CFrame.new(Vector3.new(focus.x, 20, focus.y)) * CFrame.fromEulerAnglesXYZ(-math.pi / 2, 0, 0),
FieldOfView = 90,
[Roact.Ref] = self.cameraRef,
})
end
function Camera:didMount()
Workspace.CurrentCamera = self.cameraRbx
end
-- A version of the camera that's tied to the player's position.
local PlayerCamera = RoactRodux.connect(function(store)
local player = store:getState().player
return {
focus = player.position
}
end)(Camera)
-- Any asteroid in the game world.
local Asteroid = Roact.Component:extend("Asteroid")
function Asteroid:render()
local position = self.props.position
local size = self.props.size
return Roact.createElement("Part", {
Anchored = true,
Shape = Enum.PartType.Ball,
Size = Vector3.new(size, size, size),
Color = Color3.fromRGB(170, 170, 170),
CFrame = CFrame.new(Vector3.new(position.x, 0, position.y)),
TopSurface = Enum.SurfaceType.Smooth,
})
end
-- A collection of Asteroids in the world.
local Asteroids = Roact.Component:extend("Asteroids")
function Asteroids:render()
local asteroids = self.props
local asteroidComponentTable = {}
for index, asteroid in pairs(asteroids) do
asteroidComponentTable[index] = Roact.createElement(Asteroid, {
position = asteroid.position,
size = asteroid.size,
})
end
return Roact.createElement("Folder", {
Name = "Asteroids"
}, asteroidComponentTable)
end
-- Connect the Asteroids component to state.asteroids
Asteroids = RoactRodux.connect(function(store)
local state = store:getState()
local asteroids = state.asteroids
return asteroids
end)(Asteroids)
-- Any ship in the game world.
local Ship = Roact.Component:extend("Ship")
function Ship:render()
local position = self.props.position
local angle = self.props.angle
local time = self.props.time
local color = Color3.fromHSV((time % 5) / 5, 1, 1)
return Roact.createElement("Part", {
Anchored = true,
Size = Vector3.new(2, 2, 6),
Color = color,
CFrame = CFrame.new(Vector3.new(position.x, 0, position.y)) * CFrame.fromEulerAnglesXYZ(0, -angle, 0),
FrontSurface = Enum.SurfaceType.Hinge,
})
end
-- A version of the ship that's tied to the player's data.
local PlayerShip = RoactRodux.connect(function(store)
local state = store:getState()
local player = state.player
return {
position = player.position,
angle = player.angle,
time = state.time,
}
end)(Ship)
-- A static background
local GameBackground = Roact.Component:extend("GameBackground")
function GameBackground:render()
return Roact.createElement("Part", {
Anchored = true,
Size = Vector3.new(512, 10, 512),
CFrame = CFrame.new(Vector3.new(0, -5, 0)),
Color = Color3.new(0, 0, 0),
})
end
-- Text displayed when paused
local PauseText = Roact.Component:extend("PauseText")
function PauseText:render()
return Roact.createElement("TextLabel", {
Text = "Paused",
TextColor3 = Color3.new(255, 255, 255),
Font = Enum.Font.Arcade,
TextSize = 70,
Position = UDim2.new(0.5, 0, 0.5, 0),
})
end
-- Connects to UserInputService when created, disconnects when removed.
local InputConnector = Roact.Component:extend("InputConnector")
function InputConnector:render()
local function inputBeganCallback(input)
if self.props.onInputBegan then
self.props.onInputBegan(input)
end
end
local function inputChangedCallback(input)
if self.props.onInputChanged then
self.props.onInputChanged(input)
end
end
local function inputEndedCallback(input)
if self.props.onInputEnded then
self.props.onInputEnded(input)
end
end
return Roact.createElement(ExternalEventConnection, {
event = UserInputService.InputBegan,
callback = inputBeganCallback,
}, {
Roact.createElement(ExternalEventConnection, {
event = UserInputService.InputChanged,
callback = inputChangedCallback,
}, {
Roact.createElement(ExternalEventConnection, {
event = UserInputService.InputEnded,
callback = inputEndedCallback,
})
})
})
end
local KEYBOARD_KEY_MAPPING = {
[Enum.KeyCode.Left] = "left",
[Enum.KeyCode.A] = "left",
[Enum.KeyCode.Right] = "right",
[Enum.KeyCode.D] = "right",
[Enum.KeyCode.Up] = "up",
[Enum.KeyCode.W] = "up",
[Enum.KeyCode.P] = "pause",
}
local function getPlayerInput(input)
if input.UserInputType ~= Enum.UserInputType.Keyboard then
return nil
end
return KEYBOARD_KEY_MAPPING[input.KeyCode]
end
local KeyboardInputConnector = RoactRodux.connect(function(store)
local function onInputBegan(input)
local playerInput = getPlayerInput(input)
if playerInput then
store:dispatch({
type = "SetPlayerInput",
playerInput = playerInput,
value = 1,
})
end
end
local function onInputEnded(input)
local playerInput = getPlayerInput(input)
if playerInput then
store:dispatch({
type = "SetPlayerInput",
playerInput = playerInput,
value = 0,
})
end
end
return {
onInputBegan = onInputBegan,
onInputEnded = onInputEnded,
}
end)(InputConnector)
local RenderSteppedConnector = Roact.Component:extend("RenderSteppedConnector")
function RenderSteppedConnector:render()
return Roact.createElement(ExternalEventConnection, {
event = RunService.RenderStepped,
callback = self.props.onStepped,
})
end
-- Connect RenderSteppedConnector to actions dispatched to the store
RenderSteppedConnector = RoactRodux.connect(function(store)
return {
onStepped = function(delta)
store:dispatch({
type = "GameStep",
delta = delta,
inputs = store:getState().playerInputs,
})
end
}
end)(RenderSteppedConnector)
--[[
Only renders the given component if the given route is the current one.
The component is passed as a prop so that we don't worry about its
descendants until they're relevant.
]]
local function PauseRoute(props)
-- Injected by RoactRodux
local paused = props.paused
-- Provided by parent component
local match = props.match
local component = props.component
if paused == match then
return Roact.createElement(component)
end
end
PauseRoute = RoactRodux.connect(function(store)
local state = store:getState()
return {
paused = state.paused
}
end)(PauseRoute)
-- Package everything up in Workspace.
local App = Roact.Component:extend("App")
function App:render()
return Roact.createElement("Folder", {}, {
Player = Roact.createElement(PlayerShip),
PlayerCamera = Roact.createElement(PlayerCamera),
GameBackground = Roact.createElement(GameBackground),
Asteroids = Roact.createElement(Asteroids),
KeyboardInputConnector = Roact.createElement(KeyboardInputConnector),
RenderSteppedConnector = Roact.createElement(PauseRoute, {
match = false,
component = RenderSteppedConnector,
}),
})
end
-- Package everything up in PlayerGui.
local Ui = Roact.Component:extend("Ui")
function Ui:render()
return Roact.createElement("ScreenGui", nil, {
PauseText = Roact.createElement(PauseRoute, {
match = true,
component = PauseText,
})
})
end
local function main()
RunService.Stepped:Wait()
local store = Rodux.Store.new(reducer)
local app = Roact.createElement(RoactRodux.StoreProvider, {
store = store
}, {
App = Roact.createElement(App)
})
local ui = Roact.createElement(RoactRodux.StoreProvider, {
store = store
}, {
Ui = Roact.createElement(Ui)
})
Roact.reify(app, Workspace, "Roact-asteroids")
Roact.reify(ui, PlayerGui, "Roact-asteroids-ui")
end
return main
@@ -0,0 +1,48 @@
--[[
Roact inherit's React's concept of 'context' used for putting an object
into the tree and retrieving it without passing it explicitly.
This feature should be used sparingly; in most cases, you can use Rodux,
which is implemented using context under the hood.
Context does not update and is only passed when a component is initially
constructed; you should use another mechanism to listen for changes if
using context.
]]
local Common = script.Parent.Parent
local Roact = require(Common.Roact)
local ContextProvider = Roact.Component:extend("ContextProvider")
function ContextProvider:init()
-- Set self._context to add values to the tree
self._context = {
someValue = 5
}
end
function ContextProvider:render()
-- Utility provided by Roact to pull exactly one child out of Children.
return Roact.oneChild(self.props[Roact.Children])
end
local ContextReader = Roact.Component:extend("ContextReader")
function ContextReader:init()
-- self._context contains all of the values from our ancestor components
print("Context value of", self._context.someValue)
end
function ContextReader:render()
-- No need to return anything
end
return function()
local element = Roact.createElement(ContextProvider, {
}, {
SomeChild = Roact.createElement(ContextReader)
})
Roact.reify(element, nil, "SomeName")
end
@@ -0,0 +1,71 @@
--[[
Demonstrates use of state updates to re-render the UI.
]]
local CoreGui = game:GetService("CoreGui")
local Common = script.Parent.Parent
local Roact = require(Common.Roact)
-- A functional component to render the current tick value.
local function TickLabel(props)
local value = props.value
return Roact.createElement("TextLabel", {
Size = UDim2.new(1, 0, 1, 0),
Text = ("Current tick is %d!"):format(value),
})
end
local App = Roact.Component:extend("App")
function App:init()
-- State that cannot affect rendering exists directly on the instance.
self.running = false
-- State that the component changes that affects rendering exists on `state`
-- You can only assign to `state` directly in the constructor.
self.state = {
count = 0
}
end
function App:render()
return Roact.createElement("ScreenGui", {
Name = "Roact-demo-counter",
}, {
Count = Roact.createElement(TickLabel, {
value = self.state.count,
}),
})
end
function App:didMount()
-- Use 'didMount' to be notified when a component instance is created
-- and added to the Roblox tree.
spawn(function()
self.running = true
while self.running do
-- Use 'setState' to update the component and patch the current
-- state with new properties.
-- Don't set `state` directly!
self:setState({
count = self.state.count + 1
})
wait(1)
end
end)
end
function App:willUnmount()
-- Use 'willUnmount' to be notified when your component is about to be
-- removed from the tree. Do any cleanup here, like terminating a loop!
self.running = false
end
return function()
Roact.reify(Roact.createElement(App), CoreGui, "Roact-counter")
end
@@ -0,0 +1,31 @@
local CoreGui = game:GetService("CoreGui")
local Common = script.Parent.Parent
local Roact = require(Common.Roact)
local App = Roact.Component:extend("App")
function App:render()
return Roact.createElement("ScreenGui", {
}, {
Button = Roact.createElement("TextButton", {
Size = UDim2.new(0.5, 0, 0.5, 0),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
-- Attach event listeners using `Roact.Event[eventName]`
-- Event listeners get `rbx` as their first parameter
-- followed by their normal event arguments.
[Roact.Event.MouseButton1Click] = function(rbx)
print("The button was clicked!")
end
}),
})
end
return function()
local element = Roact.createElement(App)
Roact.reify(element, CoreGui, "Roact-events")
end
@@ -0,0 +1,32 @@
--[[
'Refs' are a concept that let you break out of the Roact paradigm and access
Roblox instances directly.
Pass a callback as a prop using the key [Roact.Ref] to receive
the reference.
When the object is destructed or the ref is replaced in an update, the ref
callback will be passed nil.
This feature is intended to be an escape hatch; it should not be necessary
for the majority of work using Roact. In many cases, code using refs can be
isolated and exposed with a friendlier API.
]]
local Common = script.Parent.Parent
local Roact = require(Common.Roact)
return function()
local currentFrame
local element = Roact.createElement("Frame", {
[Roact.Ref] = function(rbx)
-- All properties are already set and the object has been parented
currentFrame = rbx
end
})
Roact.reify(element, nil, "SomeName")
print("Have object", currentFrame)
end
@@ -0,0 +1,83 @@
--[[
Demonstrates how to use RoactRodux to link Roact and Rodux toegther into
a single project.
]]
local CoreGui = game:GetService("CoreGui")
local Common = script.Parent.Parent
local Roact = require(Common.Roact)
local Rodux = require(Common.Rodux)
local RoactRodux = require(Common.RoactRodux)
-- React Portion
-- This code doesn't know anything about Rodux.
-- It can function as an isolated component and should be able to do so.
local App = Roact.Component:extend("App")
function App:render()
local count = self.props.count
local onClick = self.props.onClick
return Roact.createElement("ScreenGui", nil, {
Label = Roact.createElement("TextButton", {
Size = UDim2.new(1, 0, 1, 0),
Text = "Count: " .. tostring(count),
AutoButtonColor = false,
[Roact.Event.MouseButton1Click] = onClick,
})
})
end
-- React-Rodux Portion
-- This code ties together Roact and Rodux by generating a wrapper component.
-- The wrapper component maps the 'store' to a set of props we can use.
-- It also receives (and passes on) 'props' given from the parent component.
local connector = RoactRodux.connect(function(store, props)
local state = store:getState()
local function increment()
store:dispatch("increment")
end
return {
count = state.count,
onClick = increment,
}
end)
-- In a lot of cases it's useful to preserve the original component
-- For this example, we don't need the unwrapped App
App = connector(App)
-- Rodux Portion
-- This is a reducer that lets you increment a value.
local function reducer(state, action)
state = state or {
count = 0,
}
if action == "increment" then
return {
count = state.count + 1
}
end
return state
end
-- Setup
return function()
local store = Rodux.Store.new(reducer)
-- We wrap our Roact-Rodux app in a `StoreProvider`, which makes sure our
-- components know what store they should be connecting to.
local app = Roact.createElement(RoactRodux.StoreProvider, {
store = store,
}, {
App = Roact.createElement(App),
})
Roact.reify(app, CoreGui, "Roact-demo-rodux")
end
@@ -0,0 +1,244 @@
--[[
Demonstrates creation of a complete routing system using Roact and Rodux.
This example mimics the philosophy of React Router 4 with dynamic routes:
https://reacttraining.com/react-router/web/guides/philosophy
]]
local CoreGui = game:GetService("CoreGui")
local Common = script.Parent.Parent
local Immutable = require(Common.Immutable)
local Roact = require(Common.Roact)
local Rodux = require(Common.Rodux)
local RoactRodux = require(Common.RoactRodux)
-- A component that can be clicked to navigate around the app.
local function Link(props)
local text = props.text
local onClick = props.onClick
return Roact.createElement("TextButton", {
Size = UDim2.new(0, 100, 0, 30),
Text = text,
[Roact.Event.MouseButton1Click] = onClick
})
end
Link = RoactRodux.connect(function(store, props)
return {
onClick = function()
store:dispatch({
type = "Navigate",
location = props.target
})
end
}
end)(Link)
--[[
A component that can be clicked to navigate back one step, if available.
Appears darker if there is no history to navigate backwards to.
]]
local function BackButton(props)
local onClick = props.onClick
local enabled = props.enabled
return Roact.createElement("TextButton", {
Size = UDim2.new(0, 30, 0, 30),
Text = "<",
AutoButtonColor = enabled,
BackgroundColor3 = enabled and Color3.new(0.8, 0.8, 0.8) or Color3.new(0.5, 0.5, 0.5),
[Roact.Event.MouseButton1Click] = onClick,
})
end
BackButton = RoactRodux.connect(function(store)
local state = store:getState()
return {
onClick = function()
store:dispatch({
type = "Back"
})
end,
enabled = #state.history > 0,
}
end)(BackButton)
--[[
Only renders the given component if the given route is the current one.
The component is passed as a prop so that we don't worry about its
descendants until they're relevant.
]]
local function Route(props)
-- Injected by RoactRodux
local current = props.current
-- Provided by parent component
local match = props.match
local component = props.component
if current == match then
return Roact.createElement(component)
end
end
Route = RoactRodux.connect(function(store)
local state = store:getState()
return {
current = state.current,
}
end)(Route)
--[[
A navigation bar that lets us go to one of three locations, or
travel backwards.
]]
local function NavBar()
return Roact.createElement("Frame", {
Size = UDim2.new(1, 0, 0, 30),
Position = UDim2.new(0, 0, 1, 0),
BackgroundColor3 = Color3.new(1, 1, 1),
}, {
Layout = Roact.createElement("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
}),
Back = Roact.createElement(BackButton),
GoHome = Roact.createElement(Link, {
text = "Go to Home",
target = "home",
}),
GoAbout = Roact.createElement(Link, {
text = "Go to About",
target = "about",
}),
GoContact = Roact.createElement(Link, {
text = "Go to Contact",
target = "contact",
}),
})
end
-- 'Home' page, our default view
local function Home()
return Roact.createElement("TextLabel", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Color3.new(0.5, 0.3, 0.7),
Text = "Home",
TextSize = 30,
TextColor3 = Color3.new(0.95, 0.95, 0.95),
})
end
-- 'About' page
local function About()
return Roact.createElement("TextLabel", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Color3.new(0.5, 0.6, 0.2),
Text = "About",
TextSize = 30,
TextColor3 = Color3.new(0.95, 0.95, 0.95),
})
end
-- 'Contact' page
local function Contact()
return Roact.createElement("TextLabel", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = Color3.new(0.8, 0.3, 0.4),
Text = "Contact",
TextSize = 30,
TextColor3 = Color3.new(0.95, 0.95, 0.95),
})
end
--[[
The wrapper used to package up all of our components and display them.
]]
local App = Roact.Component:extend("App")
function App:render()
return Roact.createElement("ScreenGui", {
}, {
Body = Roact.createElement("Frame", {
Size = UDim2.new(0, 600, 0, 400),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
}, {
-- These components sort of act like a static routing table.
Home = Roact.createElement(Route, {
match = "home",
component = Home,
}),
About = Roact.createElement(Route, {
match = "about",
component = About,
}),
Contact = Roact.createElement(Route, {
match = "contact",
component = Contact,
}),
NavBar = Roact.createElement(NavBar),
}),
})
end
--[[
All of our navigation is described using a Redux reducer.
This means we can time-travel debug and log actions just like other
Redux data.
]]
local function reducer(state, action)
state = state or {
current = "home",
history = {}
}
if action.type == "Navigate" then
return {
current = action.location,
history = Immutable.Append(state.history, state.current),
}
elseif action.type == "Back" then
local length = #state.history
if length == 0 then
return state
end
return {
current = state.history[length],
history = Immutable.RemoveFromList(state.history, length),
}
end
return state
end
return function()
local store = Rodux.Store.new(reducer)
local element = Roact.createElement(RoactRodux.StoreProvider, {
store = store,
}, {
App = Roact.createElement(App)
})
Roact.reify(element, CoreGui, "Roact-router")
end
@@ -0,0 +1,63 @@
local CoreGui = game:GetService("CoreGui")
local RunService = game:GetService("RunService")
local GRID_SIZE = 50
local NODE_SIZE = 10
local Common = script.Parent.Parent
local Roact = require(Common.Roact)
local Node = Roact.Component:extend("Node")
function Node:render()
local x = self.props.x
local y = self.props.y
local time = self.props.time
local n = time + x / NODE_SIZE + y / NODE_SIZE
return Roact.createElement("Frame", {
Size = UDim2.new(0, NODE_SIZE, 0, NODE_SIZE),
Position = UDim2.new(0, NODE_SIZE * x, 0, NODE_SIZE * y),
BackgroundColor3 = Color3.new(0.5 + 0.5 * math.sin(n), 0.5, 0.5),
})
end
local App = Roact.Component:extend("App")
function App:init()
self.state = {
time = tick(),
}
end
function App:render()
local time = self.state.time
local nodes = {}
local n = 0
for x = 0, GRID_SIZE - 1 do
for y = 0, GRID_SIZE - 1 do
n = n + 1
nodes[n] = Roact.createElement(Node, {
x = x,
y = y,
time = time,
})
end
end
return Roact.createElement("ScreenGui", nil, nodes)
end
function App:didMount()
RunService.Stepped:Connect(function()
self:setState({
time = tick(),
})
end)
end
return function()
Roact.reify(Roact.createElement(App), CoreGui)
end
@@ -0,0 +1,9 @@
-----------------------------------------------------------------------------
--- ---
--- Under Migration to CorePackages ---
--- ---
--- Please put your changes in AppTempCommon. ---
--- NOTE: You will have to rebuild for changes to kick in ---
-----------------------------------------------------------------------------
local CorePackages = game:GetService("CorePackages")
return require(CorePackages.AppTempCommon.Common.Functional)
@@ -0,0 +1,9 @@
-----------------------------------------------------------------------------
--- ---
--- Under Migration to CorePackages ---
--- ---
--- Please put your changes in AppTempCommon. ---
--- NOTE: You will have to rebuild for changes to kick in ---
-----------------------------------------------------------------------------
local CorePackages = game:GetService("CorePackages")
return require(CorePackages.AppTempCommon.Common.Immutable)
@@ -0,0 +1,285 @@
--[[
A simple observer for errors on the script context.
By default, sends formatted error reports to the AnalyticsService.
]]
local ScriptContext = game:GetService("ScriptContext")
local RunService = game:GetService("RunService")
local Analytics = require(script.Parent.Analytics).new()
-- flag dependencies
local influxSeriesName = settings():GetFVariable("LuaErrorsInfluxSeries")
local influxThrottlingPercentage = tonumber(settings():GetFVariable("LuaErrorsInfluxThrottling"))
local diagCounterName = settings():GetFVariable("LuaAppsDiagErrorCounter")
local isEnabled = settings():GetFFlag("UseNewGoogleAnalyticsImpl2")
-- defaults
local defaultVerboseErrors = false
local defaultShouldReportDiag = true
local defaultShouldReportGoogleAnalytics = true
local defaultShouldReportInflux = false
local defaultCurrentApp = "Unknown"
local defaultQueuedReportTotalLimit = 30
-- string formatting functions
local function createProductName(currentApp)
local versionString = RunService:GetRobloxVersion()
return string.format("%s-%s", currentApp, versionString)
end
local function convertNewlinesToPipes(stack)
local rebuiltStack = ""
local first = true
for line in stack:gmatch("[^\r\n]+") do
if first then
rebuiltStack = line
first = false
else
rebuiltStack = rebuiltStack .. " | " .. line
end
end
return rebuiltStack
end
local function removePlayerNameFromStack(stack)
stack = string.gsub(stack, "Players%.[^.]+%.", "Players.<Player>.")
return stack
end
local function printError(currentApp, message, stack, offendingScript)
local outMessages = {
"---- Unhandled Error Handler -----",
string.format("Current App<%s, %d> : \n%s\n", type(currentApp), #currentApp, currentApp),
string.format("Message<%s,%d> :\n%s\n", type(message), #message, message),
string.format("Stack<%s,%d> :\n%s", type(stack), #stack, stack),
string.format("Script<%s> :\n%s", type(offendingScript), offendingScript:GetFullName()),
"----------------------------------"}
print(table.concat(outMessages, "\n"))
end
-- analytics reporting functions
local function reportErrorToGA(currentApp, errorMsg, stack, value)
Analytics.GoogleAnalytics:trackEvent(currentApp, errorMsg, stack, value)
end
local function reportErrorToInflux(currentApp, message, stack, offendingScript)
local additionalArgs = {
app = currentApp,
err = message,
stack = stack,
script = offendingScript:GetFullName()
}
-- fire the error report
Analytics.InfluxDb:reportSeries(influxSeriesName, additionalArgs, influxThrottlingPercentage)
end
local function reportErrorToDiag(currentApp)
-- these reports may be broken down further based on current app
Analytics.Diag:reportCounter(diagCounterName, 1)
end
-- helper queue object
local function createErrorQueue()
-- NOTE - if error batching other types of reports becomes more important,
-- this object can be generalized to work for more errors, not just GA
local ErrorQueue = {
errors = {},
totalErrors = 0,
totalKeys = 0,
countdown = defaultQueuedReportTotalLimit,
shouldCountdown = true,
}
function ErrorQueue:addError(currentApp, message, stack)
local key = string.format("%s | %s | %s", currentApp, message, stack)
if not self.errors[key] then
self.errors[key] = {
app = currentApp,
message = message,
stack = stack,
value = 1 }
self.totalKeys = self.totalKeys + 1
else
self.errors[key].value = self.errors[key].value + 1
end
self.totalErrors = self.totalErrors + 1
end
function ErrorQueue:isReadyToReport()
-- NOTE - GA has limits on how many reports that it will accept at a time.
-- According to : https://developers.google.com/analytics/devguides/config/mgmt/v3/limits-quotas
-- the Collection API is limited to 10 queries / second per IP Address
return self.totalKeys > 10 or
self.totalErrors > defaultQueuedReportTotalLimit or
self.countdown <= 0
end
function ErrorQueue:reportAllErrors()
-- copy the error queue and instantly clear it out
local errors = {}
for k, v in pairs(self.errors) do
errors[k] = v
end
self.errors = {}
self.totalErrors = 0
self.totalKeys = 0
self.countdown = defaultQueuedReportTotalLimit
-- report the errors
for _, errData in pairs(errors) do
reportErrorToGA(errData.app, errData.message, errData.stack, errData.value)
end
end
function ErrorQueue:startTimer()
spawn(function()
while self.shouldCountdown do
self.countdown = self.countdown - 1
if self:isReadyToReport() then
self:reportAllErrors()
end
wait(1.0)
end
end)
end
function ErrorQueue:stopTimer()
self.shouldCountdown = false
end
return ErrorQueue
end
local LuaErrorReporter = {}
LuaErrorReporter.__index = LuaErrorReporter
-- observedSignal : the Signal to listen for errors on
function LuaErrorReporter.new(observedSignal)
-- sanitize input
if not observedSignal then
observedSignal = ScriptContext.Error
end
-- _isInstance : (boolean) simple flag to identify that this object was created with new()
-- _verbose : (boolean) when true, prints out debug information before sending error reports
-- _shouldReportDiag : (boolean) when true, increments a counter of total errors in Diag
-- _shouldReportGoogleAnalytics : (boolean) when true, reports the error to GoogleAnalytics
-- _shouldReportInflux : (boolean) when true, reports the error to InfluxDb
-- _currentScreen : (string) the name of the screen that is currently presented to the user
-- _signalConnectionToken : (RBXScriptConnection) a token issued when connecting to the Error signal
-- _reportQueueGA : (ErrorQueue)
local instance = {
_isInstance = true,
_verbose = defaultVerboseErrors,
_signalConnectionToken = nil,
_shouldReportDiag = defaultShouldReportDiag,
_shouldReportGoogleAnalytics = defaultShouldReportGoogleAnalytics,
_shouldReportInflux = defaultShouldReportInflux,
_currentApp = defaultCurrentApp,
_reportQueueGA = createErrorQueue(),
}
setmetatable(instance, LuaErrorReporter)
-- connect a listener for errors on the provided Signal.
instance._signalConnectionToken = observedSignal:Connect(function(message, stack, offendingScript)
-- protect against endless chains of errors when actually reporting
local success, reportMessage = pcall(function()
instance:handleError(message, stack, offendingScript)
end)
if not success then
warn(string.format("An error occurred while reporting an error : %s", reportMessage))
end
end)
-- the BindToClose function does not play nicely with Studio.
if not RunService:IsStudio() then
-- BindToClose has about a 30 second timeout before the datamodel will kill any running scripts,
-- but this function will only need to fire off, at most, 9 http requests in parallel.
-- And since we're not binding any callbacks to these http requests, it's fine.
game:BindToClose(function()
instance:delete()
end)
end
return instance
end
function LuaErrorReporter:delete()
-- we're cleaning up this crash observer, disconnect from the Signal
self._signalConnectionToken:Disconnect()
-- when the game closes down, send off all the remaining reports left in the queue
self._reportQueueGA:reportAllErrors()
self._reportQueueGA.shouldCountdown = false
end
-- appName : (string) the english, human readable name of the current app that is hosting the lua app
function LuaErrorReporter:setCurrentApp(appName)
if type(appName) ~= "string" then
error("appName must be a string")
end
self._currentApp = appName
end
function LuaErrorReporter:startQueueTimers()
self._reportQueueGA:startTimer()
end
function LuaErrorReporter:stopQueueTimers()
self._reportQueueGA:stopTimer()
end
-- message : (string) the message passed from the error() call
-- stack : (string) the stack trace
-- offendingScript : (LuaSourceContainer) the specific script that threw the error
function LuaErrorReporter:handleError(message, stack, offendingScript)
-- NOTE - offendingScript is intended to show where in the workspace the error originated.
-- It will not be useful for the Lua Apps as all files originate out of the ***StarterScript.lua
-- if the fast flag isn't on yet, escape
if not isEnabled then
return
end
-- make a descriptive name to categorize the errors under- : <currentApp>-<appV
local productName = createProductName(self._currentApp)
-- parse out the error message
if self._verbose then
printError(productName, message, stack, offendingScript)
end
-- sanitize some inputs
local cleanedMessage = removePlayerNameFromStack(message)
local cleanedStack = removePlayerNameFromStack(stack)
cleanedStack = convertNewlinesToPipes(cleanedStack)
-- report to the appropriate sources
if self._shouldReportGoogleAnalytics then
self._reportQueueGA:addError(productName, cleanedMessage, cleanedStack)
if self._reportQueueGA:isReadyToReport() then
self._reportQueueGA:reportAllErrors()
end
end
if self._shouldReportInflux then
reportErrorToInflux(productName, cleanedMessage, cleanedStack, offendingScript)
end
if self._shouldReportDiag then
reportErrorToDiag(productName)
end
end
return LuaErrorReporter
@@ -0,0 +1,113 @@
return function()
FIXME("throws libc++abi.dylib: terminating with uncaught exception of type std::runtime_error: Script that implemented this callback has been destroyed")
-- NOTE : since each Lua Error Reporter object adds an observer to the script context,
-- it is important that tests clean up after themselves and call the delete() function
local LuaErrorReporter = require(script.Parent.LuaErrorReporter)
local Signal = require(script.Parent.Signal)
-- create some dummy test values
local testError = "foo"
local testAppName = "testSuite"
local testSignal = Signal.new()
local function createSilentLER()
-- NOTE - creating this reporter with the testSignal circumvents testing the functionality
-- of observing the ScriptContext.Error signal. It is assumed that the Error signal just works.
-- NOTE - even if the handleError() function doesn't get overriden on the LuaErrorReporter,
-- don't send error reports over the wire for unit tests
local ler = LuaErrorReporter.new(testSignal)
ler._verbose = false
ler._shouldReportInflux = false
ler._shouldReportGoogleAnalytics = false
ler._shouldReportDiag = false
return ler
end
local function fireTestErrorSignal()
-- fire a signal that mimics the structure of the ScriptContext.Error's arguments:
-- (message, stackTrace, scriptSource)
testSignal:Fire(testError, debug.traceback(), script)
-- NOTE - if these tests are ever to be modified to once again observe
-- the ScriptContext.Error signal, then be mindful that testSignal resolves synchronously
-- while ScriptContext.Error does not.
end
describe("new()", function()
it("should construct with a custom signal", function()
local ler = createSilentLER()
expect(ler).to.be.ok()
ler:delete()
end)
it("should create an object that observes errors", function()
local callCounter = 0
local ler = createSilentLER()
function ler:handleError(message, stack, offendingScript)
callCounter = callCounter + 1
expect(message).to.be.ok()
expect(stack).to.be.ok()
expect(offendingScript).to.be.ok()
end
fireTestErrorSignal()
ler:delete()
expect(callCounter).to.equal(1)
end)
end)
describe("delete()", function()
it("should break the connection to the error context", function()
local ler = createSilentLER()
function ler:handleError(message, stack, script)
error("delete() failed to remove the script context's error observer")
end
ler:delete()
fireTestErrorSignal()
end)
end)
describe("setCurrentApp()", function()
it("should not allow the value to be nil", function()
local ler = createSilentLER()
expect(function()
ler:setCurrentApp(nil)
end).to.throw()
ler:delete()
end)
it("should allow the value to be set to a string", function()
local ler = createSilentLER()
ler:setCurrentApp(testAppName)
expect(ler._currentApp).to.equal(testAppName)
ler:delete()
end)
end)
describe("handleError()", function()
it("should be overrideable with a custom function", function()
local callCounter = 0
local ler = createSilentLER()
function ler:handleError(messsage, stack, script)
callCounter = callCounter + 1
end
fireTestErrorSignal()
expect(callCounter).to.equal(1)
ler:delete()
end)
end)
end
@@ -0,0 +1,29 @@
--[[
Calls a function and throws an error if it attempts to yield.
Pass any number of arguments to the function after the callback.
This function supports multiple return; all results returned from the
given function will be returned.
]]
local function resultHandler(co, ok, ...)
if not ok then
local err = (...)
error(err, 2)
end
if coroutine.status(co) ~= "dead" then
error(debug.traceback(co, "Attempted to yield inside Changed event!"), 2)
end
return ...
end
local function NoYield(callback, ...)
local co = coroutine.create(callback)
return resultHandler(co, coroutine.resume(co, ...))
end
return NoYield
@@ -0,0 +1,39 @@
return function()
local NoYield = require(script.Parent.NoYield)
it("should call functions normally", function()
local callCount = 0
local function test(a, b)
expect(a).to.equal(5)
expect(b).to.equal(6)
callCount = callCount + 1
return 11, "hello"
end
local a, b = NoYield(test, 5, 6)
expect(a).to.equal(11)
expect(b).to.equal("hello")
end)
it("should throw on yield", function()
local preCount = 0
local postCount = 0
local function test()
preCount = preCount + 1
wait()
postCount = postCount + 1
end
expect(function()
NoYield(test)
end).to.throw()
expect(preCount).to.equal(1)
expect(postCount).to.equal(0)
end)
end
@@ -0,0 +1,71 @@
return function()
local HttpService = game:GetService("HttpService")
HACK_NO_XPCALL()
it("should throw if Url is not a string", function()
local options = {
Url = true,
Method = "GET",
}
expect(function()
HttpService:RequestInternal(options)
end).to.throw()
end)
it("should throw if method is invalid", function()
local options = {
Url = "https://google.com",
Method = "&&&",
}
expect(function()
HttpService:RequestInternal(options)
end).to.throw()
end)
it("should not throw if Url and Method are valid", function()
local options = {
Url = "https://google.com",
Method = "GET",
}
expect(HttpService:RequestInternal(options)).to.be.ok()
end)
it("should not throw if CachePolicy, Timeout, and Priority are valid", function()
local options = {
Url = "https://google.com",
Method = "GET",
Timeout = 20,
Priority = 20,
CachePolicy = Enum.HttpCachePolicy.Full,
}
expect(HttpService:RequestInternal(options)).to.be.ok()
end)
it("should be able to be canceled", function()
local options = {
Url = "https://google.com",
Method = "GET",
}
local request = HttpService:RequestInternal(options)
local response = nil
local success = nil
request:Start(function(suc, resp)
success = suc
response = resp
end)
request:Cancel()
while response == nil do
wait()
end
expect(success).to.equal(false)
expect(response.HttpError).to.equal(Enum.HttpError.Aborted)
end)
end
@@ -0,0 +1,3 @@
local CorePackages = game:GetService("CorePackages")
return require(CorePackages.Roact)
@@ -0,0 +1,3 @@
local CorePackages = game:GetService("CorePackages")
return require(CorePackages.RoactRodux)
@@ -0,0 +1,50 @@
--[[
A component that establishes a connection to a Roblox event when it is rendered.
]]
local Roact = require(script.Parent.Parent.Roact)
local ExternalEventConnection = Roact.Component:extend("ExternalEventConnection")
function ExternalEventConnection:init()
self.connection = nil
end
--[[
Render the child component so that ExternalEventConnections can be nested like so:
Roact.createElement(ExternalEventConnection, {
event = UserInputService.InputBegan,
callback = inputBeganCallback,
}, {
Roact.createElement(ExternalEventConnection, {
event = UserInputService.InputEnded,
callback = inputChangedCallback,
})
})
]]
function ExternalEventConnection:render()
return Roact.oneChild(self.props[Roact.Children])
end
function ExternalEventConnection:didMount()
local event = self.props.event
local callback = self.props.callback
self.connection = event:Connect(callback)
end
function ExternalEventConnection:didUpdate(oldProps)
if self.props.event ~= oldProps.event or self.props.callback ~= oldProps.callback then
self.connection:Disconnect()
self.connection = self.props.event:Connect(self.props.callback)
end
end
function ExternalEventConnection:willUnmount()
self.connection:Disconnect()
self.connection = nil
end
return ExternalEventConnection
@@ -0,0 +1,90 @@
return function ()
local Roact = require(script.Parent.Parent.Roact)
local ExternalEventConnection = require(script.Parent.ExternalEventConnection)
it("if mounted, should call the callback when the event is triggered", function()
local event = Instance.new("BindableEvent")
local count = 0
local element = Roact.createElement(ExternalEventConnection, {
event = event.Event,
callback = function()
count = count + 1
end,
})
local RoactInstance = Roact.reify(element)
event:Fire()
expect(count).to.equal(1)
Roact.teardown(RoactInstance)
event:Fire()
expect(count).to.equal(1)
event:Destroy()
end)
it("should handle updating the callback or event", function()
local firstEvent = Instance.new("BindableEvent")
local secondEvent = Instance.new("BindableEvent")
local count = 0
local changeState
local EventContainer = Roact.Component:extend("EventContainer")
function EventContainer:init()
self.state = {
event = firstEvent.Event,
callback = function()
count = count + 1
end,
}
end
function EventContainer:render()
return Roact.createElement(ExternalEventConnection, {
event = self.state.event,
callback = self.state.callback,
})
end
function EventContainer:didMount()
changeState = function(newState)
self:setState(newState)
end
end
function EventContainer:willUnmount()
changeState = nil
end
Roact.reify(Roact.createElement(EventContainer))
firstEvent:Fire()
expect(count).to.equal(1)
changeState({
event = secondEvent.Event,
})
firstEvent:Fire()
expect(count).to.equal(1)
secondEvent:Fire()
expect(count).to.equal(2)
changeState({
callback = function()
-- this is intentionally blank
end,
})
secondEvent:Fire()
expect(count).to.equal(2)
firstEvent:Destroy()
secondEvent:Destroy()
end)
end
@@ -0,0 +1,104 @@
--[[
Wraps around Rodux and applies a compatibility patch to deal with API
breakages since the last Rodux upgrade.
Things that have changed:
* Store API is now camelCase instead of PascalCase to match standards
* Signal API is now camelCase instead of PascalCase (affects store.changed signal)
* Thunks are no longer enabled by default
This file should not be modifying any objects in CorePackages, only wrapping them!
]]
local CorePackages = game:GetService("CorePackages")
local Logging = require(CorePackages.Logging)
local Rodux = require(CorePackages.Rodux)
local Signal = require(CorePackages.RoduxImpl.Signal)
local function getWarningMessage(className, funcNameCamelCase)
return string.format(
"%s:%s() has been deprecated, use %s()\n%s]",
className,
funcNameCamelCase:sub(1, 1):upper() .. funcNameCamelCase:sub(2),
funcNameCamelCase,
debug.traceback()
)
end
local StoreCompat = {}
function StoreCompat.new(reducer, initialState)
--[[
Older consumers of Rodux expect Thunks to be enabled by default, so we
introduce the thunk middleware by default here.
]]
local store = Rodux.Store.new(reducer, initialState, { Rodux.thunkMiddleware })
--[[
Create PascalCase compatibility versions of regular Store methods
It's easier to do this here since we'd otherwise have to deal with
reassigning the store's metatable
]]
store.Dispatch = function(...)
Logging.warn(getWarningMessage("Store", "dispatch"))
return store.dispatch(...)
end
store.GetState = function(...)
Logging.warn(getWarningMessage("Store", "getState"))
return store.getState(...)
end
store.Destruct = function(...)
Logging.warn(getWarningMessage("Store", "destruct"))
return store.destruct(...)
end
store.Flush = function(...)
Logging.warn(getWarningMessage("Store", "flush"))
return store.flush(...)
end
-- Store's changed signal also needs a compatibility layer added to it
store.changed.connect = function(...)
local connection = Signal.connect(...)
-- 'disconnect' is created for every connection.
connection.Disconnect = function(...)
Logging.warn(getWarningMessage("Connection", "disconnect"))
return connection.disconnect(...)
end
return connection
end
-- Create PascalCase versions of regular Signal methods
store.changed.Connect = function(...)
Logging.warn(getWarningMessage("Signal", "connect"))
return store.changed.connect(...)
end
--[[
Note: We deliberately exclude 'Signal.fire' because users
should never call it anyways!
]]
-- Provide PascalCase compatibility accessor to store.changed
store.Changed = store.changed
return store
end
--[[
Manually recreate Rodux's interface using our augmented version
of the Store functionality
]]
local RoduxCompat = {
Store = StoreCompat,
createReducer = Rodux.createReducer,
combineReducers = Rodux.combineReducers,
loggerMiddleware = Rodux.loggerMiddleware,
thunkMiddleware = Rodux.thunkMiddleware,
}
return RoduxCompat
@@ -0,0 +1,134 @@
return function()
local Rodux = require(script.Parent.Rodux)
local CorePackages = game:GetService("CorePackages")
local CorePackagesRodux = require(CorePackages.Rodux)
local Logging = require(CorePackages.Logging)
local function NoopReducer(state, action)
return state
end
describe("Rodux compatibility shim", function()
it("should process thunks by default", function()
local thunkCallCount = 0
local thunk = function(store)
thunkCallCount = thunkCallCount + 1
end
local store = Rodux.Store.new(NoopReducer)
expect(function() store:dispatch(thunk) end).never.to.throw()
expect(thunkCallCount).to.equal(1)
end)
it("should have PascalCase versions of Store API that warn", function()
local store = Rodux.Store.new(NoopReducer)
expect(typeof(store.GetState)).to.equal("function")
expect(typeof(store.Dispatch)).to.equal("function")
expect(typeof(store.Flush)).to.equal("function")
local logs = Logging.capture(function()
store:GetState()
store:Dispatch({ type = "Foo" })
store:Flush()
end)
print(unpack(logs.warnings))
expect(string.match(
logs.warnings[1],
"^Store:GetState%(%) has been deprecated, use getState%(%)"
)).to.be.ok()
expect(string.match(
logs.warnings[2],
"^Store:Dispatch%(%) has been deprecated, use dispatch%(%)"
)).to.be.ok()
expect(string.match(
logs.warnings[3],
"^Store:Flush%(%) has been deprecated, use flush%(%)"
)).to.be.ok()
end)
it("should have PascalCase versions of Signal API for 'changed' signal", function()
local store = Rodux.Store.new(NoopReducer)
expect(store.Changed).to.be.ok()
expect(typeof(store.Changed.Connect)).to.equal("function")
local connection
local logs = Logging.capture(function()
connection = store.Changed:Connect(function()
-- do nothing
end)
end)
expect(string.match(
logs.warnings[1],
"^Signal:Connect%(%) has been deprecated, use connect%(%)"
)).to.be.ok()
logs = Logging.capture(function()
connection:Disconnect()
end)
expect(string.match(
logs.warnings[1],
"^Connection:Disconnect%(%) has been deprecated, use disconnect%(%)"
)).to.be.ok()
end)
it("should match Rodux's API", function()
for name, _ in pairs(CorePackagesRodux) do
expect(Rodux[name]).to.be.ok()
end
local corePackagesStore = CorePackagesRodux.Store.new(NoopReducer)
local shimStore = Rodux.Store.new(NoopReducer)
for name, _ in pairs(corePackagesStore) do
expect(shimStore[name]).to.be.ok()
end
end)
end)
describe("CorePackages Rodux version", function()
it("should not be overwritten by the compatibility shim", function()
expect(Rodux).never.to.equal(CorePackagesRodux)
end)
it("should not have its Store API overwritten by the compatibility shim", function()
expect(Rodux.Store).never.to.equal(CorePackagesRodux.Store)
end)
it("should not process thunks by default", function()
local thunkCallCount = 0
local thunk = function(store)
thunkCallCount = thunkCallCount + 1
end
local store = CorePackagesRodux.Store.new(NoopReducer)
expect(function() store:dispatch(thunk) end).to.throw()
expect(thunkCallCount).to.equal(0)
end)
it("should accept and use middleware", function()
local middlewareInitialized = false
local lastActionProcessed = nil
local middleware = function(nextDispatch, store)
middlewareInitialized = true
return function(action)
lastActionProcessed = action.type
end
end
local store = CorePackagesRodux.Store.new(NoopReducer, {}, { middleware })
store:dispatch({ type = "Foo" })
expect(middlewareInitialized).to.equal(true)
expect(lastActionProcessed).to.equal("Foo")
end)
end)
end
@@ -0,0 +1,75 @@
--[[
A limited, simple implementation of a Signal.
Handlers are fired in order, and (dis)connections are properly handled when
executing an event.
Signal uses Immutable to avoid invalidating the 'Fire' loop iteration.
]]
local Immutable = require(script.Parent.Immutable)
local Signal = {}
Signal.__index = Signal
function Signal.new()
local self = {
_listeners = {}
}
setmetatable(self, Signal)
return self
end
function Signal:connect(callback)
local listener = {
callback = callback,
isConnected = true,
}
self._listeners = Immutable.Append(self._listeners, listener)
local function disconnect()
listener.isConnected = false
self._listeners = Immutable.RemoveValueFromList(self._listeners, listener)
end
return {
Disconnect = function()
warn(string.format(
"Connection:Disconnect() has been deprecated, use Connection:disconnect()\n%s]",
debug.traceback()
))
disconnect()
end,
disconnect = disconnect,
}
end
function Signal:fire(...)
for _, listener in ipairs(self._listeners) do
if listener.isConnected then
listener.callback(...)
end
end
end
function Signal:Connect(...)
warn(string.format(
"Signal:Connect() has been deprecated, use Signal:connect()\n%s]",
debug.traceback()
))
return self:connect(...)
end
function Signal:Fire(...)
warn(string.format(
"Signal:Fire() has been deprecated, use Signal:fire()\n%s]",
debug.traceback()
))
self:fire(...)
end
return Signal
@@ -0,0 +1,114 @@
return function()
local Signal = require(script.Parent.Signal)
it("should construct from nothing", function()
local signal = Signal.new()
expect(signal).to.be.ok()
end)
it("should fire connected callbacks", function()
local callCount = 0
local value1 = "Hello World"
local value2 = 7
local callback = function(arg1, arg2)
expect(arg1).to.equal(value1)
expect(arg2).to.equal(value2)
callCount = callCount + 1
end
local signal = Signal.new()
local connection = signal:Connect(callback)
signal:Fire(value1, value2)
expect(callCount).to.equal(1)
connection:Disconnect()
signal:Fire(value1, value2)
expect(callCount).to.equal(1)
end)
it("should disconnect handlers", function()
local callback = function()
error("Callback was called after disconnect!")
end
local signal = Signal.new()
local connection = signal:Connect(callback)
connection:Disconnect()
signal:Fire()
end)
it("should fire handlers in order", function()
local signal = Signal.new()
local x = 0
local y = 0
local callback1 = function()
expect(x).to.equal(0)
expect(y).to.equal(0)
x = x + 1
end
local callback2 = function()
expect(x).to.equal(1)
expect(y).to.equal(0)
y = y + 1
end
signal:Connect(callback1)
signal:Connect(callback2)
signal:Fire()
expect(x).to.equal(1)
expect(y).to.equal(1)
end)
it("should continue firing despite mid-event disconnection", function()
local signal = Signal.new()
local countA = 0
local countB = 0
local connectionA
connectionA = signal:Connect(function()
connectionA:Disconnect()
countA = countA + 1
end)
signal:Connect(function()
countB = countB + 1
end)
signal:Fire()
expect(countA).to.equal(1)
expect(countB).to.equal(1)
end)
it("should skip listeners that were disconnected during event evaluation", function()
local signal = Signal.new()
local countA = 0
local countB = 0
local connectionB
signal:Connect(function()
countA = countA + 1
connectionB:Disconnect()
end)
connectionB = signal:Connect(function()
countB = countB + 1
end)
signal:Fire()
expect(countA).to.equal(1)
expect(countB).to.equal(0)
end)
end
@@ -0,0 +1,43 @@
--[[
A 'Symbol' is an opaque marker type that can be used to signify unique
statuses. Symbols have the type 'userdata', but when printed to the console,
the name of the symbol is shown.
]]
local Symbol = {}
--[[
Creates a Symbol with the given name.
When printed or coerced to a string, the symbol will turn into the string
given as its name.
]]
function Symbol.named(name)
assert(type(name) == "string", "Symbols must be created using a string name!")
local self = newproxy(true)
local wrappedName = ("Symbol(%s)"):format(name)
getmetatable(self).__tostring = function()
return wrappedName
end
return self
end
--[[
Create an unnamed Symbol. Usually, you should create a named Symbol using
Symbol.named(name)
]]
function Symbol.unnamed()
local self = newproxy(true)
getmetatable(self).__tostring = function()
return "Unnamed Symbol"
end
return self
end
return Symbol
@@ -0,0 +1,45 @@
return function()
local Symbol = require(script.Parent.Symbol)
describe("named", function()
it("should give an opaque object", function()
local symbol = Symbol.named("foo")
expect(symbol).to.be.a("userdata")
end)
it("should coerce to the given name", function()
local symbol = Symbol.named("foo")
expect(tostring(symbol):find("foo")).to.be.ok()
end)
it("should be unique when constructed", function()
local symbolA = Symbol.named("abc")
local symbolB = Symbol.named("abc")
expect(symbolA).never.to.equal(symbolB)
end)
end)
describe("unnamed", function()
it("should give an opaque object", function()
local symbol = Symbol.unnamed()
expect(symbol).to.be.a("userdata")
end)
it("should coerce to some string", function()
local symbol = Symbol.unnamed()
expect(tostring(symbol)).to.be.a("string")
end)
it("should be unique when constructed", function()
local symbolA = Symbol.unnamed()
local symbolB = Symbol.unnamed()
expect(symbolA).never.to.equal(symbolB)
end)
end)
end
@@ -0,0 +1,9 @@
-----------------------------------------------------------------------------
--- ---
--- Under Migration to CorePackages ---
--- ---
--- Please put your changes in AppTempCommon. ---
--- NOTE: You will have to rebuild for changes to kick in ---
-----------------------------------------------------------------------------
local CorePackages = game:GetService("CorePackages")
return require(CorePackages.AppTempCommon.Common.Text)
@@ -0,0 +1,9 @@
-----------------------------------------------------------------------------
--- ---
--- Under Migration to CorePackages ---
--- ---
--- Please put your changes in AppTempCommon. ---
--- NOTE: You will have to rebuild for changes to kick in ---
-----------------------------------------------------------------------------
local CorePackages = game:GetService("CorePackages")
return require(CorePackages.AppTempCommon.Common.memoize)