verify-node.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. #!/usr/bin/env python3
  2. """Run one node verification case against the live SmartBotic services.
  3. Usage: scripts/verify-node.py tests/nodes/<case>.json
  4. Exit 0 if every assertion holds, 1 if one does not, 2 if the case declares a
  5. precondition this machine does not meet - reported as a skip, never as a pass.
  6. """
  7. import json
  8. import sys
  9. import time
  10. import urllib.error
  11. import urllib.request
  12. BASE = "http://localhost:8090/api/v1"
  13. def call(method, path, token=None, body=None):
  14. data = json.dumps(body).encode() if body is not None else None
  15. req = urllib.request.Request(BASE + path, data=data, method=method)
  16. req.add_header("Content-Type", "application/json")
  17. if token:
  18. req.add_header("Authorization", "Bearer " + token)
  19. try:
  20. with urllib.request.urlopen(req, timeout=30) as res:
  21. raw = res.read()
  22. return json.loads(raw) if raw else {}
  23. except urllib.error.HTTPError as e:
  24. raise SystemExit(f"{method} {path} failed: {e.code} {e.read().decode()[:400]}")
  25. def login():
  26. return call("POST", "/auth/login", body={"username": "admin", "password": "admin"})["accessToken"]
  27. def subset_matches(expected, actual, path):
  28. """Every key in expected must be present and equal in actual. Returns list of failures."""
  29. fails = []
  30. if isinstance(expected, dict):
  31. if not isinstance(actual, dict):
  32. return [f"{path}: expected an object, got {type(actual).__name__}"]
  33. for key, want in expected.items():
  34. if key not in actual:
  35. fails.append(f"{path}.{key}: missing")
  36. else:
  37. fails += subset_matches(want, actual[key], f"{path}.{key}")
  38. elif isinstance(expected, list):
  39. if not isinstance(actual, list):
  40. return [f"{path}: expected a list, got {type(actual).__name__}"]
  41. if len(expected) != len(actual):
  42. fails.append(f"{path}: expected {len(expected)} items, got {len(actual)}")
  43. else:
  44. for i, want in enumerate(expected):
  45. fails += subset_matches(want, actual[i], f"{path}[{i}]")
  46. elif expected != actual:
  47. fails.append(f"{path}: expected {expected!r}, got {actual!r}")
  48. return fails
  49. def run_http_case(case, token, workflow_id):
  50. """A webhook case asserts on the HTTP response, not on node outputs."""
  51. spec = case["http"]
  52. url = BASE.replace("/api/v1", "") + spec["path"].replace("{workflowId}", workflow_id)
  53. method = spec.get("method", "POST")
  54. data = None
  55. if method in ("POST", "PUT", "PATCH"):
  56. body = spec.get("body", {})
  57. pad = spec.get("bodyPadBytes", 0)
  58. if pad:
  59. body = dict(body)
  60. body["_pad"] = "x" * pad
  61. data = json.dumps(body).encode()
  62. req = urllib.request.Request(url, data=data, method=method)
  63. if data is not None:
  64. req.add_header("Content-Type", "application/json")
  65. failures = []
  66. try:
  67. with urllib.request.urlopen(req, timeout=45) as res:
  68. status, headers, raw = res.status, dict(res.headers), res.read().decode()
  69. except urllib.error.HTTPError as e:
  70. status, headers, raw = e.code, dict(e.headers), e.read().decode()
  71. print("http: %s %s -> %s" % (spec.get("method", "POST"), spec["path"], status))
  72. print(" body: %s" % raw[:200])
  73. if "expectStatus" in spec and status != spec["expectStatus"]:
  74. failures.append("status: expected %s, got %s" % (spec["expectStatus"], status))
  75. for name, want in spec.get("expectHeaders", {}).items():
  76. got = headers.get(name)
  77. if got is None:
  78. failures.append("header %s: missing" % name)
  79. elif want not in got:
  80. failures.append("header %s: expected to contain %r, got %r" % (name, want, got))
  81. for fragment in spec.get("expectBodyContains", []):
  82. if fragment not in raw:
  83. failures.append("body: expected to contain %r" % fragment)
  84. for fragment in spec.get("expectBodyExcludes", []):
  85. if fragment in raw:
  86. failures.append("body: expected NOT to contain %r" % fragment)
  87. return failures
  88. def run_resume_case(case, token, execution_id):
  89. """Wait for the execution to pause, answer it, then let it finish."""
  90. spec = case["resume"]
  91. want_status = spec.get("waitForStatus", "waiting")
  92. execution = {}
  93. for _ in range(60):
  94. time.sleep(0.5)
  95. execution = call("GET", "/executions/%s" % execution_id, token)
  96. if execution.get("status") == want_status:
  97. break
  98. if execution.get("status") in ("completed", "failed", "cancelled"):
  99. raise SystemExit(
  100. "execution %s reached %s without pausing" % (execution_id, execution.get("status")))
  101. else:
  102. raise SystemExit("execution %s never reached %s" % (execution_id, want_status))
  103. by_id = {n["nodeId"]: n for n in execution.get("nodeExecutions", [])}
  104. source = by_id.get(spec["tokenFrom"])
  105. if not source:
  106. raise SystemExit("resume: node %s did not run" % spec["tokenFrom"])
  107. resume_token = (source.get("output") or {}).get("token")
  108. if not resume_token:
  109. raise SystemExit("resume: node %s produced no token" % spec["tokenFrom"])
  110. body = dict(spec.get("payload", {}))
  111. body["token"] = resume_token
  112. call("POST", "/executions/%s/resume" % execution_id, token, body)
  113. print("resumed %s with token %s" % (execution_id, resume_token))
  114. def precondition_unmet(case):
  115. """Why this case cannot run here, or None if it can.
  116. A couple of cases need a model loaded on the SD.cpp server, which unloads
  117. itself when idle. Without this they fail for a reason that has nothing to do
  118. with the code, and a failure everyone learns to ignore is worse than no
  119. test. Skipping is only honest because it is reported as a skip - never as a
  120. pass.
  121. """
  122. needs = case.get("requires")
  123. if not needs:
  124. return None
  125. probe = needs.get("http")
  126. if probe:
  127. try:
  128. with urllib.request.urlopen(probe, timeout=10) as response:
  129. body = json.loads(response.read().decode())
  130. except Exception as exc:
  131. return f"{probe} could not be read ({exc})"
  132. for path, expected in (needs.get("expect") or {}).items():
  133. actual = body
  134. for part in path.split("."):
  135. actual = (actual or {}).get(part) if isinstance(actual, dict) else None
  136. if actual != expected:
  137. return f"{probe} reports {path}={actual!r}, this case needs {expected!r}"
  138. return None
  139. def main():
  140. if len(sys.argv) != 2:
  141. raise SystemExit("usage: verify-node.py <case.json>")
  142. case = json.load(open(sys.argv[1]))
  143. unmet = precondition_unmet(case)
  144. if unmet:
  145. print(f"SKIP {case['name']}: {unmet}")
  146. return 2
  147. token = login()
  148. call("POST", "/nodes/migrate", token, {"nodesPath": "./nodes"})
  149. # Workflows the case needs to exist before it runs - a sub-workflow it
  150. # calls. Created here rather than named by id, so a case is not silently
  151. # tied to a row someone made by hand once and can delete.
  152. helper_ids = []
  153. for helper in case.get("helpers", []):
  154. made = call("POST", "/workflows", token, {
  155. "name": helper["name"],
  156. "nodes": helper["nodes"],
  157. "connections": helper.get("connections", []),
  158. })
  159. helper_id = made.get("id") or made.get("_id")
  160. if not helper_id:
  161. raise SystemExit(f"no id for helper {helper['key']}")
  162. helper_ids.append(helper_id)
  163. if helper.get("active", True):
  164. call("POST", f"/workflows/{helper_id}/activate", token, {})
  165. # Substituted everywhere, so the case refers to it by name.
  166. case = json.loads(json.dumps(case).replace("{{helper:" + helper["key"] + "}}", helper_id))
  167. created = call("POST", "/workflows", token, {
  168. "name": case["name"],
  169. "nodes": case["nodes"],
  170. "connections": case["connections"],
  171. "settings": case.get("settings", {}),
  172. })
  173. workflow_id = created.get("id") or created.get("_id")
  174. if not workflow_id:
  175. raise SystemExit(f"no workflow id in create response: {json.dumps(created)[:300]}")
  176. try:
  177. if "http" in case:
  178. call("POST", f"/workflows/{workflow_id}/activate", token, {})
  179. failures = run_http_case(case, token, workflow_id)
  180. print("\nFAIL" if failures else "\nPASS")
  181. for f in failures:
  182. print(" " + f)
  183. return 1 if failures else 0
  184. started = call("POST", f"/workflows/{workflow_id}/execute", token, {})
  185. execution_id = started["executionId"]
  186. if "resume" in case:
  187. run_resume_case(case, token, execution_id)
  188. execution = None
  189. for _ in range(60):
  190. time.sleep(0.5)
  191. execution = call("GET", f"/executions/{execution_id}", token)
  192. if execution.get("status") in ("completed", "failed", "cancelled"):
  193. break
  194. else:
  195. raise SystemExit(f"execution {execution_id} did not finish in 30s")
  196. by_id = {n["nodeId"]: n for n in execution.get("nodeExecutions", [])}
  197. failures = []
  198. for node_id, want in case.get("expect", {}).items():
  199. got = by_id.get(node_id)
  200. if got is None:
  201. failures.append(f"{node_id}: did not run")
  202. continue
  203. if "status" in want and got.get("status") != want["status"]:
  204. failures.append(
  205. f"{node_id}: status {got.get('status')!r}, expected {want['status']!r}"
  206. + (f" (error: {got.get('error')})" if got.get("error") else "")
  207. )
  208. if "errorContains" in want:
  209. actual_error = got.get("error") or ""
  210. if want["errorContains"] not in actual_error:
  211. failures.append(
  212. f"{node_id}: error does not contain {want['errorContains']!r}, "
  213. f"got {actual_error!r}"
  214. )
  215. if "output" in want:
  216. failures += subset_matches(want["output"], got.get("output"), node_id)
  217. # What a watcher is told when the run fails. "Loop iteration failed" on
  218. # its own passed every assertion that looked at nodes, because the nodes
  219. # were all correct - the message was the broken part.
  220. wanted_error = case.get("expectExecutionError")
  221. if wanted_error and wanted_error not in (execution.get("error") or ""):
  222. failures.append(
  223. f"execution error does not contain {wanted_error!r}, "
  224. f"got {execution.get('error')!r}"
  225. )
  226. for node_id in case.get("expectMissing", []):
  227. got = by_id.get(node_id)
  228. if got is not None and got.get("status") != "skipped":
  229. failures.append(
  230. f"{node_id}: ran, but should not have (status: {got.get('status')!r})"
  231. )
  232. print(f"case: {case['name']} execution: {execution_id} status: {execution.get('status')}")
  233. for node_id, node in sorted(by_id.items()):
  234. print(f" {node_id:24} {node.get('status'):10} {json.dumps(node.get('output'))[:120]}")
  235. if failures:
  236. print("\nFAIL")
  237. for f in failures:
  238. print(" " + f)
  239. return 1
  240. print("\nPASS")
  241. return 0
  242. finally:
  243. call("DELETE", f"/workflows/{workflow_id}", token)
  244. for helper_id in helper_ids:
  245. call("DELETE", f"/workflows/{helper_id}", token)
  246. if __name__ == "__main__":
  247. sys.exit(main())