| 1 | import assert from "node:assert/strict"; |
| 2 | import { act } from "react"; |
| 3 | import { createTranscriptHarness } from "./transcript-dom-harness"; |
| 4 | import type { TranscriptOutlineStore } from "../lib/transcriptOutlineStore"; |
| 5 | import type { TranscriptOutlineEntry, TranscriptOutlinePage } from "../lib/transcriptProtocol"; |
| 6 | import type { Item } from "../lib/useController"; |
| 7 | |
| 8 | const TAB = "outline-jump-tab"; |
| 9 | const SNAPSHOT = "snapshot-1"; |
| 10 | const TOTAL = 6; |
| 11 | |
| 12 | function entry(turn: number): TranscriptOutlineEntry { |
| 13 | return { |
| 14 | id: `m:u${turn}`, messageId: `u${turn}`, turn, order: turn * 2 - 2, |
| 15 | prompt: `outline prompt ${turn}`, answer: `outline answer ${turn}`, |
| 16 | }; |
| 17 | } |
| 18 | |
| 19 | function outlinePage(): TranscriptOutlinePage { |
| 20 | return { |
| 21 | protocolVersion: 1, snapshotId: SNAPSHOT, stale: false, |
| 22 | entries: Array.from({ length: TOTAL }, (_, index) => entry(index + 1)), |
| 23 | nextOffset: TOTAL, done: true, total: TOTAL, |
| 24 | }; |
| 25 | } |
| 26 | |
| 27 | /** The loaded body window: the newest turns, oldest first, as the transcript |
| 28 | * loads them — an earlier page prepends above this. |
| 29 | * |
| 30 | * The user items carry the runtime shape of a question written in this app |
| 31 | * session: the reducer keeps the optimistic `u<seq>` id after the authoritative |
| 32 | * message settles and only attaches `messageId`, so matching the rail by the |
| 33 | * shape of the anchor key would miss them. */ |
| 34 | function turnsFrom(start: number): Item[] { |
| 35 | const items: Item[] = []; |
| 36 | for (let index = start; index <= TOTAL; index += 1) { |
| 37 | items.push({ kind: "user", id: `u${index}`, messageId: `u${index}`, text: `loaded prompt ${index}`, historyTurn: index }); |
| 38 | items.push({ kind: "assistant", id: `a${index}`, text: `loaded answer ${index}`, reasoning: "", streaming: false }); |
| 39 | } |
| 40 | return items; |
| 41 | } |
| 42 | |
| 43 | const harness = await createTranscriptHarness({ deterministic: true }); |
| 44 | let store: TranscriptOutlineStore | undefined; |
| 45 | try { |
| 46 | await harness.loadModule("/src/components/ChatToolBody.tsx"); |
| 47 | // The component resolves the store through the harness's module graph, so the |
| 48 | // test must take the same singleton instance rather than its own import. |
| 49 | const outlineModule = await harness.loadModule<{ |
| 50 | getTranscriptOutlineStore: () => TranscriptOutlineStore; |
| 51 | }>("/src/lib/transcriptOutlineStore.ts"); |
| 52 | store = outlineModule.getTranscriptOutlineStore(); |
| 53 | store.register(TAB, async () => outlinePage()); |
| 54 | await store.sync(TAB, SNAPSHOT); |
| 55 | assert.equal(store.getView(TAB).mode, "ready", "the outline is bound to the snapshot"); |
| 56 | |
| 57 | // Only the newest two turns are loaded. The rail must still show the whole |
| 58 | // conversation, which is the reported defect. |
| 59 | let windowStart = TOTAL - 1; |
| 60 | let pages = 0; |
| 61 | const pageCommits: Array<(loaded: boolean) => void> = []; |
| 62 | const render = () => harness.render(turnsFrom(windowStart), { |
| 63 | tabId: TAB, totalTurns: TOTAL, hasOlderHistory: windowStart > 1, historyStartTurn: windowStart - 1, |
| 64 | onLoadOlderHistory: async () => { |
| 65 | pages += 1; |
| 66 | if (windowStart <= 1) return false; |
| 67 | windowStart = Math.max(1, windowStart - 2); |
| 68 | // The parent commit is driven outside the click's act() scope. Rendering |
| 69 | // recursively from this callback creates overlapping act() calls and can |
| 70 | // leave later props uncommitted, which previously disguised hasOlder. |
| 71 | return new Promise<boolean>(resolve => { pageCommits.push(resolve); }); |
| 72 | }, |
| 73 | }); |
| 74 | await render(); |
| 75 | await harness.settle(); |
| 76 | |
| 77 | const marks = () => Array.from(harness.container.querySelectorAll<HTMLElement>("[data-nav-turn]")); |
| 78 | // The rail is a lazily imported chunk, so let it commit before asserting. |
| 79 | await harness.waitFor(() => marks().length === TOTAL, "the rail to list the complete outline"); |
| 80 | assert.equal(marks().length, TOTAL, "the rail lists every turn, not only the loaded ones"); |
| 81 | assert.deepEqual( |
| 82 | marks().map(mark => mark.dataset.navTurn), |
| 83 | Array.from({ length: TOTAL }, (_, index) => `m:u${index + 1}`), |
| 84 | "rail order follows the complete conversation", |
| 85 | ); |
| 86 | const unloaded = marks().filter(mark => mark.dataset.navUnloaded === "true"); |
| 87 | assert.deepEqual(unloaded.map(mark => mark.dataset.navTurn), ["m:u1", "m:u2", "m:u3", "m:u4"], |
| 88 | "turns without a mounted body are marked unloaded"); |
| 89 | assert.equal(harness.container.querySelector('[data-chat-anchor-key="u1"]'), null, "the oldest turn is not loaded yet"); |
| 90 | |
| 91 | // A question written in this app session keeps its optimistic `u<seq>` anchor |
| 92 | // key, which does not match its outline record id. It must still be one mark |
| 93 | // — matched by identity — and not a duplicate "unloaded" entry beside a |
| 94 | // loaded one. |
| 95 | const keys = new Set(marks().map(mark => mark.dataset.navTurn)); |
| 96 | assert.equal(keys.size, TOTAL, "every turn appears exactly once despite mismatched anchor keys"); |
| 97 | assert.equal(marks().find(mark => mark.dataset.navTurn === "m:u6")?.dataset.navUnloaded, undefined, |
| 98 | "a loaded turn with an optimistic anchor key is not marked unloaded"); |
| 99 | |
| 100 | // Absolute turn numbering must survive loading an earlier page. |
| 101 | const labelsBefore = marks().map(mark => mark.getAttribute("aria-label")); |
| 102 | assert.match(labelsBefore[0]!, /1/, "the first mark is turn 1"); |
| 103 | |
| 104 | // Clicking an unloaded turn pages history in until its node is mounted. |
| 105 | const targetMark = marks().find(mark => mark.dataset.navTurn === "m:u1")!; |
| 106 | act(() => { targetMark.click(); }); |
| 107 | for (let page = 0; page < 2; page += 1) { |
| 108 | await harness.waitFor(() => pageCommits.length > 0, `history page ${page + 1} to be requested`); |
| 109 | await render(); |
| 110 | const commit = pageCommits.shift()!; |
| 111 | await act(async () => { commit(true); await Promise.resolve(); }); |
| 112 | } |
| 113 | await harness.waitFor( |
| 114 | () => harness.container.querySelector('[data-chat-anchor-key="u1"]') !== null, |
| 115 | "the oldest turn's node to mount", |
| 116 | ); |
| 117 | await harness.settle(); |
| 118 | assert.ok(pages >= 2, "the jump paged older history more than once"); |
| 119 | assert.equal( |
| 120 | marks().find(mark => mark.dataset.navTurn === "m:u1")?.dataset.navUnloaded, |
| 121 | undefined, |
| 122 | "the target is no longer marked unloaded once mounted", |
| 123 | ); |
| 124 | assert.deepEqual( |
| 125 | marks().map(mark => mark.dataset.navTurn), |
| 126 | Array.from({ length: TOTAL }, (_, index) => `m:u${index + 1}`), |
| 127 | "the rail keeps its identity and order after the jump", |
| 128 | ); |
| 129 | assert.deepEqual(marks().map(mark => mark.getAttribute("aria-label")), labelsBefore, |
| 130 | "loading an earlier page never renumbers the rail"); |
| 131 | |
| 132 | // The rail's busy state clears once the target is reached. |
| 133 | await harness.waitFor( |
| 134 | () => harness.container.querySelectorAll('[aria-busy="true"]').length === 0, |
| 135 | "the busy state to clear", |
| 136 | ); |
| 137 | |
| 138 | // A jump that fails must offer its own retry. The outline stays perfectly |
| 139 | // readable here, so an entry gated on the outline's own failure never appears |
| 140 | // — which is exactly the case that used to render no button at all. |
| 141 | const phantom: TranscriptOutlineEntry = { |
| 142 | id: "m:u9", messageId: "u9", turn: TOTAL + 1, order: 99, prompt: "phantom prompt", answer: "", |
| 143 | }; |
| 144 | let outlineSnapshot = "snapshot-2"; |
| 145 | let outlineRefreshes = 0; |
| 146 | store.register(TAB, async (_tabId, request) => ({ |
| 147 | ...outlinePage(), snapshotId: request.snapshotId, |
| 148 | entries: [...outlinePage().entries, phantom], total: TOTAL + 1, nextOffset: TOTAL + 1, |
| 149 | }), async () => { |
| 150 | outlineRefreshes += 1; |
| 151 | outlineSnapshot = `snapshot-retry-${outlineRefreshes}`; |
| 152 | await store!.load(TAB, outlineSnapshot); |
| 153 | }); |
| 154 | await act(async () => { await store!.load(TAB, outlineSnapshot); }); |
| 155 | let jumpLoads = 0; |
| 156 | await harness.render(turnsFrom(TOTAL - 1), { |
| 157 | tabId: TAB, geometrySessionKey: "outline-jump-phantom", totalTurns: TOTAL + 1, |
| 158 | hasOlderHistory: true, historyStartTurn: TOTAL - 2, |
| 159 | onLoadOlderHistory: async () => { |
| 160 | pages += 1; |
| 161 | jumpLoads += 1; |
| 162 | return jumpLoads === 1 ? "stale" : "empty"; |
| 163 | }, |
| 164 | }); |
| 165 | await harness.waitFor(() => marks().length === TOTAL + 1, "the rail to list the phantom turn"); |
| 166 | assert.ok(harness.container.querySelector(".chat-older"), "the remounted session committed hasOlderHistory=true"); |
| 167 | const phantomMark = marks().find(mark => mark.dataset.navTurn === "m:u9")!; |
| 168 | await act(async () => { phantomMark.click(); }); |
| 169 | await harness.settle(); |
| 170 | await harness.waitFor( |
| 171 | () => harness.container.querySelector('[data-nav-retry="jump"]') !== null, |
| 172 | "the failed jump to offer its own retry", |
| 173 | ); |
| 174 | const retryButton = harness.container.querySelector<HTMLButtonElement>('[data-nav-retry="jump"]')!; |
| 175 | assert.ok(retryButton.getAttribute("title"), "the retry says why the jump failed"); |
| 176 | assert.equal(store.getView(TAB).mode, "ready", "the outline itself never failed"); |
| 177 | assert.equal( |
| 178 | harness.container.querySelector('[data-nav-retry="outline"]'), |
| 179 | null, |
| 180 | "no outline-retry entry is offered when only the jump failed", |
| 181 | ); |
| 182 | const pagesBeforeRetry = pages; |
| 183 | await act(async () => { retryButton.click(); }); |
| 184 | await harness.waitFor(() => outlineRefreshes === 1, "the retry to refresh the snapshot", 400); |
| 185 | await harness.waitFor(() => pages > pagesBeforeRetry, "the retry to re-run the jump", 400); |
| 186 | |
| 187 | console.log("chat turn outline jump: complete rail, unloaded marks, absolute numbering, mount-confirmed jump and failed-jump retry passed"); |
| 188 | } finally { |
| 189 | await harness.unmount(); |
| 190 | store?.release(TAB); |
| 191 | await harness.close(); |
| 192 | } |
| 193 |