🛸 Sync 2026-10-27 02:27:08

This commit is contained in:
github-actions[bot] committed 2026-10-27 02:27:08 +08:00
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__":