session-lineage.test.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { expect, test } from "bun:test"
  2. import { createRoot, createSignal } from "solid-js"
  3. import { createSessionLineage } from "@/pages/session/session-lineage"
  4. type Lineage = { session: { id: string; directory: string } }
  5. const lineageOf = (id: string): Lineage => ({ session: { id, directory: `/dir/${id}` } })
  6. // Fake sync lineage store: peek reads a reactive cache, resolve returns a
  7. // deferred promise the test settles or fails explicitly. The lineage memo is
  8. // live (read below), so it recomputes eagerly on cache/status writes — throws
  9. // surface at the write site, which is also where the enclosing ErrorBoundary
  10. // would see them in the app. Assertions wrap write + read to cover both.
  11. function createFixture(initial: Record<string, Lineage> = {}) {
  12. const [cache, setCache] = createSignal(initial)
  13. const deferred = new Map<string, PromiseWithResolvers<unknown>>()
  14. const resolves: string[] = []
  15. return {
  16. resolves,
  17. lineage: {
  18. peek: (id: string) => cache()[id],
  19. resolve: (id: string) => {
  20. resolves.push(id)
  21. const entry = deferred.get(id) ?? Promise.withResolvers<unknown>()
  22. deferred.set(id, entry)
  23. return entry.promise
  24. },
  25. },
  26. settle(id: string) {
  27. setCache({ ...cache(), [id]: lineageOf(id) })
  28. deferred.get(id)?.resolve(undefined)
  29. },
  30. fail(id: string, error: unknown) {
  31. deferred.get(id)?.reject(error)
  32. // The real store does not cache failures: the inflight request entry is
  33. // dropped on rejection so the next resolve retries (server-session.ts).
  34. deferred.delete(id)
  35. },
  36. remove(id: string) {
  37. const next = { ...cache() }
  38. delete next[id]
  39. setCache(next)
  40. },
  41. }
  42. }
  43. // Two microtask ticks: one for the resolve promise handed back by the fixture,
  44. // one for the .then/.catch chain inside createSessionLineage.
  45. const flush = async () => {
  46. await Promise.resolve()
  47. await Promise.resolve()
  48. }
  49. test("resolves an uncached session and exposes its lineage", async () => {
  50. await createRoot(async (dispose) => {
  51. const fixture = createFixture()
  52. const current = createSessionLineage(
  53. () => "ses_a",
  54. () => fixture.lineage,
  55. )
  56. expect(current()).toBeUndefined()
  57. await flush()
  58. expect(fixture.resolves).toEqual(["ses_a"])
  59. fixture.settle("ses_a")
  60. await flush()
  61. expect(current()?.session.id).toBe("ses_a")
  62. dispose()
  63. })
  64. })
  65. // Session tabs on the same server share one route instance, so navigating to
  66. // another session changes the id in place; resolution must follow it instead
  67. // of reporting the new session as missing.
  68. test("re-resolves when navigating to an uncached session without a remount", async () => {
  69. await createRoot(async (dispose) => {
  70. const fixture = createFixture({ ses_a: lineageOf("ses_a") })
  71. const [id, setId] = createSignal("ses_a")
  72. const current = createSessionLineage(id, () => fixture.lineage)
  73. await flush()
  74. expect(current()?.session.id).toBe("ses_a")
  75. expect(() => {
  76. setId("ses_b")
  77. current()
  78. }).not.toThrow()
  79. expect(fixture.resolves).toEqual(["ses_b"])
  80. fixture.settle("ses_b")
  81. await flush()
  82. expect(current()?.session.id).toBe("ses_b")
  83. dispose()
  84. })
  85. })
  86. // A late failure from a session the user already navigated away from must not
  87. // poison the currently viewed session.
  88. test("ignores a stale resolution failure after the target changes", async () => {
  89. await createRoot(async (dispose) => {
  90. const fixture = createFixture()
  91. const [id, setId] = createSignal("ses_a")
  92. const current = createSessionLineage(id, () => fixture.lineage)
  93. await flush()
  94. setId("ses_b")
  95. fixture.fail("ses_a", new Error("Session not found: ses_a"))
  96. await flush()
  97. expect(() => current()).not.toThrow()
  98. fixture.settle("ses_b")
  99. await flush()
  100. expect(current()?.session.id).toBe("ses_b")
  101. dispose()
  102. })
  103. })
  104. test("returning to a pruned session re-resolves instead of throwing not found", async () => {
  105. await createRoot(async (dispose) => {
  106. const fixture = createFixture()
  107. const [id, setId] = createSignal("ses_a")
  108. const current = createSessionLineage(id, () => fixture.lineage)
  109. await flush()
  110. fixture.settle("ses_a")
  111. await flush()
  112. setId("ses_b")
  113. fixture.settle("ses_b")
  114. await flush()
  115. fixture.remove("ses_a")
  116. expect(() => {
  117. setId("ses_a")
  118. current()
  119. }).not.toThrow()
  120. expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
  121. fixture.settle("ses_a")
  122. await flush()
  123. expect(current()?.session.id).toBe("ses_a")
  124. dispose()
  125. })
  126. })
  127. // A resolution that fails while its session is unfocused must not leave a
  128. // poisoned status behind: revisiting that session retries cleanly instead of
  129. // rethrowing the stale failure before the retry can start.
  130. test("revisiting a session whose resolution failed while unfocused retries cleanly", async () => {
  131. await createRoot(async (dispose) => {
  132. const fixture = createFixture()
  133. const [id, setId] = createSignal("ses_a")
  134. const current = createSessionLineage(id, () => fixture.lineage)
  135. await flush()
  136. setId("ses_b")
  137. fixture.fail("ses_a", new Error("resolve failed"))
  138. await flush()
  139. expect(() => {
  140. setId("ses_a")
  141. current()
  142. }).not.toThrow()
  143. expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
  144. fixture.settle("ses_a")
  145. await flush()
  146. expect(current()?.session.id).toBe("ses_a")
  147. dispose()
  148. })
  149. })
  150. // The lineage accessor is reactive: replacing the sync store (for example after
  151. // the server context is rebuilt) must gate out the old store's status and
  152. // re-resolve against the new one instead of fabricating a not-found.
  153. test("re-resolves against a replaced lineage store", async () => {
  154. await createRoot(async (dispose) => {
  155. const first = createFixture()
  156. const second = createFixture()
  157. const [store, setStore] = createSignal(first.lineage)
  158. const current = createSessionLineage(() => "ses_a", store)
  159. await flush()
  160. first.settle("ses_a")
  161. await flush()
  162. expect(current()?.session.id).toBe("ses_a")
  163. expect(() => {
  164. setStore(second.lineage)
  165. current()
  166. }).not.toThrow()
  167. await flush()
  168. expect(second.resolves).toEqual(["ses_a"])
  169. second.settle("ses_a")
  170. await flush()
  171. expect(current()?.session.id).toBe("ses_a")
  172. dispose()
  173. })
  174. })
  175. // The viewed session is pinned in the cache, so disappearing after settlement
  176. // means it was deleted; the boundary must show the not found fallback.
  177. test("throws not found when the settled session is deleted", async () => {
  178. await createRoot(async (dispose) => {
  179. const fixture = createFixture()
  180. const current = createSessionLineage(
  181. () => "ses_a",
  182. () => fixture.lineage,
  183. )
  184. await flush()
  185. fixture.settle("ses_a")
  186. await flush()
  187. expect(current()?.session.id).toBe("ses_a")
  188. expect(() => {
  189. fixture.remove("ses_a")
  190. current()
  191. }).toThrow("Session not found: ses_a")
  192. dispose()
  193. })
  194. })