0
0
mirror of https://github.com/Doodle3D/doodle3d-firmware.git synced 2024-12-22 19:13:49 +01:00

Add simple unit test framework and various other improvements/changes:

* Move logger.lua;
* update quotation style in several files;
* add proposed configuration key layout to conf_defaults.lua;
* move dump function to utils.lua;
* implement (very) basic unit testing environment;
* fix bugs in various functions in utils.lua;
* checkin of preliminary REST API test code.
This commit is contained in:
Wouter R 2013-07-17 08:25:24 +02:00
parent 3726063b99
commit 9a8ac55b8f
15 changed files with 468 additions and 61 deletions

34
src/conf_defaults.lua Normal file
View File

@ -0,0 +1,34 @@
local M = {}
--NOTE: pcall protects from invocation exceptions, which is what we need except
--during debugging. This flag replaces them with a normal call so we can inspect stack traces.
M.DEBUG_PCALLS = true
--REST responses will contain 'module' and 'function' keys describing what was requested
M.API_INCLUDE_ENDPOINT_INFO = false
M.DEFAULT_AP_SSID = "d3d-ap-%MAC_ADDR_TAIL%"
M.DEFAULT_AP_ADDRESS = "192.168.10.1"
M.DEFAULT_AP_NETMASK = "255.255.255.0"
--NOTE: proposed notation for baseline configuration (containing defaults as well as type and constraint information)
--the table name is the configuration key; min, max and regex are all optional; type is one of: {int, float, string, ...?}
M.temperature = {
default = 230,
type = 'int',
description = '...xyzzy',
min = 0,
max = 350
}
M.ssid = {
default = 'd3d-ap-%%MAC_TAIL%%',
type = 'int', --one of: {int, float, string, ...?}
min = 1,
max = 32,
regex = '[a-zA-Z0-9 -=+]+'
}
return M

View File

@ -1,14 +0,0 @@
local M = {}
--NOTE: pcall protects from invocation exceptions, which is what we need except
--during debugging. This flag replaces them with a normal call so we can inspect stack traces.
M.DEBUG_PCALLS = true
--REST responses will contain 'module' and 'function' keys describing what was requested
M.API_INCLUDE_ENDPOINT_INFO = false
M.DEFAULT_AP_SSID = "d3d-ap-%MAC_ADDR_TAIL%"
M.DEFAULT_AP_ADDRESS = "192.168.10.1"
M.DEFAULT_AP_NETMASK = "255.255.255.0"
return M

View File

@ -1,11 +1,12 @@
package.path = package.path .. ';/usr/share/lua/wifibox/?.lua' package.path = package.path .. ';/usr/share/lua/wifibox/?.lua'
local l = require("logger") local config = require('config')
local RequestClass = require("rest.request") local u = require('util.utils')
local ResponseClass = require("rest.response") local l = require('util.logger')
local wifi = require("network.wlanconfig") local wifi = require('network.wlanconfig')
local netconf = require("network.netconfig") local netconf = require('network.netconfig')
local config = require("config") local RequestClass = require('rest.request')
local ResponseClass = require('rest.response')
local postData = nil local postData = nil
@ -22,8 +23,8 @@ local function init()
else l:info("Wifibox CGI handler started") else l:info("Wifibox CGI handler started")
end end
if (os.getenv("REQUEST_METHOD") == "POST") then if (os.getenv('REQUEST_METHOD') == 'POST') then
local n = tonumber(os.getenv("CONTENT_LENGTH")) local n = tonumber(os.getenv('CONTENT_LENGTH'))
postData = io.read(n) postData = io.read(n)
end end
@ -41,14 +42,14 @@ end
local rq = RequestClass.new(postData, config.DEBUG_PCALLS) local rq = RequestClass.new(postData, config.DEBUG_PCALLS)
l:info("received request of type " .. rq:getRequestMethod() .. " for " .. (rq:getRequestedApiModule() or "<unknown>") l:info("received request of type " .. rq:getRequestMethod() .. " for " .. (rq:getRequestedApiModule() or "<unknown>")
.. "/" .. (rq:getRealApiFunctionName() or "<unknown>") .. " with arguments: " .. l:dump(rq:getAll())) .. "/" .. (rq:getRealApiFunctionName() or "<unknown>") .. " with arguments: " .. u.dump(rq:getAll()))
if rq:getRequestMethod() ~= "CMDLINE" then if rq:getRequestMethod() ~= 'CMDLINE' then
l:info("remote IP/port: " .. rq:getRemoteHost() .. "/" .. rq:getRemotePort()) l:info("remote IP/port: " .. rq:getRemoteHost() .. "/" .. rq:getRemotePort())
l:debug("user agent: " .. rq:getUserAgent()) l:debug("user agent: " .. rq:getUserAgent())
end end
if (not config.DEBUG_PCALLS and rq:getRequestMethod() == "CMDLINE") then if (not config.DEBUG_PCALLS and rq:getRequestMethod() == 'CMDLINE') then
if rq:get("autowifi") ~= nil then if rq:get('autowifi') ~= nil then
setupAutoWifiMode() setupAutoWifiMode()
else else
l:info("Nothing to do...bye.\n") l:info("Nothing to do...bye.\n")

