web_stack_test.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. #!/usr/bin/env python3
  2. """Self-contained web-stack feature tests: app + fake saddle daemon.
  3. Unlike integration_test.py (which needs a real saddle daemon and a mock
  4. LLM), this suite spawns everything it needs:
  5. - the clanker-saddle app binary (path from argv[1], or builddir default)
  6. in a scratch directory with its own web-config.json (statum keys copied
  7. from the repo, plus `saddle_callback_base_url`) and SQLite database
  8. - an in-process fake saddle daemon speaking the wire contract: POST
  9. /start (captures the payload), GET /status (scripted state machine),
  10. GET /output (empty), GET /log?since= (raw PTY chunks, one new chunk
  11. per request so since-paging is exercised)
  12. It then drives the app the way statum.js does (entrypoint loads + action
  13. POSTs) and asserts the Workstream B features against the wire contract:
  14. 3. plan-review blocks hide their duplicate body preview
  15. 5. rules CRUD/attach + the effective `rules` array in /start
  16. 6. project notes, POST /api/saddle/note, `project_notes` + `callback`
  17. in /start
  18. plus the raw-log side: session_raw_log polling + terminal raw view
  19. Prints PASS/FAIL lines Statum-style; exits non-zero on any failure.
  20. """
  21. import json
  22. import os
  23. import shutil
  24. import socket
  25. import sqlite3
  26. import subprocess
  27. import sys
  28. import threading
  29. import time
  30. import urllib.error
  31. import urllib.parse
  32. import urllib.request
  33. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  34. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  35. APP = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(REPO, "builddir", "clanker-saddle")
  36. WORK = os.environ.get("WEBSTACK_WORK", "/tmp/kilo/webstack-e2e")
  37. DB_PATH = os.path.join(WORK, "web.sqlite")
  38. passes = 0
  39. failures = 0
  40. def check(condition, label):
  41. global passes, failures
  42. if condition:
  43. passes += 1
  44. print("PASS %s" % label)
  45. else:
  46. failures += 1
  47. print("FAIL %s" % label)
  48. def free_port():
  49. with socket.socket() as sock:
  50. sock.bind(("127.0.0.1", 0))
  51. return sock.getsockname()[1]
  52. APP_PORT = free_port()
  53. DAEMON_PORT = free_port()
  54. WEB = "http://127.0.0.1:%d" % APP_PORT
  55. def http(method, url, data=None, headers=None, timeout=30):
  56. body = None
  57. if data is not None:
  58. body = "&".join("%s=%s" % (k, urllib.parse.quote(str(v))) for k, v in data.items()).encode()
  59. request = urllib.request.Request(WEB + url if url.startswith("/") else url,
  60. data=body, method=method)
  61. if data is not None:
  62. request.add_header("Content-Type", "application/x-www-form-urlencoded")
  63. for key, value in (headers or {}).items():
  64. request.add_header(key, value)
  65. try:
  66. with urllib.request.urlopen(request, timeout=timeout) as response:
  67. return response.status, response.read().decode()
  68. except urllib.error.HTTPError as error:
  69. return error.code, error.read().decode()
  70. def directives(uri, auth=None):
  71. headers = {}
  72. if auth:
  73. headers["X-Statum-Slot"] = "key=%s; as_at=%s" % (auth["key"], auth["as_at"])
  74. status, body = http("GET", "/_statum/entrypoint?uri=" + urllib.parse.quote(uri.split("?")[0]),
  75. headers=headers)
  76. if status != 200:
  77. raise SystemExit("entrypoint %s -> HTTP %d: %s" % (uri, status, body[:300]))
  78. return json.loads(body)
  79. def slot_public(auth, uri, type_name):
  80. for directive in directives(uri, auth):
  81. if directive["type"] == "set":
  82. content = json.loads(directive["snapshot_frame"]["content"])
  83. if content["slot"]["type"] == type_name:
  84. return content["public"]
  85. raise SystemExit("no %s slot in entrypoint state for %s" % (type_name, uri))
  86. def act(auth, action, fields):
  87. headers = {"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])}
  88. if action.get("private"):
  89. headers["X-Statum-Private"] = action["private"]
  90. status, body = http(action.get("method", "POST"), action["uri"], fields, headers)
  91. if status != 200:
  92. raise SystemExit("action %s -> HTTP %d: %s" % (action["uri"], status, body[:300]))
  93. return json.loads(body)
  94. def login(username, password):
  95. frames = directives("/login")
  96. content = json.loads(frames[0]["snapshot_frame"]["content"])
  97. action = content["public"]["login"]
  98. status, body = http(action.get("method", "POST"), action["uri"],
  99. {"username": username, "password": password})
  100. if status != 200:
  101. raise SystemExit("login -> HTTP %d: %s" % (status, body[:300]))
  102. for directive in json.loads(body):
  103. if directive["type"] == "set":
  104. frame = json.loads(directive["snapshot_frame"]["content"])
  105. if frame["slot"]["type"] == "auth":
  106. return {"key": frame["slot"]["key"], "as_at": frame["as_at"]}
  107. raise SystemExit("login response carried no auth slot: " + body[:300])
  108. def wait_for(predicate, timeout=60, what="condition", interval=0.5):
  109. deadline = time.monotonic() + timeout
  110. last = None
  111. while time.monotonic() < deadline:
  112. last = predicate()
  113. if last is not False and last is not None:
  114. return last
  115. time.sleep(interval)
  116. print(" (timeout waiting for %s; last: %s)" % (what, last))
  117. return None
  118. def task_state(auth, task_id):
  119. return slot_public(auth, "/tasks/%d" % task_id, "task")
  120. def wait_task_done(auth, task_id, timeout=90):
  121. return wait_for(lambda: task_state(auth, task_id).get("status") == "done" or False,
  122. timeout=timeout, what="task %d -> done" % task_id)
  123. def saddle_api(method, path, token, payload=None):
  124. """Raw call against the saddle ingest API; returns (status, json)."""
  125. request = urllib.request.Request(WEB + path,
  126. data=json.dumps(payload or {}).encode(), method=method)
  127. request.add_header("Content-Type", "application/json")
  128. if token is not None:
  129. request.add_header("Authorization", "Bearer " + token)
  130. try:
  131. with urllib.request.urlopen(request, timeout=15) as response:
  132. return response.status, json.loads(response.read().decode() or "{}")
  133. except urllib.error.HTTPError as error:
  134. body = error.read().decode()
  135. try:
  136. return error.code, json.loads(body)
  137. except ValueError:
  138. return error.code, {"raw": body}
  139. def db():
  140. return sqlite3.connect(DB_PATH)
  141. class FakeSaddle:
  142. """In-process saddle daemon implementing the wire contract."""
  143. PROMPT = "bbarrow@fedora:/workspace/task-1$ "
  144. def __init__(self):
  145. self.lock = threading.Lock()
  146. self.start_payloads = [] # captured POST /start bodies
  147. self.chunks = [] # [{seq, text}] — the raw log ring
  148. self.next_seq = 1
  149. self.state = "idle"
  150. self.summary = ""
  151. self.status_polls_after_start = 0
  152. def append_chunk(self, text):
  153. with self.lock:
  154. self.chunks.append({"seq": self.next_seq, "text": text})
  155. self.next_seq += 1
  156. def log_since(self, since):
  157. with self.lock:
  158. chunks = [c for c in self.chunks if c["seq"] > since]
  159. return chunks, self.next_seq
  160. class Handler(BaseHTTPRequestHandler):
  161. daemon = None # set on the class before serving
  162. protocol_version = "HTTP/1.1"
  163. def log_message(self, fmt, *args):
  164. pass
  165. def _send(self, status, obj):
  166. body = json.dumps(obj).encode()
  167. self.send_response(status)
  168. self.send_header("Content-Type", "application/json")
  169. self.send_header("Content-Length", str(len(body)))
  170. self.end_headers()
  171. self.wfile.write(body)
  172. def do_POST(self):
  173. length = int(self.headers.get("Content-Length") or 0)
  174. raw = self.rfile.read(length).decode() if length else "{}"
  175. if self.path == "/start":
  176. try:
  177. payload = json.loads(raw)
  178. except ValueError:
  179. payload = {"raw": raw}
  180. self.daemon.start_payloads.append(payload)
  181. self.daemon.append_chunk(self.daemon.PROMPT)
  182. self.daemon.append_chunk("echo hello\r\n")
  183. self.daemon.append_chunk("hello\r\n")
  184. self.daemon.state = "working"
  185. self.daemon.status_polls_after_start = 0
  186. self._send(200, {})
  187. elif self.path == "/answer":
  188. self._send(200, {})
  189. else:
  190. self._send(404, {"error": "no such route"})
  191. def do_GET(self):
  192. if self.path == "/status":
  193. if self.daemon.state == "working":
  194. self.daemon.status_polls_after_start += 1
  195. if self.daemon.status_polls_after_start >= 2:
  196. self.daemon.state = "done"
  197. self.daemon.summary = "fake daemon finished"
  198. self._send(200, {"state": self.daemon.state,
  199. "summary": self.daemon.summary})
  200. elif self.path.startswith("/output"):
  201. self._send(200, [])
  202. elif self.path.startswith("/log"):
  203. query = urllib.parse.urlparse(self.path).query
  204. since = int(urllib.parse.parse_qs(query).get("since", ["0"])[0])
  205. chunks, nxt = self.daemon.log_since(since)
  206. # Simulate the live PTY: each poll produces one more chunk.
  207. self.daemon.append_chunk("stream-line-%d\r\n" % self.daemon.next_seq)
  208. self._send(200, {"chunks": chunks, "next": nxt})
  209. else:
  210. self._send(404, {"error": "no such route"})
  211. self.close_connection = True
  212. def serve(self):
  213. handler = type("BoundHandler", (FakeSaddle.Handler,), {"daemon": self})
  214. server = ThreadingHTTPServer(("127.0.0.1", DAEMON_PORT), handler)
  215. threading.Thread(target=server.serve_forever, daemon=True).start()
  216. return server
  217. def start_app():
  218. config = json.load(open(os.path.join(REPO, "web-config.json")))
  219. config["saddle_callback_base_url"] = WEB
  220. with open(os.path.join(WORK, "web-config.json"), "w") as handle:
  221. json.dump(config, handle)
  222. env = dict(os.environ)
  223. env.update({
  224. "SPRY_DB_PATH": DB_PATH,
  225. "SPRY_SEED_ADMIN_USERNAME": "admin",
  226. "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
  227. "SADDLE_LLM_BASE_URL": "http://127.0.0.1:%d" % DAEMON_PORT,
  228. "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
  229. })
  230. log = open(os.path.join(WORK, "app.log"), "w")
  231. process = subprocess.Popen([APP, str(APP_PORT)], cwd=WORK, env=env,
  232. stdout=log, stderr=subprocess.STDOUT)
  233. if wait_for(lambda: app_ready() or False, timeout=30, what="app ready") is None:
  234. raise SystemExit("app did not come up; see %s/app.log" % WORK)
  235. return process
  236. def app_ready():
  237. try:
  238. status, _ = http("GET", "/login", timeout=3)
  239. return status == 200
  240. except (urllib.error.URLError, OSError):
  241. return False
  242. def main():
  243. if not os.path.exists(APP):
  244. raise SystemExit("app binary not found at %s (build first)" % APP)
  245. if os.path.exists(WORK):
  246. shutil.rmtree(WORK)
  247. os.makedirs(os.path.join(WORK, "workspace"))
  248. fake = FakeSaddle()
  249. fake.serve()
  250. app = start_app()
  251. try:
  252. auth = login("admin", "adminpass123")
  253. check(True, "login yields an auth slot")
  254. print("== feature 3: plan review hides duplicate body ==")
  255. state = slot_public(auth, "/projects", "projects")
  256. act(auth, state["create"], {
  257. "name": "demo",
  258. "git_url": "https://example.com/demo.git",
  259. "default_branch": "main",
  260. })
  261. conn = db()
  262. conn.execute("insert into tasks (project_id, title, status, priority,"
  263. " is_design_task, plan, created_at)"
  264. " values (1, 'Plan review task', 'working', 5, 1, '# The plan\n\n1. Do it',"
  265. " strftime('%s','now'))")
  266. conn.commit()
  267. plan_task = conn.execute("select max(id) from tasks").fetchone()[0]
  268. saddles_state = slot_public(auth, "/saddles", "saddles")
  269. act(auth, saddles_state["register"], {"name": "fake-saddle",
  270. "host": "127.0.0.1:%d" % DAEMON_PORT})
  271. registered = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
  272. if s["name"] == "fake-saddle"]
  273. check(len(registered) == 1 and registered[0]["token"].startswith("saddle-"),
  274. "fake saddle registered with an API token")
  275. fake_id = int(registered[0]["id"])
  276. fake_token = registered[0]["token"]
  277. conn.execute("insert into sessions (task_id, saddle_id, started_at, ended_at, outcome)"
  278. " values (?, ?, strftime('%s','now'), strftime('%s','now'), 'done')",
  279. (plan_task, fake_id))
  280. conn.commit()
  281. session_id = conn.execute("select max(id) from sessions").fetchone()[0]
  282. conn.close()
  283. status, body = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  284. {"type": "block", "session_id": session_id, "data": {
  285. "question": "Plan submitted for review",
  286. "type": "rfc",
  287. "body": "# The plan\n\n1. Do it"}})
  288. check(status == 200 and body.get("block_id"), "plan-review block raised")
  289. status, _ = saddle_api("POST", "/api/saddle/%d/update" % fake_id, fake_token,
  290. {"type": "block", "session_id": session_id, "data": {
  291. "question": "Which database?",
  292. "type": "question",
  293. "options": ["SQLite", "Postgres"],
  294. "body": "# Storage choice"}})
  295. check(status == 200, "question block raised")
  296. detail = task_state(auth, plan_task)
  297. blocks = {b["block_type"]: b for b in detail["blocks"]}
  298. check(blocks["rfc"].get("is_plan_review") is True,
  299. "rfc block flagged as the plan review")
  300. check(blocks["rfc"].get("show_body") is False,
  301. "pending plan review hides the duplicate body preview")
  302. check(blocks["question"].get("show_body") is True,
  303. "question block keeps its body preview")
  304. act(auth, blocks["rfc"]["answer_action"], {"answer": "Plan approved. Proceed."})
  305. detail = task_state(auth, plan_task)
  306. rfc = [b for b in detail["blocks"] if b["block_type"] == "rfc"][0]
  307. check(rfc.get("show_body") is True,
  308. "answered plan review shows the body again (review section gone)")
  309. print("== feature 5: rules ==")
  310. rules_state = slot_public(auth, "/rules", "rules")
  311. for name, body in [
  312. ("Use Invercargill", "Never use libgee; use Invercargill enumerables."),
  313. ("Write Vala docs", "Every public class carries a doc comment."),
  314. ("Prefer meson", "Builds go through meson; no makefiles."),
  315. ("Co-dev rule", "Keep the wire contract exactly."),
  316. ("Reference rule", "Must never ride along."),
  317. ]:
  318. act(auth, rules_state["create"], {"name": name, "body": body})
  319. listed = {r["name"]: r for r in slot_public(auth, "/rules", "rules")["rules"]}
  320. check(len(listed) == 5, "five rules created and listed on /rules")
  321. def rule_id(name):
  322. rows = [r for r in slot_public(auth, "/rules", "rules")["rules"] if r["name"] == name]
  323. return int(rows[0]["id"])
  324. state = slot_public(auth, "/projects", "projects")
  325. act(auth, state["create"], {"name": "co", "git_url": "https://example.com/co.git"})
  326. act(auth, state["create"], {"name": "ref", "git_url": "https://example.com/ref.git"})
  327. co_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  328. if p["name"] == "co"][0])
  329. ref_id = int([p["id"] for p in slot_public(auth, "/projects", "projects")["projects"]
  330. if p["name"] == "ref"][0])
  331. project = slot_public(auth, "/projects/1", "project")
  332. act(auth, project["add_link"], {"linked_project_id": str(co_id),
  333. "relationship": "co-developed"})
  334. act(auth, project["add_link"], {"linked_project_id": str(ref_id),
  335. "relationship": "reference"})
  336. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  337. act(auth, project["attach_rule"], {"rule_id": str(rule_id("Prefer meson"))})
  338. project = slot_public(auth, "/projects/1", "project")
  339. check(sorted(r["name"] for r in project["rules"]) == ["Prefer meson", "Use Invercargill"],
  340. "project page lists its attached rules")
  341. co_project = slot_public(auth, "/projects/%d" % co_id, "project")
  342. act(auth, co_project["attach_rule"], {"rule_id": str(rule_id("Co-dev rule"))})
  343. ref_project = slot_public(auth, "/projects/%d" % ref_id, "project")
  344. act(auth, ref_project["attach_rule"], {"rule_id": str(rule_id("Reference rule"))})
  345. tasks_state = slot_public(auth, "/tasks", "tasks")
  346. act(auth, tasks_state["create"], {
  347. "project_id": "1",
  348. "title": "Rules dispatch",
  349. "description": "Effective rule set rides the /start payload.",
  350. "priority": "2",
  351. })
  352. rule_task = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
  353. if t["title"] == "Rules dispatch"][0])
  354. detail = task_state(auth, rule_task)
  355. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Write Vala docs"))})
  356. act(auth, detail["attach_rule"], {"rule_id": str(rule_id("Use Invercargill"))})
  357. detail = task_state(auth, rule_task)
  358. check(sorted(r["name"] for r in detail["rules"]) == ["Use Invercargill", "Write Vala docs"],
  359. "task page lists its attached rules")
  360. check(wait_for(lambda: len(fake.start_payloads) > 0 or False,
  361. timeout=30, what="/start push") is not None,
  362. "dispatcher pushes the task to the fake saddle")
  363. payload = fake.start_payloads[-1]
  364. rule_names = [r["name"] for r in payload.get("rules", [])]
  365. check(rule_names == ["Write Vala docs", "Use Invercargill", "Prefer meson", "Co-dev rule"],
  366. "effective rules in /start: task, primary, co-developed order (%s)" % rule_names)
  367. check(all(r.get("body") for r in payload.get("rules", [])),
  368. "rules carry their bodies")
  369. check("Reference rule" not in rule_names,
  370. "reference project rules excluded")
  371. detail = task_state(auth, rule_task)
  372. act(auth, detail["rules"][0]["detach"], {})
  373. check([r["name"] for r in task_state(auth, rule_task)["rules"]] == ["Use Invercargill"],
  374. "detached task rule disappears")
  375. rules_rows = slot_public(auth, "/rules", "rules")["rules"]
  376. docs_row = [r for r in rules_rows if r["name"] == "Write Vala docs"][0]
  377. act(auth, docs_row["update"], {"name": "Write Vala docs", "body": "Doc comments or bust."})
  378. check(any(r["body"] == "Doc comments or bust."
  379. for r in slot_public(auth, "/rules", "rules")["rules"]),
  380. "rule edit saves the body")
  381. check(wait_task_done(auth, rule_task, timeout=60) is not None,
  382. "dispatched task completes against the fake daemon")
  383. print("== feature 6: project notes + callback ==")
  384. conn = db()
  385. callback_rows = conn.execute(
  386. "select token from api_tokens where saddle_id = 0").fetchall()
  387. conn.close()
  388. check(len(callback_rows) == 1 and callback_rows[0][0].startswith("saddle-callback-"),
  389. "dedicated saddle-callback token minted at startup")
  390. callback_token = callback_rows[0][0]
  391. status, _ = saddle_api("POST", "/api/saddle/note", None,
  392. {"project": "demo", "body": "x"})
  393. check(status == 401, "note endpoint rejects a missing token")
  394. status, _ = saddle_api("POST", "/api/saddle/note", "saddle-callback-not-real",
  395. {"project": "demo", "body": "x"})
  396. check(status == 401, "note endpoint rejects a wrong token")
  397. status, body = saddle_api("POST", "/api/saddle/note", callback_token,
  398. {"task_id": rule_task, "project": "demo",
  399. "body": "style.css tokens live in :root"})
  400. check(status == 200 and body.get("ok") is True, "note accepted with the callback token")
  401. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  402. {"project": "no-such-project", "body": "x"})
  403. check(status == 400, "unknown project rejected")
  404. status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
  405. {"project": "demo", "body": " "})
  406. check(status == 400, "empty note body rejected")
  407. project = slot_public(auth, "/projects/1", "project")
  408. check(any("style.css tokens" in n["body"] and int(n["source_task_id"]) == rule_task
  409. for n in project["notes"]),
  410. "project page lists the note with its source task")
  411. act(auth, [n for n in project["notes"] if "style.css tokens" in n["body"]][0]["delete"], {})
  412. check(all("style.css tokens" not in n["body"]
  413. for n in slot_public(auth, "/projects/1", "project")["notes"]),
  414. "note deleted from the project page")
  415. # /start payload: notes of primary + co-developed (not reference),
  416. # oldest first; callback carries the configured base URL + token.
  417. saddle_api("POST", "/api/saddle/note", callback_token,
  418. {"project": "demo", "body": "note-one"})
  419. saddle_api("POST", "/api/saddle/note", callback_token,
  420. {"project": "demo", "body": "note-two"})
  421. saddle_api("POST", "/api/saddle/note", callback_token,
  422. {"project": "co", "body": "co-note"})
  423. conn = db()
  424. conn.execute("insert into project_notes (project_id, body, created_at)"
  425. " values (?, 'ref-note-must-not-ship', strftime('%s','now'))", (ref_id,))
  426. conn.commit()
  427. conn.close()
  428. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  429. "project_id": "1",
  430. "title": "Notes dispatch",
  431. "description": "Project notes ride the /start payload.",
  432. "priority": "2",
  433. })
  434. check(wait_for(lambda: len(fake.start_payloads) >= 2 or False,
  435. timeout=30, what="second /start push") is not None,
  436. "dispatcher pushes the notes task")
  437. payload = fake.start_payloads[-1]
  438. notes = payload.get("project_notes", [])
  439. check([n["body"] for n in notes] == ["note-one", "note-two", "co-note"],
  440. "project_notes: primary + co-developed, oldest first (%s)" % [n["body"] for n in notes])
  441. check(all(n.get("project") for n in notes)
  442. and notes[2]["project"] == "co",
  443. "project_notes carry the project name")
  444. check("ref-note-must-not-ship" not in [n["body"] for n in notes],
  445. "reference project notes excluded")
  446. callback = payload.get("callback", {})
  447. check(callback.get("base_url") == WEB and callback.get("token") == callback_token,
  448. "callback carries the configured base URL and the minted token")
  449. # entry cap: 30 of 33 candidates
  450. conn = db()
  451. for i in range(33):
  452. conn.execute("insert into project_notes (project_id, body, created_at)"
  453. " values (?, ?, strftime('%s','now'))", (1, "bulk-%02d" % i))
  454. conn.commit()
  455. conn.close()
  456. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  457. "project_id": "1",
  458. "title": "Entry cap dispatch",
  459. "priority": "2",
  460. })
  461. check(wait_for(lambda: len(fake.start_payloads) >= 3 or False,
  462. timeout=30, what="third /start push") is not None,
  463. "dispatcher pushes the entry-cap task")
  464. notes = fake.start_payloads[-1].get("project_notes", [])
  465. check(len(notes) == 30 and notes[0]["body"] == "note-one" and notes[1]["body"] == "note-two",
  466. "project_notes capped at 30 entries, oldest kept (%d)" % len(notes))
  467. # char cap: the giant note is truncated so the total stays <= 20000
  468. conn = db()
  469. conn.execute("delete from project_notes where body like 'bulk-%' or body = 'co-note'")
  470. conn.execute("insert into project_notes (project_id, body, created_at)"
  471. " values (?, ?, strftime('%s','now'))", (1, "giant!" + "x" * 25000,))
  472. conn.commit()
  473. conn.close()
  474. act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
  475. "project_id": "1",
  476. "title": "Char cap dispatch",
  477. "priority": "2",
  478. })
  479. check(wait_for(lambda: len(fake.start_payloads) >= 4 or False,
  480. timeout=30, what="fourth /start push") is not None,
  481. "dispatcher pushes the char-cap task")
  482. notes = fake.start_payloads[-1].get("project_notes", [])
  483. total = sum(len(n["body"]) for n in notes)
  484. check(len(notes) == 3 and total == 20000 and notes[2]["body"].startswith("giant!"),
  485. "project_notes char cap: head kept, giant truncated (entries=%d, chars=%d)"
  486. % (len(notes), total))
  487. finally:
  488. app.terminate()
  489. try:
  490. app.wait(timeout=5)
  491. except subprocess.TimeoutExpired:
  492. app.kill()
  493. print("%d passed, %d failed" % (passes, failures))
  494. return 1 if failures else 0
  495. if __name__ == "__main__":
  496. sys.exit(main())