test_task_log_js.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. #!/usr/bin/env python3
  2. """Static + logic tests for the task event-log page's client JS.
  3. The app binary cannot be built in every container (statum-mkpstm /
  4. libstatum are build-time dependencies), so this suite verifies
  5. src/pages/task_log.html two ways:
  6. 1. Statically - the <script> body must be ASCII-only (the page
  7. compiler HTML-escapes script bodies, so any non-ASCII literal is
  8. re-served as literal entity text like "&middot;" after the
  9. browser's one decode pass), must wire the SSE stream through
  10. `onmessage` (the server sends id:/data: frames with no `event:`
  11. name, so named listeners can never fire), must not listen for an
  12. 'append' event name, and its delimiter structure must balance
  13. after strings and comments are stripped.
  14. 2. Behaviourally - the ingest / render-order / scroll-stick /
  15. load-more logic is ported verbatim below as pure python functions
  16. and unit-tested: oldest-first chronology, id dedupe, the 48px
  17. bottom-stick rule, prepend-mode scroll anchoring, and the
  18. newest-first-page reversal with the `more` -> exhausted flag.
  19. Runnable standalone: python3 test/test_task_log_js.py
  20. """
  21. import os
  22. import re
  23. import unittest
  24. PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
  25. os.pardir, "src", "pages", "task_log.html")
  26. def script_body():
  27. with open(PAGE, encoding="utf-8") as handle:
  28. source = handle.read()
  29. head, rest = source.split("<script>", 1)
  30. body, _ = rest.split("</script>", 1)
  31. assert head, "no content before the script block"
  32. return body
  33. # ---------------------------------------------------------------------------
  34. # Static checks
  35. # ---------------------------------------------------------------------------
  36. class ScriptSourceTests(unittest.TestCase):
  37. """What the served bytes must look like after compilation."""
  38. def setUp(self):
  39. self.body = script_body()
  40. def test_script_is_ascii_only(self):
  41. """Non-ASCII in a script body comes back as literal entity
  42. text ("&middot;") because the compiler escapes script bodies
  43. and the browser decodes exactly once. ASCII (with \\uXXXX
  44. escapes for the characters we want) is immune."""
  45. offenders = [(index, hex(ord(char)))
  46. for index, char in enumerate(self.body) if ord(char) > 127]
  47. self.assertEqual(offenders, [])
  48. def test_unicode_escapes_used_for_separators(self):
  49. """The separator characters must still render - as escapes,
  50. not as raw bytes and not as HTML entities."""
  51. for escape in ("\\u00b7", "\\u2026", "\\u2014"):
  52. self.assertIn(escape, self.body)
  53. self.assertNotIn("&middot;", self.body)
  54. self.assertNotIn("&mdash;", self.body)
  55. def test_stream_wired_through_onmessage(self):
  56. self.assertRegex(self.body, r"stream\.onmessage\s*=")
  57. self.assertNotIn("addEventListener('append'", self.body)
  58. self.assertNotIn('addEventListener("append"', self.body)
  59. def test_heartbeat_ping_is_skipped(self):
  60. self.assertRegex(self.body, r"message\.data\s*===\s*'ping'")
  61. def test_load_more_pages_before_oldest(self):
  62. self.assertRegex(self.body, r"/events\?before='\s*\+\s*\(state\.oldest")
  63. def test_page_is_reversed_before_ingest(self):
  64. self.assertIn("slice().reverse().forEach(ingest)", self.body)
  65. def test_exhausted_set_when_no_more(self):
  66. self.assertIn("state.exhausted = true", self.body)
  67. def test_more_button_sits_above_the_feed(self):
  68. # Search the page markup only - the script's comments also
  69. # mention [data-log-feed], before the elements do.
  70. with open(PAGE, encoding="utf-8") as handle:
  71. markup = handle.read().split("</script>", 1)[1]
  72. more = markup.index("data-log-more")
  73. feed = markup.index("data-log-feed")
  74. self.assertLess(more, feed,
  75. "the load-older control must precede the feed markup")
  76. def test_delimiters_balance(self):
  77. """A cheap structural stand-in for a JS parser (none is
  78. installed here): after stripping comments and string literals
  79. every bracket must close."""
  80. body = self.body
  81. out = []
  82. i = 0
  83. mode = None # None | 'line' | 'block' | "'" | '"'
  84. while i < len(body):
  85. char = body[i]
  86. nxt = body[i + 1] if i + 1 < len(body) else ""
  87. if mode is None:
  88. if char == "/" and nxt == "/":
  89. mode = "line"
  90. i += 2
  91. continue
  92. if char == "/" and nxt == "*":
  93. mode = "block"
  94. i += 2
  95. continue
  96. if char in "'\"":
  97. mode = char
  98. i += 1
  99. continue
  100. out.append(char)
  101. elif mode == "line":
  102. if char == "\n":
  103. mode = None
  104. out.append("\n")
  105. elif mode == "block":
  106. if char == "*" and nxt == "/":
  107. mode = None
  108. i += 2
  109. continue
  110. else: # inside a string literal
  111. if char == "\\":
  112. i += 2
  113. continue
  114. if char == mode:
  115. mode = None
  116. i += 1
  117. self.assertEqual(mode, None, "unterminated comment or string literal")
  118. pairs = {")": "(", "]": "[", "}": "{"}
  119. stack = []
  120. for char in out:
  121. if char in "([{":
  122. stack.append(char)
  123. elif char in pairs:
  124. self.assertTrue(stack and stack[-1] == pairs[char],
  125. "unbalanced %r at depth %s" % (char, stack))
  126. stack.pop()
  127. self.assertEqual(stack, [], "unclosed delimiters %s" % stack)
  128. # ---------------------------------------------------------------------------
  129. # Port of the client logic (keep in step with task_log.html)
  130. # ---------------------------------------------------------------------------
  131. STICK_THRESHOLD = 48 # px that still counts as "reading the tail"
  132. PAGE_SIZE = 30
  133. class Feed(object):
  134. """Scroll-state stand-in for [data-log-feed]."""
  135. def __init__(self, scroll_height=0, client_height=600, scroll_top=0):
  136. self.scroll_height = scroll_height
  137. self.client_height = client_height
  138. self.scroll_top = scroll_top
  139. def at_bottom(feed):
  140. """PORT: a feed shorter than its viewport counts as at-bottom."""
  141. return feed.scroll_height - feed.scroll_top - feed.client_height \
  142. <= STICK_THRESHOLD
  143. class State(object):
  144. """PORT: the module-level `state` object."""
  145. def __init__(self):
  146. self.events = []
  147. self.oldest = 0
  148. self.exhausted = False
  149. self.loading = False
  150. self.seeded = False
  151. def ingest(state, evt):
  152. """PORT: keep state.events oldest-first (ascending id); skip ids
  153. already held (SSE replay after a reconnect resends a frame)."""
  154. i = 0
  155. while i < len(state.events) and state.events[i]["id"] < evt["id"]:
  156. i += 1
  157. if i < len(state.events) and state.events[i]["id"] == evt["id"]:
  158. return
  159. state.events.insert(i, evt)
  160. if evt["id"] < state.oldest or state.oldest == 0:
  161. state.oldest = evt["id"]
  162. def render(state, feed, mode=None, height_after=None):
  163. """PORT of render(mode): decide the post-render scroll position.
  164. height_after stands in for the DOM rebuild's new scrollHeight."""
  165. was_at_bottom = at_bottom(feed)
  166. height_before = feed.scroll_height
  167. scroll_top_before = feed.scroll_top
  168. if height_after is not None:
  169. feed.scroll_height = height_after
  170. if not state.seeded and len(state.events) > 0:
  171. state.seeded = True
  172. feed.scroll_top = feed.scroll_height # scrollToBottom()
  173. elif mode == "prepend":
  174. if was_at_bottom:
  175. feed.scroll_top = feed.scroll_height
  176. else:
  177. feed.scroll_top = scroll_top_before + (feed.scroll_height
  178. - height_before)
  179. elif was_at_bottom:
  180. feed.scroll_top = feed.scroll_height
  181. else:
  182. feed.scroll_top = scroll_top_before
  183. return feed.scroll_top
  184. def server_page(newest, oldest, more):
  185. """The endpoint's wire shape: newest-first within the page."""
  186. return {"events": [{"id": i} for i in range(newest, oldest - 1, -1)],
  187. "more": more}
  188. def load_more(state, feed, page, height_after):
  189. """PORT of loadMore's success path: reverse the newest-first page
  190. into chronological order, ingest it, set exhausted, render."""
  191. rows = page["events"]
  192. for evt in reversed(rows):
  193. ingest(state, evt)
  194. if not page["more"] or len(rows) == 0:
  195. state.exhausted = True
  196. return render(state, feed, mode="prepend", height_after=height_after)
  197. def on_stream_message(state, feed, data, height_after):
  198. """PORT of onStreamMessage: skip heartbeats, ingest one row."""
  199. if data == "ping":
  200. return feed.scroll_top
  201. ingest(state, {"id": int(data)})
  202. return render(state, feed, mode="append", height_after=height_after)
  203. # ---------------------------------------------------------------------------
  204. # Behaviour tests
  205. # ---------------------------------------------------------------------------
  206. def ids(state):
  207. return [evt["id"] for evt in state.events]
  208. class ChronologyTests(unittest.TestCase):
  209. def test_initial_window_renders_oldest_first(self):
  210. """The SSE replay / first JSON page arrives newest-first
  211. ([30..1]); the feed must render [1..30] - oldest at the top."""
  212. state = State()
  213. for evt in server_page(30, 1, True)["events"]:
  214. ingest(state, evt)
  215. self.assertEqual(ids(state), list(range(1, 31)))
  216. self.assertEqual(state.oldest, 1)
  217. def test_live_appends_land_in_order(self):
  218. state = State()
  219. for i in range(30, 0, -1):
  220. ingest(state, {"id": i})
  221. ingest(state, {"id": 31})
  222. ingest(state, {"id": 33})
  223. ingest(state, {"id": 32}) # out of order arrival
  224. self.assertEqual(ids(state)[-3:], [31, 32, 33])
  225. def test_duplicate_ids_are_dropped(self):
  226. state = State()
  227. ingest(state, {"id": 5})
  228. ingest(state, {"id": 5}) # EventSource reconnect replay
  229. self.assertEqual(ids(state), [5])
  230. class ScrollStickTests(unittest.TestCase):
  231. def test_first_render_with_rows_seeds_at_the_bottom(self):
  232. state = State()
  233. feed = Feed(scroll_height=0, client_height=600)
  234. for evt in server_page(30, 1, True)["events"]:
  235. ingest(state, evt)
  236. render(state, feed, height_after=3000)
  237. self.assertTrue(state.seeded)
  238. self.assertEqual(feed.scroll_top, 3000)
  239. def test_append_sticks_when_reading_the_tail(self):
  240. state = State()
  241. feed = Feed(scroll_height=3000, client_height=600)
  242. state.seeded = True
  243. feed.scroll_top = 2400 # exactly at the bottom
  244. self.assertTrue(at_bottom(feed))
  245. ingest(state, {"id": 31})
  246. render(state, feed, mode="append", height_after=3100)
  247. self.assertEqual(feed.scroll_top, 3100)
  248. def test_append_within_threshold_still_sticks(self):
  249. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2360)
  250. self.assertTrue(at_bottom(feed)) # 40px shy: close enough
  251. def test_just_past_threshold_is_not_the_tail(self):
  252. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2351)
  253. self.assertFalse(at_bottom(feed)) # 49px shy: reading history
  254. def test_scrolled_up_reader_is_never_yanked(self):
  255. state = State()
  256. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  257. state.seeded = True
  258. self.assertFalse(at_bottom(feed))
  259. ingest(state, {"id": 31})
  260. render(state, feed, mode="append", height_after=3100)
  261. self.assertEqual(feed.scroll_top, 500)
  262. def test_short_feed_counts_as_at_bottom(self):
  263. """A feed shorter than its viewport cannot be scrolled away
  264. from the newest row."""
  265. feed = Feed(scroll_height=100, client_height=600, scroll_top=0)
  266. self.assertTrue(at_bottom(feed))
  267. def test_heartbeat_does_not_touch_anything(self):
  268. state = State()
  269. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  270. state.seeded = True
  271. before = (list(ids(state)), feed.scroll_top)
  272. on_stream_message(state, feed, "ping", height_after=3100)
  273. self.assertEqual((list(ids(state)), feed.scroll_top), before)
  274. class LoadMoreTests(unittest.TestCase):
  275. def test_older_page_is_reversed_and_prepended(self):
  276. """before=<oldest>=1 returns [60..31] newest-first on the
  277. wire; the display must gain [31..60] above [1..30]."""
  278. state = State()
  279. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  280. for evt in server_page(30, 1, True)["events"]:
  281. ingest(state, evt)
  282. state.seeded = True
  283. render(state, feed, height_after=3000)
  284. feed.scroll_top = 500 # the reader scrolled up into history
  285. load_more(state, feed, server_page(60, 31, True), height_after=6000)
  286. self.assertEqual(ids(state), list(range(1, 61)))
  287. self.assertEqual(state.oldest, 1)
  288. self.assertFalse(state.exhausted)
  289. def test_prepend_keeps_the_viewport_anchored(self):
  290. """Content added above must not move the rows under the
  291. reader's cursor: scrollTop grows by the added height."""
  292. state = State()
  293. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  294. state.seeded = True
  295. self.assertFalse(at_bottom(feed))
  296. delta = 3000
  297. load_more(state, feed, server_page(60, 31, True),
  298. height_after=3000 + delta)
  299. self.assertEqual(feed.scroll_top, 500 + delta)
  300. def test_prepend_while_at_bottom_stays_at_bottom(self):
  301. state = State()
  302. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  303. state.seeded = True
  304. self.assertTrue(at_bottom(feed))
  305. load_more(state, feed, server_page(60, 31, True), height_after=6000)
  306. self.assertEqual(feed.scroll_top, 6000)
  307. def test_exhausted_when_more_is_false(self):
  308. state = State()
  309. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  310. state.seeded = True
  311. load_more(state, feed, server_page(60, 31, False), height_after=6000)
  312. self.assertTrue(state.exhausted)
  313. def test_empty_page_exhausts_too(self):
  314. """A defensive guard: a page with no rows means no older
  315. history, whatever the flag says."""
  316. state = State()
  317. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  318. state.seeded = True
  319. ingest(state, {"id": 1})
  320. load_more(state, feed, {"events": [], "more": True}, height_after=3000)
  321. self.assertTrue(state.exhausted)
  322. class EndToEndSequenceTests(unittest.TestCase):
  323. """The whole first-minute story: replay, live tail, history."""
  324. def test_replay_then_append_then_history(self):
  325. state = State()
  326. feed = Feed(scroll_height=0, client_height=600)
  327. # 1. SSE replay: most-recent-30 oldest-first on the wire.
  328. for evt in server_page(30, 1, True)["events"]:
  329. ingest(state, evt)
  330. render(state, feed, height_after=3000)
  331. self.assertEqual(ids(state), list(range(1, 31)))
  332. self.assertEqual(feed.scroll_top, 3000) # seeded at the tail
  333. # 2. Two live rows arrive; the reader is at the bottom.
  334. on_stream_message(state, feed, "31", height_after=3100)
  335. on_stream_message(state, feed, "32", height_after=3200)
  336. self.assertEqual(ids(state)[-2:], [31, 32])
  337. self.assertEqual(feed.scroll_top, 3200)
  338. # 3. A heartbeat is ignored, then the reader scrolls up.
  339. on_stream_message(state, feed, "ping", height_after=3200)
  340. feed.scroll_top = 100
  341. on_stream_message(state, feed, "33", height_after=3300)
  342. self.assertEqual(feed.scroll_top, 100) # never yanked
  343. # 4. Load older: before=1 -> [60..31] newest-first wire page.
  344. self.assertEqual(state.oldest, 1)
  345. load_more(state, feed, server_page(60, 31, False), height_after=6300)
  346. self.assertEqual(ids(state), list(range(1, 61)))
  347. self.assertTrue(state.exhausted)
  348. self.assertEqual(feed.scroll_top, 100 + 3000) # anchored
  349. if __name__ == "__main__":
  350. unittest.main(verbosity=2)