View File

@ -1,6 +1,6 @@
local config = require("config") local config = require("config")
local u = require("util.utils") local u = require("util.utils")
local l = require("logger") local l = require("util.logger")
local uci = require("uci").cursor() local uci = require("uci").cursor()
local M = {} local M = {}

View File

@ -1,5 +1,5 @@
local util = require("util.utils") local util = require("util.utils")
local l = require("logger") local l = require("util.logger")
local uci = require("uci").cursor() local uci = require("uci").cursor()
local iwinfo = require("iwinfo") local iwinfo = require("iwinfo")

View File

@ -1,6 +1,6 @@
local config = require("config") local config = require("config")
local l = require("logger")
local u = require("util.utils") local u = require("util.utils")
local l = require("util.logger")
local netconf = require("network.netconfig") local netconf = require("network.netconfig")
local wifi = require("network.wlanconfig") local wifi = require("network.wlanconfig")
local ResponseClass = require("rest.response") local ResponseClass = require("rest.response")
@ -36,7 +36,7 @@ function M.available(request, response)
netInfo["signal"] = se.signal netInfo["signal"] = se.signal
netInfo["quality"] = se.quality netInfo["quality"] = se.quality
netInfo["quality_max"] = se.quality_max netInfo["quality_max"] = se.quality_max
if withRaw then netInfo["_raw"] = l:dump(se) end if withRaw then netInfo["_raw"] = u.dump(se) end
table.insert(netInfoList, netInfo) table.insert(netInfoList, netInfo)
end end
@ -63,7 +63,7 @@ function M.known(request, response)
netInfo["bssid"] = net.bssid or "" netInfo["bssid"] = net.bssid or ""
netInfo["channel"] = net.channel or "" netInfo["channel"] = net.channel or ""
netInfo["encryption"] = net.encryption netInfo["encryption"] = net.encryption
if withRaw then netInfo["_raw"] = l:dump(net) end if withRaw then netInfo["_raw"] = u.dump(net) end
table.insert(netInfoList, netInfo) table.insert(netInfoList, netInfo)
end end
end end
@ -87,7 +87,7 @@ function M.state(request, response)
response:addData("txpower", ds.txpower) response:addData("txpower", ds.txpower)
response:addData("signal", ds.signal) response:addData("signal", ds.signal)
response:addData("noise", ds.noise) response:addData("noise", ds.noise)
if withRaw then response:addData("_raw", l:dump(ds)) end if withRaw then response:addData("_raw", u.dump(ds)) end
end end
--UNTESTED --UNTESTED

View File

@ -1,4 +1,4 @@
local l = require("logger") local l = require("util.logger")
local ResponseClass = require("rest.response") local ResponseClass = require("rest.response")
local M = {} local M = {}

63
src/test/ansicolors.lua Normal file
View File

@ -0,0 +1,63 @@
-- adapted from: http://lua-users.org/wiki/AnsiTerminalColors
local pairs = pairs
local tostring = tostring
local setmetatable = setmetatable
local schar = string.char
local colormt = {}
function colormt:__tostring()
return self.value
end
function colormt:__concat(other)
return tostring(self) .. tostring(other)
end
function colormt:__call(s)
return self .. s .. colormt.reset
end
colormt.__metatable = {}
local function makecolor(value)
return setmetatable({ value = schar(27) .. '[' .. tostring(value) .. 'm' }, colormt)
end
local colors = {
-- attributes
reset = 0,
clear = 0,
bright = 1,
dim = 2,
underscore = 4,
blink = 5,
reverse = 7,
hidden = 8,
-- foreground
black = 30,
red = 31,
green = 32,
yellow = 33,
blue = 34,
magenta = 35,
cyan = 36,
white = 37,
-- background
onblack = 40,
onred = 41,
ongreen = 42,
onyellow = 43,
onblue = 44,
onmagenta = 45,
oncyan = 46,
onwhite = 47,
}
for c, v in pairs(colors) do
colormt[c] = makecolor(v)
end
return colormt

