t7log.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """The t7log bundle (goal 41 T7, ux-notes structural bet 3): the task
  2. page <-> event log unification. The two surfaces are ONE investigation
  3. flow: the task page mounts the log as its "Event log" tab (the same
  4. task-log-feed partial the standalone /tasks/{id}/log route renders),
  5. the entrypoint publishes both slots, and no link to the log opens a
  6. new tab any more.
  7. Fixture-driven like the pages bundle: a task + session are inserted as
  8. rows, events land through the push wire (/api/saddle/chunks), and the
  9. assertions read page HTML, entrypoint slot state and the SSE stream.
  10. """
  11. from harness import * # noqa: F401,F403 — the shared wire helpers
  12. # =============================================================================
  13. # Bundle: t7log — task page <-> event log unification.
  14. # =============================================================================
  15. def run(ctx):
  16. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  17. print("== B1: the task page embeds the log as its second tab ==")
  18. # A working task with one session, then a real event through the
  19. # push wire (the same path the daemon writes).
  20. conn = db()
  21. conn.execute(
  22. "insert into tasks (project_id, title, status, priority, created_at)"
  23. " values (1, 'T7 unified log task', 'working', 5,"
  24. " strftime('%s','now'))")
  25. task_id = conn.execute("select max(id) from tasks").fetchone()[0]
  26. conn.execute(
  27. "insert into sessions (task_id, saddle_id, started_at)"
  28. " values (?, ?, strftime('%s','now'))", (task_id, fake_id))
  29. conn.commit()
  30. session_id = conn.execute("select max(id) from sessions").fetchone()[0]
  31. conn.close()
  32. status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
  33. "session_id": session_id,
  34. "events": [{"seq": 101, "kind": "comment", "actor": "user:admin",
  35. "payload": {"body": "T7 probe comment",
  36. "author": "user:admin"}}]})
  37. check(status == 200 and int(body.get("stored") or 0) == 1,
  38. "the fixture event stores through the push wire")
  39. # The tab markup: Spry's CSS tabs (radios first, then .tab-list
  40. # labels, then .tab-panels), Overview checked by default.
  41. task_page = http_get_page(auth, "/tasks/%d" % task_id)
  42. check('class="tabs task-tabs"' in task_page,
  43. "the task page carries the tab strip")
  44. check(task_page.index('id="task-view-overview"')
  45. < task_page.index('class="tab-list"')
  46. < task_page.index('class="tab-panels"'),
  47. "the radios lead the tab markup (Spry's nth-of-type contract)")
  48. check('id="task-view-overview" checked' in task_page,
  49. "Overview is the default (checked) tab")
  50. check('<label for="task-view-overview">Overview</label>' in task_page
  51. and '<label for="task-view-log">Event log</label>' in task_page,
  52. "the tab list names Overview and Event log")
  53. check('class="task-overview-panel"' in task_page
  54. and 'class="task-log-panel"' in task_page,
  55. "the panels carry the overview and the log")
  56. print("== B2: one shared shell, both surfaces ==")
  57. # The SAME partial ships both pages: the interactive hooks the
  58. # client engine binds appear exactly once on each, and the
  59. # compiled shell markup (the partial's) rides both.
  60. task_page = http_get_page(auth, "/tasks/%d" % task_id)
  61. log_page = http_get_page(auth, "/tasks/%d/log" % task_id)
  62. for surface, name in ((task_page, "the task page"),
  63. (log_page, "the log page")):
  64. check('class="log-shell"' in surface and "data-log-filter" in surface,
  65. "%s renders the shared log shell" % name)
  66. # Hook multiplicity: each interactive hook appears exactly as
  67. # many times as the ONE shared shell carries it (5 filter
  68. # buttons, 1 feed/more/search/jump) — proving neither surface
  69. # ships a second, hand-copied shell.
  70. for hook, times in (("data-log-feed", 1), ("data-log-more", 1),
  71. ("data-log-filter", 5), ("data-log-search", 1),
  72. ("data-log-jump", 1)):
  73. check(task_page.count(hook) == times
  74. and log_page.count(hook) == times,
  75. "the %s hook appears exactly %d time(s) on both surfaces"
  76. % (hook, times))
  77. print("== B3: no log link opens a new tab any more ==")
  78. # The Progress cluster's control is a <label> toggling the radio
  79. # in place; no link element on the page navigates to the log
  80. # route any more (comments may mention it, markup may not).
  81. check('<label class="tab-link" for="task-view-log">Event log</label>'
  82. in task_page,
  83. "the Progress cluster's Event log control toggles the tab in place")
  84. markup = task_page.split("<script>")[0] + "".join(
  85. part.split("</script>", 1)[-1] for part in task_page.split("<script>")[1:])
  86. check(re.search(r"<a[^>]+(\n[^>]*)*?/log", markup) is None,
  87. "the task page markup carries no anchor to the log route")
  88. check(re.search(r"/log['\"]", markup) is None,
  89. "no log URL appears in any attribute (the tab is the only door)")
  90. print("== B4: both slots publish from the task entrypoint ==")
  91. # The embedded shell binds the page's task_log slot; it must name
  92. # the same task the standalone route's slot does.
  93. embedded = slot_public(auth, "/tasks/%d" % task_id, "task_log")
  94. standalone = slot_public(auth, "/tasks/%d/log" % task_id, "task_log")
  95. check(embedded.get("exists") is True
  96. and int(embedded["task_id"]) == task_id,
  97. "the task page entrypoint publishes the embedded log slot")
  98. for field in ("exists", "task_id", "task_title", "task_status", "task_url"):
  99. check(standalone.get(field) == embedded.get(field),
  100. "the %s field is identical on both surfaces" % field)
  101. check(embedded.get("task_title") == "T7 unified log task",
  102. "the embedded slot names the task")
  103. check(embedded.get("task_status") == "working",
  104. "the embedded slot carries the live status the badge reads")
  105. print("== B5: the standalone page keeps its context strip ==")
  106. # The forensic route renders the T5 context header (back chevron +
  107. # task title + live badge) and the not-found guard still works.
  108. check('class="log-header"' in log_page
  109. and 'data-log-title' in log_page
  110. and 'data-log-live' in log_page,
  111. "the standalone page keeps its one-row context header")
  112. check('stm-attribute.href="task_log.task_url"' in log_page,
  113. "the back chevron binds the task page URL from the slot")
  114. missing = slot_public(auth, "/tasks/99999999/log", "task_log")
  115. check(missing.get("exists") is False,
  116. "a missing task's log slot says exists=false (both surfaces render their own note)")
  117. print("== B6: the feed is reachable through both paths ==")
  118. # The JSON page + SSE stream are task-scoped, not route-scoped:
  119. # the embedded tab reads the same endpoints the standalone page
  120. # does (one engine, one wire).
  121. status, page_json = http("GET", "/tasks/%d/events" % task_id, timeout=15)
  122. rows = json.loads(page_json)
  123. check(status == 200
  124. and any(e.get("payload", {}).get("body") == "T7 probe comment"
  125. for e in rows.get("events", [])),
  126. "the events endpoint serves the fixture event")
  127. request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % task_id)
  128. with urllib.request.urlopen(request, timeout=15) as stream:
  129. probe = None
  130. deadline = time.monotonic() + 8
  131. while time.monotonic() < deadline and probe is None:
  132. line = stream.readline().decode().strip()
  133. if not line.startswith("data: ") or line == "data: ping":
  134. continue
  135. event = json.loads(line[6:])
  136. if (event.get("kind") == "comment"
  137. and "T7 probe comment" in
  138. (event.get("payload", {}).get("body") or "")):
  139. probe = event
  140. check(probe is not None,
  141. "the SSE stream carries the fixture event to either surface")
  142. print("== B7: the pulse refresh rebuilds BOTH slots ==")
  143. # The refresh action (the page's sealed pulse) rebuilds the task
  144. # slot AND the embedded task_log slot (the badge's status words
  145. # stay truthful after a transition without its own poll). Drive
  146. # it through the real action wire and read both back.
  147. task_slot = task_state(auth, task_id)
  148. refresh = task_slot.get("refresh")
  149. check(refresh is not None, "the task slot carries its sealed refresh action")
  150. if refresh:
  151. act(auth, refresh, {})
  152. # Flip the round's state through the push wire, refresh again,
  153. # and the embedded slot must name the NEW status.
  154. status, _ = saddle_api("POST", "/api/saddle/results", fake_token,
  155. {"session_id": session_id, "state": "done",
  156. "summary": "T7 round settled"})
  157. check(status == 200, "the round settles through the results push")
  158. act(auth, task_state(auth, task_id)["refresh"], {})
  159. settled = task_state(auth, task_id)
  160. refreshed_log = slot_public(auth, "/tasks/%d" % task_id, "task_log")
  161. check(refreshed_log.get("task_status") == settled.get("status"),
  162. "the embedded log slot's status tracks the task slot after a pulse refresh")
  163. print("== B8: the goal surface's run links navigate same-tab ==")
  164. # goal_detail's run rows and header link dropped target=_blank
  165. # (the goal page is T6's; only the log-link shape is T7's concern).
  166. goal_page_shape = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
  167. os.pardir, os.pardir,
  168. "src", "pages", "goal_detail.html"),
  169. encoding="utf-8").read()
  170. check('run.terminal_url" target="_blank"' not in goal_page_shape,
  171. "the goal page's run links no longer open a new tab")