verify-node.py 13 KB

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