mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
🛸 Sync 2026-10-27 02:27:08
This commit is contained in:
1 parent
b3798acf79
commit
8b63d5d4a9
78 files changed
+2288
-518
No files matched your search
@@ -0,0 +1,74 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
function page(installed) {
|
||||
const elements = Object.fromEntries(['classic-kai-status', 'classic-kai-install', 'classic-kai-update', 'classic-kai-update-hint', 'classic-kai-open']
|
||||
.map(id => [id, { style: {}, disabled: false }]));
|
||||
const state = { elements, alerts: [], logs: [], reloads: 0 };
|
||||
function XHR() {}
|
||||
XHR.poll = (_, url, data, callback) => { state.poll = callback; };
|
||||
XHR.prototype.post = (url, data, callback) => { state.post = { url, data, callback }; };
|
||||
const context = {
|
||||
XHR, document: { getElementById: id => elements[id] },
|
||||
window: {
|
||||
confirm: () => true, alert: message => state.alerts.push(message),
|
||||
location: { reload: () => state.reloads++ },
|
||||
taskd: { csrfToken: 'test-token', show_log: id => state.logs.push(id) }
|
||||
}
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source.replace('PAGE_INSTALLED', String(installed)), context);
|
||||
state.context = context;
|
||||
return state;
|
||||
}
|
||||
let state = page(false);
|
||||
state.poll(null, { installed: false, running: false });
|
||||
assert.equal(state.elements['classic-kai-install'].style.display, '');
|
||||
assert.equal(state.elements['classic-kai-open'].style.display, 'none');
|
||||
assert.equal(state.elements['classic-kai-update'].style.display, 'none');
|
||||
state.context.installClassicKai();
|
||||
assert.equal(state.post.data.token, 'test-token');
|
||||
assert.equal(state.post.url, '/cgi-bin/luci/admin/services/kaiplus/install_kai');
|
||||
assert.equal(state.elements['classic-kai-install'].disabled, true);
|
||||
state.post.callback({ responseText: '{"ok":true,"task_id":"kaiplus-kai-install"}' });
|
||||
assert.deepEqual(state.logs, ['kaiplus-kai-install']);
|
||||
assert.equal(state.elements['classic-kai-install'].disabled, false);
|
||||
state.poll(null, { installed: true, running: false });
|
||||
assert.equal(state.reloads, 1);
|
||||
state = page(false);
|
||||
state.context.installClassicKai();
|
||||
state.post.callback({ responseText: 'invalid response' });
|
||||
assert.equal(state.alerts.length, 1);
|
||||
assert.equal(state.elements['classic-kai-install'].disabled, false);
|
||||
state.context.installClassicKai();
|
||||
state.post.callback({ responseText: '{"ok":false,"error":"installer unavailable"}' });
|
||||
assert.equal(state.alerts.at(-1), 'installer unavailable');
|
||||
state.context.installClassicKai();
|
||||
state.post.callback({ responseText: '{"ok":true,"installed":true}' });
|
||||
assert.equal(state.reloads, 1);
|
||||
state = page(true);
|
||||
state.poll(null, { installed: true, running: true });
|
||||
assert.equal(state.elements['classic-kai-install'].style.display, 'none');
|
||||
assert.equal(state.elements['classic-kai-update'].style.display, '');
|
||||
assert.equal(state.elements['classic-kai-open'].style.display, '');
|
||||
assert.equal(state.elements['classic-kai-status'].style.color, 'green');
|
||||
assert.equal(state.reloads, 0);
|
||||
assert.equal(state.context.classicOpenUrl, '/cgi-bin/luci/admin/services/linkease_apps/open?id=kai');
|
||||
state.poll(null, { installed: true, running: false });
|
||||
assert.equal(state.elements['classic-kai-open'].style.display, 'none');
|
||||
assert.equal(state.elements['classic-kai-update'].style.display, '');
|
||||
state.context.updateClassicKai();
|
||||
assert.equal(state.post.url, '/cgi-bin/luci/admin/services/kaiplus/update_kai');
|
||||
assert.equal(state.post.data.token, 'test-token');
|
||||
assert.equal(state.elements['classic-kai-update'].disabled, true);
|
||||
state.post.callback({ responseText: '{"ok":true,"busy":true,"task_id":"kaiplus-kai-install"}' });
|
||||
assert.deepEqual(state.logs, ['kaiplus-kai-install']);
|
||||
assert.equal(state.elements['classic-kai-update'].disabled, false);
|
||||
assert.equal(state.reloads, 0);
|
||||
state.context.updateClassicKai();
|
||||
state.post.callback({ responseText: 'invalid response' });
|
||||
assert.equal(state.alerts.length, 1);
|
||||
assert.equal(state.elements['classic-kai-update'].disabled, false);
|
||||
state.context.window.confirm = () => false;
|
||||
state.post = undefined;
|
||||
state.context.updateClassicKai();
|
||||
assert.equal(state.post, undefined);
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Real sources are embedded by the Python runner. All I/O is stubbed, so this
|
||||
-- harness can also run over SSH without changing the device's files or services.
|
||||
local checks = 0
|
||||
local function check(value, message)
|
||||
assert(value, message)
|
||||
checks = checks + 1
|
||||
end
|
||||
local runtime_files = { "/usr/sbin/kai_bin", "/usr/sbin/kai_session", "/etc/init.d/kai", "/etc/config/kai", "/usr/share/kai/kai-plugin.json" }
|
||||
local function fixture(o)
|
||||
o = o or {}
|
||||
local f = { calls = {}, routes = {} }
|
||||
local files = { ["/etc/config/kaiplus"] = true, ["/etc/init.d/tasks"] = true, ["/bin/is-opkg"] = true, ["/usr/libexec/kaiplus-classic"] = true }
|
||||
if o.installed then for _, path in ipairs(runtime_files) do files[path] = true end end
|
||||
if o.missing then files[o.missing] = nil end
|
||||
local fs = { access = function(path) return files[path] end }
|
||||
local sys = {
|
||||
call = function(command)
|
||||
f.calls[#f.calls + 1] = command
|
||||
if command:match("^pidof kai_bin") then return o.running and 0 or 1 end
|
||||
return o.rc or 0
|
||||
end,
|
||||
exec = function(command) f.task_query = command; return "task status fixture" end
|
||||
}
|
||||
package.loaded["nixio.fs"], package.loaded["luci.sys"] = fs, sys
|
||||
package.loaded["luci.jsonc"] = { parse = function() return o.task_status or {} end }
|
||||
package.loaded["luci.util"] = { shellquote = function(v) return "'" .. v:gsub("'", "'\\''") .. "'" end }
|
||||
package.loaded["luci.http"] = {
|
||||
getenv = function() return o.method or "POST" end,
|
||||
formvalue = function() return o.token or "valid-token" end,
|
||||
prepare_content = function() end,
|
||||
write_json = function(v) f.reply = v end,
|
||||
status = function(v) f.code = v end
|
||||
}
|
||||
package.loaded["luci.dispatcher"] = { context = { authsession = "session", token = "valid-token" } }
|
||||
if o.no_session then package.loaded["luci.dispatcher"].context.authsession = nil end
|
||||
f.classic = assert(loadstring(sources.classic))()
|
||||
package.loaded["luci.model.kaiplus.classic"] = f.classic
|
||||
f.paths = assert(loadstring(sources.paths))()
|
||||
f.paths.blocks = function() return {} end
|
||||
f.paths.home = function() return { Configs = "/mnt/test/Configs" } end
|
||||
package.loaded["luci.model.kaiplus"] = f.paths
|
||||
local cursor = { get_first = function(_, config, section, key)
|
||||
check(config == "kai" and section == "kai" and key == "enabled", "form must read only KAI configuration")
|
||||
return o.enabled or "0"
|
||||
end }
|
||||
local env = setmetatable({
|
||||
module = function() end, nixio = { fs = fs },
|
||||
_ = function(v) return v end, translate = function(v) return v end,
|
||||
entry = function(path, target)
|
||||
local r = { target = target }; f.routes[table.concat(path, "/")] = r; return r
|
||||
end,
|
||||
alias = function(...) return { kind = "alias", path = table.concat({...}, "/") } end,
|
||||
cbi = function(name) return { name = name } end,
|
||||
call = function(name) return { name = name } end,
|
||||
SimpleSection = {}, TypedSection = {}, Flag = {}, Value = {}
|
||||
}, { __index = _G })
|
||||
local function form(kind, name)
|
||||
local m = { kind = kind, name = name, sections = {}, uci = cursor }
|
||||
function m:section()
|
||||
local s = { fields = {} }
|
||||
function s:option(_, key)
|
||||
local field = { values = {} }
|
||||
function field:value(v) self.values[#self.values + 1] = v end
|
||||
self.fields[key] = field; return field
|
||||
end
|
||||
self.sections[#self.sections + 1] = s; return s
|
||||
end
|
||||
return m
|
||||
end
|
||||
env.Map = function(name) return form("map", name) end
|
||||
env.SimpleForm = function(name) return form("simple", name) end
|
||||
local function load(source)
|
||||
local chunk = assert(loadstring(source)); setfenv(chunk, env); return chunk()
|
||||
end
|
||||
load(sources.controller)
|
||||
f.controller, f.form = env, function() return load(sources.form) end
|
||||
return f
|
||||
end
|
||||
|
||||
local f = fixture()
|
||||
f.controller.index()
|
||||
check(f.routes["admin/services/kaiplus"].target.kind == "alias", "root entry must keep working")
|
||||
check(f.routes["admin/services/kaiplus/settings"].target.name == "kaiplus", "KaiPlus settings missing")
|
||||
check(f.routes["admin/services/kaiplus/classic"].target.name == "kaiplus/classic", "classic tab must exist without KAI")
|
||||
check(f.routes["admin/services/kaiplus/update_kai"].target.name == "update_kai", "update route missing")
|
||||
f.controller.classic_status()
|
||||
check(f.reply.installed == false and f.reply.running == false, "missing KAI status")
|
||||
f = fixture({ installed = true, running = true })
|
||||
f.controller.classic_status()
|
||||
check(f.reply.installed and f.reply.running, "runtime must work without luci-app-kai")
|
||||
check(f.calls[1] == "pidof kai_bin >/dev/null", "status must use classic process")
|
||||
for _, path in ipairs(runtime_files) do
|
||||
f = fixture({ installed = true, missing = path })
|
||||
check(not f.classic.installed(), "incomplete runtime must not be installed: " .. path)
|
||||
end
|
||||
for _, o in ipairs({ { method = "GET" }, { token = "wrong" }, { no_session = true } }) do
|
||||
f = fixture(o); f.controller.install_kai()
|
||||
check(f.code == (o.method == "GET" and 405 or 403), "install requires POST, session and CSRF")
|
||||
check(#f.calls == 0 and not f.task_query, "rejected install must not execute commands")
|
||||
o.installed = true
|
||||
f = fixture(o); f.controller.update_kai()
|
||||
check(f.code == (o.method == "GET" and 405 or 403), "update requires POST, session and CSRF")
|
||||
check(#f.calls == 0 and not f.task_query, "rejected update must not execute commands")
|
||||
end
|
||||
f = fixture(); f.controller.install_kai()
|
||||
check(f.reply.ok and f.reply.task_id == "kaiplus-kai-install", "install task missing")
|
||||
check(#f.calls == 1 and f.calls[1]:find("/usr/libexec/kaiplus%-classic install"), "install optional runtime")
|
||||
check(not f.calls[1]:find("luci%-app%-kai") and not f.calls[1]:find("/etc/init.d/kaiplus"), "install must not depend on old LuCI or restart KaiPlus")
|
||||
f = fixture({ installed = true }); f.controller.install_kai()
|
||||
check(f.reply.ok and f.reply.installed and #f.calls == 0, "already installed must be a no-op")
|
||||
f = fixture(); f.controller.update_kai()
|
||||
check(f.code == 409 and not f.reply.ok and #f.calls == 0, "update must not install an absent runtime")
|
||||
f = fixture({ installed = true }); f.controller.update_kai()
|
||||
check(f.reply.ok and f.reply.task_id == "kaiplus-kai-install", "update must share the installation task lock")
|
||||
check(#f.calls == 1 and f.calls[1]:find("/usr/libexec/kaiplus%-classic update"), "queue classic update")
|
||||
for _, running in ipairs({ true, 1, "1", "true" }) do
|
||||
f = fixture({ task_status = { running = running } }); f.controller.install_kai()
|
||||
check(f.reply.ok and f.reply.busy and #f.calls == 0, "do not duplicate busy installation")
|
||||
f = fixture({ installed = true, task_status = { running = running } }); f.controller.update_kai()
|
||||
check(f.reply.ok and f.reply.busy and #f.calls == 0, "install/update tasks must not overlap")
|
||||
end
|
||||
for _, path in ipairs({ "/etc/init.d/tasks", "/bin/is-opkg", "/usr/libexec/kaiplus-classic" }) do
|
||||
f = fixture({ missing = path }); f.controller.install_kai()
|
||||
check(not f.reply.ok and #f.calls == 0, "report unavailable installation support")
|
||||
f = fixture({ installed = true, missing = path }); f.controller.update_kai()
|
||||
check(not f.reply.ok and #f.calls == 0, "report unavailable update support")
|
||||
end
|
||||
f = fixture({ rc = 1 }); f.controller.install_kai()
|
||||
check(not f.reply.ok and f.reply.task_id, "report task submission failure")
|
||||
f = fixture({ installed = true, rc = 1 }); f.controller.update_kai()
|
||||
check(not f.reply.ok and f.reply.task_id, "report update submission failure")
|
||||
f = fixture()
|
||||
local m = f.form()
|
||||
check(m.kind == "simple" and m.submit == false and m.reset == false, "uninstalled tab must not create UCI form")
|
||||
check(#f.calls == 0, "viewing tab must not start services")
|
||||
f = fixture({ installed = true, enabled = "1" }); m = f.form()
|
||||
check(m.kind == "map" and m.name == "kai", "edit original KAI configuration")
|
||||
check(m.sections[2].fields.data_dir.default == "/mnt/test/Configs/KAI", "separate data directory")
|
||||
check(m.sections[2].fields.port.default == "8197" and m.sections[2].fields.port.datatype == "port", "validated classic port")
|
||||
m:on_after_commit()
|
||||
check(#f.calls == 1 and f.calls[1] == "/etc/init.d/kai restart >/dev/null 2>&1", "apply must restart only KAI")
|
||||
f = fixture({ installed = true, enabled = "0" }); m = f.form(); m:on_after_commit()
|
||||
check(#f.calls == 1 and f.calls[1] == "/etc/init.d/kai stop >/dev/null 2>&1", "disable must stop only KAI")
|
||||
f = fixture({ installed = true, enabled = "1", rc = 1 }); m = f.form(); m:on_after_commit()
|
||||
check(m.message ~= nil, "report service operation failure")
|
||||
local _, plus = f.paths.find_paths({}, { Configs = "/mnt/test/Configs" }, "Configs")
|
||||
local _, classic = f.paths.find_paths({}, { Configs = "/mnt/test/Configs" }, "Configs", "KAI")
|
||||
check(plus == "/mnt/test/Configs/KAIPlus" and classic == "/mnt/test/Configs/KAI", "preserve independent path defaults")
|
||||
local _, fallback = f.paths.find_paths({ "/mnt/data" }, { Configs = "/root/Configs" }, "Configs", "KAI")
|
||||
check(fallback == "/mnt/data/Configs/KAI", "preserve KAI disk selection")
|
||||
print(checks .. " classic KAI runtime checks passed")
|
||||
@@ -1,4 +1,11 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
@@ -6,51 +13,102 @@ APP_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ClassicKaiEntryContractTest(unittest.TestCase):
|
||||
def read(self, relative):
|
||||
return (APP_DIR / relative).read_text(encoding="utf-8")
|
||||
def test_classic_runtime_remains_an_optional_install(self):
|
||||
makefile = (APP_DIR / "Makefile").read_text()
|
||||
deps = re.search(r"^LUCI_DEPENDS:=(.*)$", makefile, re.M).group(1).split()
|
||||
self.assertIn("+kaiplus", deps)
|
||||
self.assertIn("+luci-lib-taskd", deps)
|
||||
for package in ("kai", "kai_session", "kai-agent", "luci-app-kai"):
|
||||
self.assertNotIn("+" + package, deps)
|
||||
|
||||
def test_status_reports_classic_kai_manifest(self):
|
||||
controller = self.read("luasrc/controller/kaiplus.lua")
|
||||
def test_lua_routes_installation_and_configuration(self):
|
||||
command = os.environ.get("LUA_COMMAND")
|
||||
if not command:
|
||||
lua = shutil.which("lua5.1") or shutil.which("lua") or shutil.which("luajit")
|
||||
if not lua:
|
||||
self.skipTest("Lua 5.1 required; LUA_COMMAND can use an SSH interpreter")
|
||||
command = shlex.quote(lua) + " -"
|
||||
paths = {
|
||||
"controller": "luasrc/controller/kaiplus.lua",
|
||||
"classic": "luasrc/model/kaiplus/classic.lua",
|
||||
"paths": "luasrc/model/kaiplus.lua",
|
||||
"form": "luasrc/model/cbi/kaiplus/classic.lua",
|
||||
}
|
||||
sources = "local sources = {}\n"
|
||||
for name, relative in paths.items():
|
||||
body = (APP_DIR / relative).read_text()
|
||||
self.assertNotIn("]====]", body)
|
||||
sources += f"sources.{name} = [====[{body}]====]\n"
|
||||
harness = (APP_DIR / "tests/classic_kai_runtime.lua").read_text()
|
||||
result = subprocess.run(shlex.split(command), input=sources + harness,
|
||||
text=True, capture_output=True, timeout=45)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("classic KAI runtime checks passed", result.stdout)
|
||||
|
||||
self.assertIn('fs.access("/usr/share/kai/kai-plugin.json")', controller)
|
||||
self.assertIn('fs.access("/usr/lib/lua/luci/controller/kai.lua")', controller)
|
||||
def test_installation_ui_retries_and_reloads_the_embedded_form(self):
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
self.skipTest("Node.js required for browser behavior checks")
|
||||
template = (APP_DIR / "luasrc/view/kaiplus/classic_status.htm").read_text()
|
||||
script = re.search(r"<script[^>]*>(.*?)</script>", template, re.S).group(1)
|
||||
script = re.sub(r'<%=\s*installed and "true" or "false"\s*%>', "PAGE_INSTALLED", script)
|
||||
script = re.sub(r'<%=url\("([^"]+)"\)%>', r"/cgi-bin/luci/\1", script)
|
||||
script = re.sub(r"<%:([^%]+)%>", r"\1", script)
|
||||
self.assertNotIn("<%", script)
|
||||
harness = (APP_DIR / "tests/classic_kai_browser.cjs").read_text()
|
||||
result = subprocess.run([node, "-e", "const source = " + json.dumps(script) + ";\n" + harness],
|
||||
text=True, capture_output=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_entry_opens_kai_luci_and_prompts_to_install_when_missing(self):
|
||||
controller = self.read("luasrc/controller/kaiplus.lua")
|
||||
status = self.read("luasrc/view/kaiplus/kaiplus_status.htm")
|
||||
def test_package_updates_preserve_state_and_stop_on_failure(self):
|
||||
source = (APP_DIR / "root/usr/libexec/kaiplus-classic").read_text()
|
||||
with tempfile.TemporaryDirectory(prefix="classic-kai-test-") as directory:
|
||||
root = Path(directory)
|
||||
stub = '''#!/bin/sh
|
||||
case "${0##*/}" in
|
||||
pidof) [ "$TEST_RUNNING" = 1 ]; exit $?;;
|
||||
uci) printf '%s\\n' "$TEST_ENABLED"; exit 0;;
|
||||
esac
|
||||
printf '%s %s\\n' "${0##*/}" "$*" >> "$TEST_LOG"
|
||||
if [ "$TEST_FAIL" = "${0##*/}:$1" ]; then exit 1; fi
|
||||
'''
|
||||
for name in ("is-opkg", "kai", "pidof", "uci"):
|
||||
path = root / name
|
||||
path.write_text(stub)
|
||||
path.chmod(0o755)
|
||||
script = source.replace("/bin/is-opkg", shlex.quote(str(root / "is-opkg")))
|
||||
script = script.replace("/etc/init.d/kai", shlex.quote(str(root / "kai")))
|
||||
path = root / "helper.sh"
|
||||
path.write_text(script)
|
||||
|
||||
self.assertNotIn('entry({"admin", "services", "kaiplus", "open_kai"}', controller)
|
||||
self.assertIn('url("admin/services/kai")', status)
|
||||
self.assertIn("st.kai_installed === true", status)
|
||||
self.assertIn("function openClassicKai()", status)
|
||||
self.assertIn("Classic KAI is not installed. Install it now?", status)
|
||||
self.assertIn("window.location.href = kaiLuciUrl", status)
|
||||
def run(action, running="1", enabled="1", fail=""):
|
||||
log = root / "commands.log"
|
||||
log.write_text("")
|
||||
env = {**os.environ, "PATH": str(root) + os.pathsep + os.environ["PATH"],
|
||||
"TEST_LOG": str(log), "TEST_RUNNING": running,
|
||||
"TEST_ENABLED": enabled, "TEST_FAIL": fail}
|
||||
result = subprocess.run(["sh", str(path), action], env=env,
|
||||
text=True, capture_output=True, timeout=10)
|
||||
return result, log.read_text().splitlines()
|
||||
|
||||
def test_missing_kai_starts_taskd_install_and_opens_log(self):
|
||||
makefile = self.read("Makefile")
|
||||
controller = self.read("luasrc/controller/kaiplus.lua")
|
||||
status = self.read("luasrc/view/kaiplus/kaiplus_status.htm")
|
||||
|
||||
self.assertIn("+luci-lib-taskd", makefile)
|
||||
self.assertIn('<%+tasks/embed%>', status)
|
||||
self.assertIn('entry({"admin", "services", "kaiplus", "install_kai"}', controller)
|
||||
self.assertIn("require_post_csrf()", controller)
|
||||
self.assertIn("/bin/is-opkg install luci-app-kai", controller)
|
||||
self.assertNotIn("app-meta-kai", controller)
|
||||
self.assertIn("/etc/init.d/tasks task_add ", controller)
|
||||
self.assertIn("window.taskd.show_log", status)
|
||||
self.assertIn("token: kaiCsrfToken", status)
|
||||
|
||||
def test_action_buttons_share_one_row(self):
|
||||
status = self.read("luasrc/view/kaiplus/kaiplus_status.htm")
|
||||
|
||||
actions = status.index('<div class="kaiplus-actions">')
|
||||
kaiplus = status.index('id="kaiplus-open"', actions)
|
||||
classic = status.index('id="kaiplus-open-kai"', actions)
|
||||
actions_end = status.index("</div>", classic)
|
||||
self.assertLess(kaiplus, classic)
|
||||
self.assertLess(classic, actions_end)
|
||||
self.assertIn("margin: 16px;", status)
|
||||
result, commands = run("install")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(commands, ["is-opkg install kai"])
|
||||
for running, enabled in (("1", "1"), ("0", "1"), ("1", "0"), ("0", "0")):
|
||||
with self.subTest(running=running, enabled=enabled):
|
||||
result, commands = run("update", running, enabled)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
operation = "restart" if running == enabled == "1" else "stop"
|
||||
self.assertEqual(commands, ["is-opkg update", "is-opkg upgrade kai kai_session", "kai " + operation])
|
||||
for failure, count in (("is-opkg:update", 1), ("is-opkg:upgrade", 2), ("kai:restart", 3)):
|
||||
with self.subTest(failure=failure):
|
||||
result, commands = run("update", fail=failure)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(len(commands), count)
|
||||
self.assertNotIn("update completed", result.stdout)
|
||||
result, commands = run("unknown")
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user