105
src/test/test_utils.lua Normal file
View File

@ -0,0 +1,105 @@
local utils = require("util.utils")
local M = {
_is_test = true,
_skip = { 'dump', 'symlink', 'getUciSectionName', 'symlinkInRoot' },
_wifibox_only = { 'getUciSectionName', 'symlinkInRoot' }
}
local function compareTables(t1, t2)
if #t1 ~= #t2 then return false end
for i=1,#t1 do
if t1[i] ~= t2[i] then return false end
end
return true
end
-- Returns a string representation of the argument, with 'real' strings enclosed in single quotes
local function stringRepresentation(v)
return type(v) == 'string' and ("'"..v.."'") or tostring(v)
end
local filename = "/tmp/somefile12345.txt"
function M:_setup()
os.execute("rm -f " .. filename) -- make sure the file does not exist
end
function M:_teardown()
os.execute("rm -f " .. filename) -- make sure the file gets removed again
end
function M:test_splitString()
local input1, input2, input3 = ':a:b::', '/a/b//', '$a$b$$'
local expected = { '', 'a', 'b', '', '' }
local result1 = input1:split()
local result2 = input2:split('/')
local result3 = input3:split('$')
assert(#result1 == 5)
assert(compareTables(result1, expected))
assert(#result2 == 5)
assert(compareTables(result2, expected))
assert(#result3 == 5)
assert(compareTables(result3, expected))
end
function M:test_toboolean()
local trues = { true, 1, 'true', 'True', 'T', '1' }
local falses = { nil, false, 0, 'false', 'False' , 'f', {} }
for _,v in pairs(trues) do assert(utils.toboolean(v), "expected true: " .. stringRepresentation(v)) end
for _,v in pairs(falses) do assert(not utils.toboolean(v), "expected false: " .. stringRepresentation(v)) end
end
function M:test_dump()
--test handling of reference loops
assert(false, 'not implemented')
end
function M:test_getUciSectionName()
assert(false, 'not implemented')
end
function M:test_exists()
assert(utils.exists() == nil)
assert(utils.exists(nil) == nil)
assert(not utils.exists(filename))
os.execute("touch " .. filename)
assert(utils.exists(filename))
end
function M:test_create()
local f, testContents = nil, 'test text'
assert(utils.create() == nil)
assert(utils.create(nil) == nil)
assert(not io.open(filename, 'r'))
utils.create(filename)
assert(io.open(filename, 'r'))
f = io.open(filename, 'w')
f:write(testContents)
f:close()
utils.create(filename)
f = io.open(filename, 'r')
local actualContents = f:read('*all')
assert(actualContents == testContents)
end
function M:test_symlink()
assert(false, 'not implemented')
end
function M:test_symlinkInRoot()
assert(false, 'not implemented')
end
return M

87
src/test/testrunner.lua Normal file
View File

@ -0,0 +1,87 @@
package.path = package.path .. ';./test/?.lua'
local ansicolors = require('test.ansicolors')
local testFunctionPrefix = 'test_'
local function tableIndexOf(t, val)
for k,v in ipairs(t) do
if v == val then return k end
end
return -1
end
local function runningOnWifibox()
local f,e = io.open('/etc/openwrt_release', 'r')
return f ~= nil
end
local function runTestFile(filename, showStackTraces)
local r,tf = pcall(require, filename)
local stackTrace, errorMessage
local function errorHandler(msg)
stackTrace = debug.traceback()
errorMessage = msg
end
if not r then return nil,tf end
if not tf._is_test then return nil,"not a test file" end
local setupFunc, teardownFunc = tf._setup, tf._teardown
print("======= running test file '" .. filename .. "' =======")
for k,v in pairs(tf) do
--if type(v) == 'function' and k ~= '_setup' and k ~= '_teardown' then
if type(v) == 'function' and k:find(testFunctionPrefix) == 1 then
local baseName = k:sub(testFunctionPrefix:len() + 1)
local skip = (tableIndexOf(tf._skip, baseName) > -1)
local wifiboxOnly = (tableIndexOf(tf._wifibox_only, baseName) > -1)
if not skip and (not wifiboxOnly or runningOnWifibox()) then
pcall(setupFunc)
local testResult = xpcall(v, errorHandler)
pcall(teardownFunc)
if testResult then
print(ansicolors.green .. "[OK ] " .. ansicolors.reset .. k)
else
print(ansicolors.red .. "[ERR] " .. ansicolors.reset .. k)
if errorMessage then print(" " .. errorMessage) end
if showStackTraces and stackTrace then print(" " .. stackTrace) end
end
else
if skip then
print(ansicolors.bright .. ansicolors.black .. "[SKP] " .. ansicolors.reset .. k)
else
print(ansicolors.yellow .. "[WBO] " .. ansicolors.reset .. k)
end
end
end
end
print("")
return true
end
local function main()
if #arg < 1 then
print("Please specify at least one lua test file")
os.exit(1)
end
for i=1,#arg do
local modName = 'test_'..arg[i]
local r,e = runTestFile(modName, true)
if not r then
io.stderr:write("test file '" .. modName .. "' could not be loaded or is not a test file ('" .. e .. "')\n")
end
end
os.exit(0)
end
main(arg)

43
src/test/www/restapi.css Normal file
View File

@ -0,0 +1,43 @@
body {
width: 100%;
}
div#content {
width: 80%;
margin: auto;
}
div.module {
border: 1px solid #eee;
margin-bottom: 1em;
}
.title {
padding-left: 2em;
font-size: larger;
display: block;
}
.resp_success {
color: green;
}
.resp_status {
margin-right: 1em;
}
.resp_fail {
color: yellow;
}
.resp_error {
color: red;
}
.resp_unknown {
color: purple;
}
span.resp_msg {
padding-left: 1em;
}

22
src/test/www/restapi.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<title>Test page for the Doodle3D WiFiBox REST API</title>
<link type="text/css" rel="stylesheet" href="restapi.css" />
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>
<script type="text/javascript" src="restapi.js"></script>
</head>
<body><div id="content">
<div id="module_test" class="module">
<span class="title">Test API</span>
</div>
<div id="module_network" class="module">
<span class="title">Network API</span>
</div>
<div>
<span class="title">Run arbitrary requests</span>
</div>
</div></body>
</html>

40
src/test/www/restapi.js Normal file
View File

@ -0,0 +1,40 @@
const API_ROOT = "/cgi-bin/d3dapi";
$(document).ready(function(){
performRequest('test', '123', "GET", {});
performRequest('test', '', "POST", {});
performRequest('test', 'success', "GET", {});
performRequest('test', 'fail', "GET", {});
performRequest('test', 'error', "GET", {});
performRequest('test', 'echo', "GET", {});
performRequest('test', 'write', "POST", {});
performRequest('test', 'read', "GET", {});
});
/*
* TODO
* - change function signature to: apiPath, data, expected
* - create request divs before ajax request so it's visible what 'threads' have been put out
* - function always tries both GET and POST and matches this with expected.methods
* - expected.methods can be 'GET', 'POST' or 'BOTH'
* - expected.status indicates what status was expected
* - expected.data.{...} indicates what fields should look like (regex/literal/...?)
* - anything not specified in expected.data is ignored
*/
function performRequest(mod, func, method, data) {
$.ajax({
type: method,
context: $("#module_" + mod),
url: API_ROOT + "/" + mod + "/" + func,
dataType: 'json',
data: data
}).done(function(response){
var status = response.status == 'success' ? '<span class="resp_status resp_success">+</span>'
: response.status == 'fail' ? '<span class="resp_status resp_fail">-</span>'
: response.status == 'error' ? '<span class="resp_status resp_error">!</span>'
: '<span class="resp_status resp_unknown">?</span>';
var modFunc = '<span class="resp_mod_func">' + mod + '/' + func + '</span><br/>';
this.append('<div id="resp_' + mod + '_' + func + '">' + status + modFunc
+ '<span class="resp_msg">' + response.msg + '</span></div>');
});
}

View File

@ -1,10 +1,12 @@
local utils = require('util.utils')
local M = {} local M = {}
local logLevel, logVerbose, logStream local logLevel, logVerbose, logStream
M.LEVEL = {"debug", "info", "warn", "error", "fatal"} M.LEVEL = {'debug', 'info', 'warn', 'error', 'fatal'}
--M.LEVEL already has idx=>name entries, now create name=>idx entries -- M.LEVEL already has idx=>name entries, now create name=>idx entries
for i,v in ipairs(M.LEVEL) do for i,v in ipairs(M.LEVEL) do
M.LEVEL[v] = i M.LEVEL[v] = i
end end
@ -15,38 +17,25 @@ function M:init(level, verbose)
logStream = stream or io.stdout logStream = stream or io.stdout
end end
--pass nil as stream to reset to stdout -- pass nil as stream to reset to stdout
function M:setStream(stream) function M:setStream(stream)
logStream = stream or io.stdout logStream = stream or io.stdout
end end
local function log(level, msg, verbose) local function log(level, msg, verbose)
if level >= logLevel then if level >= logLevel then
local now = os.date("%m-%d %H:%M:%S") local now = os.date('%m-%d %H:%M:%S')
local i = debug.getinfo(3) --the stack frame just above the logger call local i = debug.getinfo(3) --the stack frame just above the logger call
local v = verbose local v = verbose
if v == nil then v = logVerbose end if v == nil then v = logVerbose end
local name = i.name or "(nil)" local name = i.name or "(nil)"
local vVal = "nil" local vVal = 'nil'
local m = (type(msg) == "string") and msg or M:dump(msg) local m = (type(msg) == 'string') and msg or utils.dump(msg)
if v then logStream:write(now .. " (" .. M.LEVEL[level] .. ") " .. m .. " [" .. name .. "@" .. i.short_src .. ":" .. i.linedefined .. "]\n") if v then logStream:write(now .. " (" .. M.LEVEL[level] .. ") " .. m .. " [" .. name .. "@" .. i.short_src .. ":" .. i.linedefined .. "]\n")
else logStream:write(now .. " (" .. M.LEVEL[level] .. ") " .. m .. "\n") end else logStream:write(now .. " (" .. M.LEVEL[level] .. ") " .. m .. "\n") end
end end
end end
function M:dump(o)
if type(o) == 'table' then
local s = '{ '
for k,v in pairs(o) do
if type(k) ~= 'number' then k = '"'..k..'"' end
s = s .. '['..k..'] = ' .. M:dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
function M:debug(msg, verbose) log(M.LEVEL.debug, msg, verbose); return true end function M:debug(msg, verbose) log(M.LEVEL.debug, msg, verbose); return true end
function M:info(msg, verbose) log(M.LEVEL.info, msg, verbose); return true end function M:info(msg, verbose) log(M.LEVEL.info, msg, verbose); return true end
function M:warn(msg, verbose) log(M.LEVEL.warn, msg, verbose); return true end function M:warn(msg, verbose) log(M.LEVEL.warn, msg, verbose); return true end

View File

@ -1,9 +1,9 @@
local uci = require("uci").cursor() local uci = require('uci').cursor()
local M = {} local M = {}
function string:split(div) function string:split(div)
local div, pos, arr = div or ":", 0, {} local div, pos, arr = div or ':', 0, {}
for st,sp in function() return self:find(div, pos, true) end do for st,sp in function() return self:find(div, pos, true) end do
table.insert(arr, self:sub(pos, st - 1)) table.insert(arr, self:sub(pos, st - 1))
pos = sp + 1 pos = sp + 1
@ -15,26 +15,63 @@ end
function M.toboolean(s) function M.toboolean(s)
if not s then return false end if not s then return false end
local b = s:lower() local b = type(s) == 'string' and s:lower() or s
return (b == "1" or b == "t" or b == "true") and true or false local textTrue = (b == '1' or b == 't' or b == 'true')
local boolTrue = (type(b) == 'boolean' and b == true)
local numTrue = (type(b) == 'number' and b > 0)
return textTrue or boolTrue or numTrue
end
function M.dump(o)
if type(o) == 'table' then
local s = '{ '
for k,v in pairs(o) do
if type(k) ~= 'number' then k = '"'..k..'"' end
s = s .. '['..k..'] = ' .. M.dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end end
function M.getUciSectionName(config, type) function M.getUciSectionName(config, type)
local sname = nil local sname = nil
uci:foreach(config, type, function(s) sname = s[".name"] end) uci:foreach(config, type, function(s) sname = s['.name'] end)
return sname return sname
end end
function M.exists(file) function M.exists(file)
local r = io.open(file) --ignore returned message if not file or type(file) ~= 'string' or file:len() == 0 then
if r ~= nil then io.close(r) end return nil, "file must be a non-empty string"
end
local r = io.open(file, 'r') -- ignore returned message
if r then r:close() end
return r ~= nil return r ~= nil
end end
--creates and returns true if not exists, returns false it does, nil+msg on error
function M.create(file)
local r,m = M.exists(file)
if r == nil then
return r,m
elseif r == true then
return true
end
r,m = io.open(file, 'a') -- append mode is probably safer in case the file does exist after all
if not r then return r,m end
r:close()
return true
end
--FIXME: somehow protect this function from running arbitrary commands --FIXME: somehow protect this function from running arbitrary commands
function M.symlink(from, to) function M.symlink(from, to)
if from == nil or from == "" or to == nil or to == "" then return -1 end if from == nil or from == '' or to == nil or to == '' then return -1 end
local x = "ln -s " .. from .. " " .. to local x = 'ln -s ' .. from .. ' ' .. to
return os.execute(x) return os.execute(x)
end end