| 1 | import assert from "node:assert/strict"; |
| 2 | import { act, createElement } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { createTranscriptHarness } from "./transcript-dom-harness"; |
| 5 | import type { Item } from "../lib/useController"; |
| 6 | |
| 7 | const harness = await createTranscriptHarness({ deterministic: true }); |
| 8 | const { ChatSource } = await harness.loadModule<typeof import("../lib/chatViewSource")>("/src/lib/chatViewSource.ts"); |
| 9 | const { ChatNodeList } = await harness.loadModule<typeof import("../components/ChatNodes")>("/src/components/ChatNodes.tsx"); |
| 10 | const { ChatMountedOrder } = await harness.loadModule<typeof import("../lib/chatMountedOrder")>("/src/lib/chatMountedOrder.ts"); |
| 11 | const { ChatContentLoader } = await harness.loadModule<typeof import("../lib/chatContentLoader")>("/src/lib/chatContentLoader.ts"); |
| 12 | const { ChatScrollController } = await harness.loadModule<typeof import("../lib/chatScrollController")>("/src/lib/chatScrollController.ts"); |
| 13 | const { LocaleProvider } = await harness.loadModule<typeof import("../lib/i18n")>("/src/lib/i18n.tsx"); |
| 14 | |
| 15 | const source = new ChatSource("reasoning-load-full"); |
| 16 | let fail = false; |
| 17 | let release: (() => void) | undefined; |
| 18 | const loads: string[] = []; |
| 19 | const loader = new ChatContentLoader(undefined, (item, field) => { |
| 20 | if (field !== "reasoning") return Promise.resolve(""); |
| 21 | loads.push(item.id); |
| 22 | if (fail) return Promise.reject(new Error("unavailable")); |
| 23 | return new Promise<string>(resolve => { release = () => resolve((item as { reasoning: string }).reasoning); }); |
| 24 | }); |
| 25 | const scroll = new ChatScrollController("reasoning-load-full"); |
| 26 | const mounts = new ChatMountedOrder(); |
| 27 | await harness.unmount(); |
| 28 | const root = createRoot(harness.container); |
| 29 | const question: Item = { kind: "user", id: "q", text: "think" }; |
| 30 | const live = (reasoning: string, streaming = true): Item => ({ kind: "assistant", id: "a", text: "", reasoning, streaming }); |
| 31 | const publish = async (items: Item[], running: boolean) => { |
| 32 | await act(async () => source.update({ items, running, hydrating: false, hasOlder: false, loadingOlder: false })); |
| 33 | await harness.settle(); |
| 34 | }; |
| 35 | const body = () => harness.container.querySelector(".chat-reasoning__body")?.textContent ?? ""; |
| 36 | const loadButton = () => [...harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning__body > .btn")][0]; |
| 37 | |
| 38 | try { |
| 39 | const first = "a".repeat(9000); |
| 40 | await publish([question, live(first)], true); |
| 41 | await act(async () => root.render(createElement(LocaleProvider, null, |
| 42 | createElement(ChatNodeList, { source, mounts, loader, scroll, actions: { openDetails: () => {}, recover: () => {} } })))); |
| 43 | await harness.settle(); |
| 44 | await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click()); |
| 45 | assert.ok(loadButton(), "long streaming reasoning offers a full-text button"); |
| 46 | await act(async () => loadButton()!.click()); |
| 47 | await harness.settle(); |
| 48 | assert.ok(body().length >= first.length, "clicking load shows the whole reasoning while it streams"); |
| 49 | assert.equal(loadButton(), undefined, "the button is gone once the full text is shown"); |
| 50 | |
| 51 | const grown = first + "b".repeat(500); |
| 52 | await publish([question, live(grown)], true); |
| 53 | assert.ok(body().length >= grown.length, "the loaded view follows the growing stream instead of snapping back"); |
| 54 | assert.equal(loadButton(), undefined, "streaming chunks do not bring the button back"); |
| 55 | |
| 56 | const final = grown + "c".repeat(500); |
| 57 | await publish([question, live(final, false)], false); |
| 58 | assert.ok(body().length >= final.length, "the body never shrinks while the settled load is pending"); |
| 59 | assert.equal(loadButton(), undefined, "no button while the settled load is pending"); |
| 60 | await act(async () => { release?.(); }); |
| 61 | await harness.settle(); |
| 62 | assert.ok(body().length >= final.length, "the loaded view survives the settled load"); |
| 63 | assert.equal(loadButton(), undefined, "settling does not bring the button back"); |
| 64 | assert.equal(loads.length, 1, "the settled load runs once"); |
| 65 | |
| 66 | const copies = loads.length; |
| 67 | await publish([question, { ...live("d".repeat(9000)), id: "b" }], true); |
| 68 | await act(async () => harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning .copybtn")[1]?.click()); |
| 69 | assert.equal(loads.length, copies, "copying while streaming does not start a load"); |
| 70 | |
| 71 | const start = loads.length; |
| 72 | await publish([question, { ...live("e".repeat(9000)), id: "c" }], true); |
| 73 | await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click()); |
| 74 | await act(async () => loadButton()!.click()); |
| 75 | const idle = loadButton() ? "" : "gone"; |
| 76 | fail = true; |
| 77 | const failedFinal = "e".repeat(9500); |
| 78 | await publish([question, { ...live(failedFinal, false), id: "c" }], false); |
| 79 | await harness.settle(); |
| 80 | assert.equal(loads.length, start + 1, "the failing load ran once"); |
| 81 | assert.ok(body().length >= failedFinal.length, "a failed load does not shrink the body"); |
| 82 | const retry = loadButton(); |
| 83 | assert.ok(retry, `a failed load keeps a retry button (${idle})`); |
| 84 | assert.equal(retry!.disabled, false, "the retry button is enabled"); |
| 85 | const failedLabel = retry!.textContent; |
| 86 | fail = false; |
| 87 | await act(async () => retry!.click()); |
| 88 | await act(async () => { release?.(); }); |
| 89 | await harness.settle(); |
| 90 | assert.equal(loads.length, start + 2, "retry loads again"); |
| 91 | assert.equal(loadButton(), undefined, "a successful retry removes the button"); |
| 92 | assert.ok(failedLabel, "the failed state is labelled"); |
| 93 | } finally { |
| 94 | await act(async () => root.unmount()); |
| 95 | source.dispose(); mounts.dispose(); loader.dispose(); scroll.dispose(); |
| 96 | await harness.unmount(); await harness.close(); |
| 97 | } |
| 98 | console.log("reasoning load full: stays loaded across streaming chunks and settlement"); |
| 99 |