| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- import { expect, test } from "bun:test"
- import { createRoot, createSignal } from "solid-js"
- import { createSessionLineage } from "@/pages/session/session-lineage"
- type Lineage = { session: { id: string; directory: string } }
- const lineageOf = (id: string): Lineage => ({ session: { id, directory: `/dir/${id}` } })
- // Fake sync lineage store: peek reads a reactive cache, resolve returns a
- // deferred promise the test settles or fails explicitly. The lineage memo is
- // live (read below), so it recomputes eagerly on cache/status writes — throws
- // surface at the write site, which is also where the enclosing ErrorBoundary
- // would see them in the app. Assertions wrap write + read to cover both.
- function createFixture(initial: Record<string, Lineage> = {}) {
- const [cache, setCache] = createSignal(initial)
- const deferred = new Map<string, PromiseWithResolvers<unknown>>()
- const resolves: string[] = []
- return {
- resolves,
- lineage: {
- peek: (id: string) => cache()[id],
- resolve: (id: string) => {
- resolves.push(id)
- const entry = deferred.get(id) ?? Promise.withResolvers<unknown>()
- deferred.set(id, entry)
- return entry.promise
- },
- },
- settle(id: string) {
- setCache({ ...cache(), [id]: lineageOf(id) })
- deferred.get(id)?.resolve(undefined)
- },
- fail(id: string, error: unknown) {
- deferred.get(id)?.reject(error)
- // The real store does not cache failures: the inflight request entry is
- // dropped on rejection so the next resolve retries (server-session.ts).
- deferred.delete(id)
- },
- remove(id: string) {
- const next = { ...cache() }
- delete next[id]
- setCache(next)
- },
- }
- }
- // Two microtask ticks: one for the resolve promise handed back by the fixture,
- // one for the .then/.catch chain inside createSessionLineage.
- const flush = async () => {
- await Promise.resolve()
- await Promise.resolve()
- }
- test("resolves an uncached session and exposes its lineage", async () => {
- await createRoot(async (dispose) => {
- const fixture = createFixture()
- const current = createSessionLineage(
- () => "ses_a",
- () => fixture.lineage,
- )
- expect(current()).toBeUndefined()
- await flush()
- expect(fixture.resolves).toEqual(["ses_a"])
- fixture.settle("ses_a")
- await flush()
- expect(current()?.session.id).toBe("ses_a")
- dispose()
- })
- })
- // Session tabs on the same server share one route instance, so navigating to
- // another session changes the id in place; resolution must follow it instead
- // of reporting the new session as missing.
- test("re-resolves when navigating to an uncached session without a remount", async () => {
- await createRoot(async (dispose) => {
- const fixture = createFixture({ ses_a: lineageOf("ses_a") })
- const [id, setId] = createSignal("ses_a")
- const current = createSessionLineage(id, () => fixture.lineage)
- await flush()
- expect(current()?.session.id).toBe("ses_a")
- expect(() => {
- setId("ses_b")
- current()
- }).not.toThrow()
- expect(fixture.resolves).toEqual(["ses_b"])
- fixture.settle("ses_b")
- await flush()
- expect(current()?.session.id).toBe("ses_b")
- dispose()
- })
- })
- // A late failure from a session the user already navigated away from must not
- // poison the currently viewed session.
- test("ignores a stale resolution failure after the target changes", async () => {
- await createRoot(async (dispose) => {
- const fixture = createFixture()
- const [id, setId] = createSignal("ses_a")
- const current = createSessionLineage(id, () => fixture.lineage)
- await flush()
- setId("ses_b")
- fixture.fail("ses_a", new Error("Session not found: ses_a"))
- await flush()
- expect(() => current()).not.toThrow()
- fixture.settle("ses_b")
- await flush()
- expect(current()?.session.id).toBe("ses_b")
- dispose()
- })
- })
- test("returning to a pruned session re-resolves instead of throwing not found", async () => {
- await createRoot(async (dispose) => {
- const fixture = createFixture()
- const [id, setId] = createSignal("ses_a")
- const current = createSessionLineage(id, () => fixture.lineage)
- await flush()
- fixture.settle("ses_a")
- await flush()
- setId("ses_b")
- fixture.settle("ses_b")
- await flush()
- fixture.remove("ses_a")
- expect(() => {
- setId("ses_a")
- current()
- }).not.toThrow()
- expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
- fixture.settle("ses_a")
- await flush()
- expect(current()?.session.id).toBe("ses_a")
- dispose()
- })
- })
- // A resolution that fails while its session is unfocused must not leave a
- // poisoned status behind: revisiting that session retries cleanly instead of
- // rethrowing the stale failure before the retry can start.
- test("revisiting a session whose resolution failed while unfocused retries cleanly", async () => {
- await createRoot(async (dispose) => {
- const fixture = createFixture()
- const [id, setId] = createSignal("ses_a")
- const current = createSessionLineage(id, () => fixture.lineage)
- await flush()
- setId("ses_b")
- fixture.fail("ses_a", new Error("resolve failed"))
- await flush()
- expect(() => {
- setId("ses_a")
- current()
- }).not.toThrow()
- expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
- fixture.settle("ses_a")
- await flush()
- expect(current()?.session.id).toBe("ses_a")
- dispose()
- })
- })
- // The lineage accessor is reactive: replacing the sync store (for example after
- // the server context is rebuilt) must gate out the old store's status and
- // re-resolve against the new one instead of fabricating a not-found.
- test("re-resolves against a replaced lineage store", async () => {
- await createRoot(async (dispose) => {
- const first = createFixture()
- const second = createFixture()
- const [store, setStore] = createSignal(first.lineage)
- const current = createSessionLineage(() => "ses_a", store)
- await flush()
- first.settle("ses_a")
- await flush()
- expect(current()?.session.id).toBe("ses_a")
- expect(() => {
- setStore(second.lineage)
- current()
- }).not.toThrow()
- await flush()
- expect(second.resolves).toEqual(["ses_a"])
- second.settle("ses_a")
- await flush()
- expect(current()?.session.id).toBe("ses_a")
- dispose()
- })
- })
- // The viewed session is pinned in the cache, so disappearing after settlement
- // means it was deleted; the boundary must show the not found fallback.
- test("throws not found when the settled session is deleted", async () => {
- await createRoot(async (dispose) => {
- const fixture = createFixture()
- const current = createSessionLineage(
- () => "ses_a",
- () => fixture.lineage,
- )
- await flush()
- fixture.settle("ses_a")
- await flush()
- expect(current()?.session.id).toBe("ses_a")
- expect(() => {
- fixture.remove("ses_a")
- current()
- }).toThrow("Session not found: ses_a")
- dispose()
- })
- })
|