Step 2: the settings page can change things - shared draft, Review & Apply, rollback, history, undo
- settings/apply.py: the Apply pipeline (check, keep history, write, restart the watchdog, re-import pages by the marker-only rule, recolour page keys), rollback when the watchdog does not come back, the recording guard with the Emergency (LIVE) override, history entries and Go back / Undo. - settings/server.py: the write routes behind the editing code (draft PUT/DELETE with 409 on a conflict, validate, apply, restore, undo, watchdog restart/stop/start, commands to the watchdog, the switcher file upload / pick newest / discard, gear notes). - settings/ui/app.js: every screen grows inputs with Editing on; keys (checks, fixes, hold-always, members), computers, fixes, site setup (schedules, Dante devices, Spotify, ATEM), Review & Apply with live progress, History with Go back and Undo, Run this fix now. - schema.unmask, push_page.push, nav_keys.colour; tests for all of it (163 pass). - Verified live on NA Ingest: a page-only Apply, a restart Apply, Undo, history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""Saving a change, offline: the shared draft, what Apply will do, the pipeline against a temp data
|
||||
dir with a fake launchctl and a fake Companion, rollback when the watchdog does not come back, the
|
||||
recording guard with its Emergency override, history and Go back."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import status_panel as sp # noqa: E402
|
||||
from settings import apply as ap # noqa: E402
|
||||
from settings import server # noqa: E402
|
||||
from test_status_panel import CONFIG # noqa: E402
|
||||
|
||||
|
||||
class FakeLaunchctl:
|
||||
"""Records calls; a kickstart or bootstrap "starts" the watchdog by writing started.json
|
||||
(with the sha of the config on disk) unless told to fail."""
|
||||
|
||||
def __init__(self, data_dir, fail=False):
|
||||
self.data_dir, self.fail, self.calls, self.pid = data_dir, fail, [], 100
|
||||
|
||||
def __call__(self, argv, **kw):
|
||||
self.calls.append(argv)
|
||||
|
||||
class R:
|
||||
returncode, stdout, stderr = 0, "", ""
|
||||
r = R()
|
||||
if argv[1] == "print":
|
||||
r.stdout = "state = running\n\tpid = %d\n" % self.pid
|
||||
elif argv[1] in ("kickstart", "bootstrap"):
|
||||
if not self.fail:
|
||||
self.pid += 1
|
||||
cfg_sha = ap.sha(open(os.path.join(self.data_dir, "config.json"), "rb").read())
|
||||
sp.write_json(os.path.join(self.data_dir, "started.json"), {"pid": self.pid, "started_at": time.time(), "config_sha": cfg_sha, "version": "test"})
|
||||
return r
|
||||
|
||||
|
||||
class TestDraftAndPlan(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.data = tempfile.mkdtemp(prefix="sp-apply-")
|
||||
self.cfg = json.loads(json.dumps(CONFIG))
|
||||
self.cfg["companion"] = {"host": "127.0.0.1", "port": 1}
|
||||
with open(os.path.join(self.data, "config.json"), "wb") as f:
|
||||
f.write(ap.config_text(self.cfg))
|
||||
|
||||
def test_draft_is_shared_and_never_overwritten_silently(self):
|
||||
d = ap.Draft(self.data)
|
||||
a, _ = d.update({"x": 1}, "sam", "sha1")
|
||||
self.assertEqual(oct(os.stat(d.path).st_mode)[-3:], "600")
|
||||
b, conflict = d.update({"x": 2}, "chris", "sha1", expect=a["version"])
|
||||
self.assertIsNone(conflict)
|
||||
_, conflict = d.update({"x": 3}, "sam", "sha1", expect=a["version"]) # sam's tab is behind
|
||||
self.assertEqual(conflict["by"], "chris")
|
||||
self.assertEqual(d.read()["config"], {"x": 2})
|
||||
|
||||
def test_what_apply_does(self):
|
||||
hashes = ap.build_hashes(self.cfg)
|
||||
same = json.loads(json.dumps(self.cfg))
|
||||
self.assertEqual(ap.what_apply_does(self.cfg, same, hashes)["changes"], [])
|
||||
notes = json.loads(json.dumps(self.cfg))
|
||||
notes["gear"] = {"UHDJr": {"location": "lobby rack"}}
|
||||
plan = ap.what_apply_does(self.cfg, notes, hashes)
|
||||
self.assertFalse(plan["restart"]) # page-only data
|
||||
self.assertEqual(plan["pages"], [])
|
||||
timing = json.loads(json.dumps(self.cfg))
|
||||
timing["debounce_seconds"] = 4
|
||||
self.assertTrue(ap.what_apply_does(self.cfg, timing, hashes)["restart"])
|
||||
self.assertEqual(ap.what_apply_does(self.cfg, timing, hashes)["pages"], [])
|
||||
moved = json.loads(json.dumps(self.cfg))
|
||||
next(t for t in moved["tiles"] if t["id"] == "led")["pos"] = [2, 8]
|
||||
plan = ap.what_apply_does(self.cfg, moved, hashes)
|
||||
self.assertEqual(plan["pages"], ["status"])
|
||||
self.assertTrue(plan["nav"])
|
||||
label = json.loads(json.dumps(self.cfg))
|
||||
label["fixes"]["vista"]["label"] = "start Vista please" # only a key note changes
|
||||
self.assertEqual(ap.what_apply_does(self.cfg, label, hashes)["pages"], [])
|
||||
|
||||
def test_words(self):
|
||||
after = json.loads(json.dumps(self.cfg))
|
||||
hd2 = next(t for t in after["tiles"] if t["id"] == "hd2")
|
||||
hd2["checks"][0]["optional"] = False
|
||||
after["site"]["editing_code"] = "new words"
|
||||
after["hosts"]["lighting"]["notes"] = "keep it Public"
|
||||
texts = [ap.words_for(p, a, b, self.cfg, after)["text"] for p, a, b in ap.changed_paths(self.cfg, after)]
|
||||
self.assertIn("key HD2 › check 1 › optional: yes → no", texts)
|
||||
self.assertIn("computer Lighting PC › notes: Keep the network profile on Public: rtpMIDI's Bonjour rules only allow Public. → keep it Public", texts)
|
||||
self.assertTrue(any(t == "site › editing code changed" for t in texts), texts) # a secret: never its value
|
||||
|
||||
|
||||
class TestPipeline(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.data = tempfile.mkdtemp(prefix="sp-pipe-")
|
||||
self.cfg = json.loads(json.dumps(CONFIG))
|
||||
self.cfg["companion"] = {"host": "127.0.0.1", "port": 1}
|
||||
with open(os.path.join(self.data, "config.json"), "wb") as f:
|
||||
f.write(ap.config_text(self.cfg))
|
||||
open(os.path.join(self.data, "atem-reference.xml"), "w").write("<x/>")
|
||||
self.site = server.Site(self.data, companion=mock.Mock())
|
||||
self.launchctl = FakeLaunchctl(self.data)
|
||||
self.applier = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: None)
|
||||
|
||||
def run_apply(self, new_cfg, note="t", emergency=None):
|
||||
r = self.applier.start(new_cfg, note, "sam", "10.1.1.50", emergency)
|
||||
if r.get("ok"):
|
||||
self.applier.thread.join(30)
|
||||
return r
|
||||
|
||||
def test_a_timing_change_restarts_and_is_kept_in_history(self):
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
new["debounce_seconds"] = 4
|
||||
r = self.run_apply(new, "slower")
|
||||
self.assertTrue(r["ok"])
|
||||
st = self.applier.status
|
||||
self.assertEqual(st["state"], "done", st)
|
||||
names = {s["name"]: s["state"] for s in st["steps"]}
|
||||
self.assertEqual((names["check"], names["save"], names["restart"], names["pages"]), ("ok", "ok", "ok", "skipped"))
|
||||
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 4)
|
||||
self.assertTrue(any(a[1] == "kickstart" for a in self.launchctl.calls))
|
||||
entries = ap.history_entries(self.data)
|
||||
self.assertEqual((entries[0]["by"], entries[0]["note"], entries[0]["actions"]), ("sam", "slower", ["restart"]))
|
||||
self.assertEqual(ap.history_config(self.data, entries[0]["id"])["debounce_seconds"], 3) # the version before
|
||||
self.assertEqual(oct(os.stat(os.path.join(self.data, "history", entries[0]["id"] + ".json")).st_mode)[-3:], "600")
|
||||
|
||||
def test_page_only_data_needs_no_restart(self):
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
new["gear"] = {"UHDJr": {"location": "lobby"}}
|
||||
self.run_apply(new)
|
||||
names = {s["name"]: s["state"] for s in self.applier.status["steps"]}
|
||||
self.assertEqual(names["restart"], "skipped")
|
||||
self.assertFalse(any(a[1] == "kickstart" for a in self.launchctl.calls))
|
||||
|
||||
def test_a_bad_config_is_refused_before_anything_happens(self):
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
new["fixes"]["vista"]["host"] = "nowhere"
|
||||
self.run_apply(new)
|
||||
self.assertEqual(self.applier.status["state"], "failed")
|
||||
self.assertIn("unknown computer nowhere", self.applier.status["outcome"])
|
||||
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["fixes"]["vista"]["host"], "lighting")
|
||||
self.assertEqual(ap.history_entries(self.data), [])
|
||||
|
||||
def test_rollback_when_the_watchdog_does_not_come_back(self):
|
||||
self.launchctl.fail = True
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
new["debounce_seconds"] = 5
|
||||
self.run_apply(new, "will fail")
|
||||
st = self.applier.status
|
||||
self.assertEqual(st["state"], "failed")
|
||||
self.assertIn("previous settings", st["outcome"].lower())
|
||||
self.assertEqual(json.load(open(os.path.join(self.data, "config.json")))["debounce_seconds"], 3) # put back
|
||||
self.assertTrue(os.path.exists(os.path.join(self.data, "last-apply-failed.json")))
|
||||
meta = ap.history_entries(self.data)[0]
|
||||
self.assertIn("rolled back", meta["outcome"])
|
||||
|
||||
def test_layout_change_imports_the_page_and_recolours_the_nav_keys(self):
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
|
||||
pushed, coloured, calls = [], [], {"n": 0}
|
||||
|
||||
def fetch(h, p, page): # before the push: only page keys; after: as built
|
||||
calls["n"] += 1
|
||||
return {"page": {"name": "19\\nStatus", "controls": {}}} if calls["n"] % 2 == 1 else ap.make_page.build(new)
|
||||
with mock.patch.object(ap.push_page, "fetch_page", fetch), \
|
||||
mock.patch.object(ap.push_page, "push", lambda *a: pushed.append(a[2])), \
|
||||
mock.patch.object(ap.nav_keys, "colour", lambda *a: coloured.append(a) or 96):
|
||||
self.run_apply(new, "move LED")
|
||||
st = self.applier.status
|
||||
self.assertEqual(st["state"], "done", st)
|
||||
self.assertEqual(pushed, [19])
|
||||
self.assertEqual(len(coloured), 1)
|
||||
self.assertEqual(json.load(open(os.path.join(self.data, "build-hashes.json")))["status"], ap.build_hashes(new)["status"])
|
||||
|
||||
def test_a_foreign_key_on_the_page_stops_the_import_and_rolls_back(self):
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
next(t for t in new["tiles"] if t["id"] == "led")["pos"] = [2, 8]
|
||||
busy = {"page": {"name": "19\\nStatus", "controls": {"2": {"3": {"type": "button-layered", "options": {"notes": "someone's key"}}}}}}
|
||||
with mock.patch.object(ap.push_page, "fetch_page", lambda h, p, page: busy), mock.patch.object(ap.push_page, "push", lambda *a: self.fail("must not push")):
|
||||
self.run_apply(new, "move LED")
|
||||
st = self.applier.status
|
||||
self.assertEqual(st["state"], "failed")
|
||||
self.assertIn("row 2 col 3", st["outcome"])
|
||||
self.assertEqual(next(t for t in json.load(open(os.path.join(self.data, "config.json")))["tiles"] if t["id"] == "led")["pos"], [3, 6])
|
||||
self.assertTrue(os.path.exists(os.path.join(self.data, "pages-out-of-sync.json")))
|
||||
|
||||
def test_live_refuses_unless_emergency(self):
|
||||
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "a HyperDeck is recording", "time": time.time()})
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
new["debounce_seconds"] = 4
|
||||
r = self.run_apply(new)
|
||||
self.assertFalse(r["ok"])
|
||||
self.assertTrue(r.get("live"))
|
||||
r = self.run_apply(new, "urgent", {"word": "LIVE", "reason": "the deck is wrong"})
|
||||
self.assertTrue(r["ok"])
|
||||
self.assertEqual(self.applier.status["state"], "done")
|
||||
self.assertIn("EMERGENCY apply by sam", open(os.path.join(self.data, "apply.log")).read())
|
||||
sp.write_json(os.path.join(self.data, "live-now.json"), {"live": True, "reason": "stale", "time": time.time() - 120})
|
||||
self.assertEqual(self.applier.live(), (False, "")) # stale: the schedule (none) decides
|
||||
|
||||
def test_a_new_switcher_file_goes_with_the_config_and_is_kept(self):
|
||||
pending = os.path.join(self.data, "draft-atem.xml")
|
||||
open(pending, "w").write("<y/>")
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
r = self.applier.start(new, "new switcher file", "sam", "10.1.1.50", None, pending)
|
||||
self.applier.thread.join(30)
|
||||
self.assertEqual(open(os.path.join(self.data, "atem-reference.xml")).read(), "<y/>")
|
||||
self.assertTrue(os.listdir(os.path.join(self.data, "history", "atem"))) # the old one, dated
|
||||
self.assertFalse(os.path.exists(pending))
|
||||
self.assertIn("switcher file", ap.history_entries(self.data)[0]["actions"])
|
||||
|
||||
def test_one_apply_at_a_time(self):
|
||||
gate = threading.Event()
|
||||
slow = ap.Applier(self.site, run=self.launchctl, sleep=lambda s: gate.wait(2))
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
new["debounce_seconds"] = 4
|
||||
self.assertTrue(slow.start(new, "a", "sam", "x")["ok"])
|
||||
self.assertIn("already running", slow.start(new, "b", "sam", "x")["error"])
|
||||
gate.set()
|
||||
slow.thread.join(30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+81
-3
@@ -20,10 +20,11 @@ from settings import keyguide, probes, server # noqa: E402
|
||||
from test_status_panel import CONFIG, FakeCompanion, healthy_snapshot # noqa: E402
|
||||
|
||||
|
||||
def get(port, path, json_body=None, origin=None):
|
||||
def get(port, path, json_body=None, origin=None, method=None, code=None, who=None):
|
||||
data = json.dumps(json_body).encode() if json_body is not None else None
|
||||
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data,
|
||||
headers={"Content-Type": "application/json", **({"Origin": origin} if origin else {})})
|
||||
headers = {"Content-Type": "application/json", **({"Origin": origin} if origin else {}),
|
||||
**({"X-Edit-Key": code} if code else {}), **({"X-Editor": who} if who else {})}
|
||||
req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
body = r.read()
|
||||
@@ -134,6 +135,83 @@ class TestServer(unittest.TestCase):
|
||||
self.site.attempts.clear()
|
||||
self.assertEqual(get(self.port, "/api/editing/check", {"code": "x"}, origin="http://evil.example")[0], 403)
|
||||
|
||||
# ---- step 2: the write routes ----
|
||||
CODE = "apple river stone"
|
||||
|
||||
def put(self, path, body, method="PUT", who="sam", code=None):
|
||||
st, raw = get(self.port, path, body, method=method, code=code or self.CODE, who=who)
|
||||
return st, json.loads(raw)
|
||||
|
||||
def test_writes_need_the_code_and_a_name(self):
|
||||
self.site.attempts.clear()
|
||||
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT")[0], 401)
|
||||
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code="wrong", who="sam")[0], 401)
|
||||
self.assertEqual(get(self.port, "/api/draft", {"config": {}}, method="PUT", code=self.CODE)[0], 400) # no name
|
||||
self.site.attempts.clear()
|
||||
|
||||
def test_draft_round_trip_conflict_and_discard(self):
|
||||
self.site.attempts.clear()
|
||||
live = self.site.load()
|
||||
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 4
|
||||
st, r = self.put("/api/draft", {"config": mine})
|
||||
self.assertEqual(st, 200)
|
||||
v1 = r["draft"]["version"]
|
||||
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 4)
|
||||
self.assertTrue(r["draft"]["config"]["site"]["editing_code"].startswith("set (")) # masked on the way out
|
||||
self.assertEqual(json.loads(get(self.port, "/api/draft")[1])["draft"]["by"], "sam")
|
||||
theirs = json.loads(json.dumps(live)); theirs["debounce_seconds"] = 5
|
||||
st, r = self.put("/api/draft", {"config": theirs, "expect": v1}, who="chris")
|
||||
self.assertEqual(st, 200)
|
||||
st, r = self.put("/api/draft", {"config": mine, "expect": v1}) # sam's tab is behind
|
||||
self.assertEqual(st, 409)
|
||||
self.assertIn("chris", r["error"])
|
||||
self.assertEqual(r["draft"]["config"]["debounce_seconds"], 5)
|
||||
self.assertEqual(self.site.draft.read()["config"]["site"]["editing_code"], self.CODE) # the secret survived the masked round trip
|
||||
st, r = self.put("/api/validate", {})
|
||||
self.assertEqual(st, 200)
|
||||
self.assertTrue(any("debounce seconds: %d → 5" % live["debounce_seconds"] in c["text"] for c in r["changes"]), r["changes"])
|
||||
self.assertIn("purple", r["feel"].lower())
|
||||
self.assertTrue(r["live"]["live"])
|
||||
self.assertEqual(self.put("/api/draft", {}, method="DELETE")[0], 200)
|
||||
self.assertIsNone(json.loads(get(self.port, "/api/draft")[1])["draft"])
|
||||
|
||||
def test_apply_waits_for_live_then_runs_restores_and_undoes(self):
|
||||
from test_apply import FakeLaunchctl
|
||||
self.site.attempts.clear()
|
||||
fake = FakeLaunchctl(self.data)
|
||||
self.site.applier.run, self.site.applier.sleep = fake, lambda s: None
|
||||
live = self.site.load()
|
||||
mine = json.loads(json.dumps(live)); mine["debounce_seconds"] = 6
|
||||
self.put("/api/draft", {"config": mine})
|
||||
st, r = self.put("/api/apply", {"note": "slower"}, method="POST")
|
||||
self.assertEqual(st, 409)
|
||||
self.assertTrue(r.get("live"))
|
||||
self.assertIsNotNone(self.site.draft.read()) # the draft is kept
|
||||
st, r = self.put("/api/apply", {"note": "slower", "emergency": {"word": "LIVE", "reason": "deck wrong"}}, method="POST")
|
||||
self.assertEqual(st, 200, r)
|
||||
self.site.applier.thread.join(30)
|
||||
self.assertIsNone(self.site.draft.read())
|
||||
status = json.loads(get(self.port, "/api/apply/status")[1])
|
||||
self.assertEqual(status["state"], "done", status)
|
||||
self.assertEqual(self.site.load()["debounce_seconds"], 6)
|
||||
hist = json.loads(get(self.port, "/api/history")[1])["entries"]
|
||||
self.assertEqual((hist[0]["by"], hist[0]["note"]), ("sam", "slower"))
|
||||
detail = json.loads(get(self.port, "/api/history/" + hist[0]["id"])[1])
|
||||
self.assertTrue(any("debounce seconds: %d → 6" % live["debounce_seconds"] in c["text"] for c in detail["changes_to_now"]), detail)
|
||||
st, r = self.put("/api/restore/" + hist[0]["id"], {}, method="POST")
|
||||
self.assertEqual(r["draft"]["config"]["debounce_seconds"], live["debounce_seconds"])
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
st, r = self.put("/api/undo", {}, method="POST")
|
||||
self.assertEqual((st, r["draft"]["config"]["debounce_seconds"], r["note"]), (200, live["debounce_seconds"], "slower"))
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
# watchdog control waits for live too; a command goes to the watchdog through Companion
|
||||
self.assertEqual(self.put("/api/watchdog/restart", {}, method="POST")[0], 409)
|
||||
st, r = self.put("/api/command", {"text": "service off"}, method="POST")
|
||||
self.assertEqual((st, r["sent"]), (200, "service off"))
|
||||
self.assertIn("service off", FakeCompanion.store["custom"]["sp_cmd"])
|
||||
self.assertEqual(self.put("/api/command", {"text": "rm -rf"}, method="POST")[0], 400)
|
||||
self.assertEqual(self.put("/api/command", {"text": "test hd2 vista 0"}, method="POST")[0], 409) # no test while live
|
||||
|
||||
def test_probe_is_refused_while_a_fix_runs(self):
|
||||
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user