| 1 | import assert from "node:assert/strict"; |
| 2 | import { act } from "react"; |
| 3 | import { createTranscriptHarness } from "./transcript-dom-harness"; |
| 4 | import { reconcileMountedOrder, revealEarlierMountedOrder } from "../lib/chatMountedOrder"; |
| 5 | import type { Item } from "../lib/useController"; |
| 6 | |
| 7 | for (const count of [1, 5, 23, 24, 25, 48, 49]) { |
| 8 | const resident = Array.from({ length: 60 }, (_, index) => `resident-${index}`); |
| 9 | const prefix = Array.from({ length: count }, (_, index) => `history-${index}`); |
| 10 | const full = [...prefix, ...resident]; |
| 11 | let mounted = reconcileMountedOrder(resident, full); |
| 12 | assert.equal(mounted, resident, `${count}-node prepend keeps the resident order object before the first frame`); |
| 13 | let frames = 0; |
| 14 | while (mounted.length < full.length) { |
| 15 | const previous = mounted; |
| 16 | mounted = revealEarlierMountedOrder(mounted, full); |
| 17 | frames += 1; |
| 18 | assert.deepEqual(mounted.slice(-resident.length), resident, `${count}-node prepend keeps the resident suffix after frame ${frames}`); |
| 19 | assert.ok(mounted.length > previous.length && mounted.length - previous.length <= 24, `${count}-node prepend reveals a bounded frame`); |
| 20 | } |
| 21 | assert.deepEqual(mounted, full, `${count}-node prepend converges to the source order`); |
| 22 | assert.equal(frames, Math.ceil(count / 24), `${count}-node prepend uses the expected frame count`); |
| 23 | } |
| 24 | |
| 25 | const harness = await createTranscriptHarness({ deterministic: true }); |
| 26 | const items: Item[] = [ |
| 27 | { kind: "user", id: "u1", text: "hello", checkpointTurn: 1 }, |
| 28 | { kind: "tool", id: "t1", name: "read_file", args: "{}", output: "result", readOnly: true, status: "done" }, |
| 29 | { kind: "assistant", id: "a1", text: "answer", reasoning: "thought", streaming: false }, |
| 30 | ]; |
| 31 | try { |
| 32 | await harness.loadModule("/src/components/ChatToolBody.tsx"); |
| 33 | await harness.render(items); |
| 34 | await harness.settle(); |
| 35 | assert.ok(harness.container.querySelector(".chat-column")); |
| 36 | assert.ok(harness.container.querySelector('[id^="reasonix-chat-transcript-"] .chat-column'), "native upgrade evidence scopes history to the transcript"); |
| 37 | assert.equal(harness.container.querySelectorAll(".transcript__window-item").length, 0); |
| 38 | assert.equal(harness.container.querySelectorAll(".chat-tool").length, 0, "completed process unmounts heavy rows"); |
| 39 | const disclosure = harness.container.querySelector<HTMLButtonElement>(".chat-process"); |
| 40 | await act(async () => disclosure!.click()); |
| 41 | const tool = harness.container.querySelector<HTMLElement>(".chat-tool [data-disclosure-row]"); |
| 42 | assert.ok(tool); |
| 43 | await act(async () => tool.click()); |
| 44 | await harness.settle(); |
| 45 | assert.ok(harness.container.querySelector(".dsh-ToolRow-ioCard"), "tool opens an inline preview"); |
| 46 | await act(async () => harness.container.querySelector<HTMLButtonElement>(".dsh-ToolRow-inspectButton")!.click()); |
| 47 | assert.ok(harness.container.querySelector('[role="dialog"]')); |
| 48 | for (let i = 0; i < 60; i++) { |
| 49 | await harness.render(items.map(item => item.kind === "assistant" ? { ...item, text: `answer ${i}`, streaming: true } : item), { running: true }); |
| 50 | await act(async () => { harness.resizeNotifications.forEach(notify => notify()); harness.clock.advance(16); }); |
| 51 | } |
| 52 | assert.ok(harness.container.querySelector(".chat-column"), "60 geometry changes do not trip React nested update fuse"); |
| 53 | await harness.render(items, { geometrySessionKey: "other" }); |
| 54 | assert.equal(harness.container.querySelector('[role="dialog"]'), null, "session replacement closes details"); |
| 55 | |
| 56 | const restored: Item[] = []; |
| 57 | for (let turn = 1; turn <= 12; turn += 1) { |
| 58 | restored.push( |
| 59 | { kind: "user", id: `u${turn}`, text: `prompt ${turn}`, checkpointTurn: turn }, |
| 60 | { kind: "assistant", id: `a${turn}`, text: `answer ${turn}`, reasoning: "", streaming: false }, |
| 61 | ); |
| 62 | } |
| 63 | await harness.render(restored, { geometrySessionKey: "history-identity" }); |
| 64 | await harness.settle(); |
| 65 | const existing = new Map(Array.from(harness.container.querySelectorAll<HTMLElement>("[data-chat-anchor-key]")) |
| 66 | .map(node => [node.dataset.chatAnchorKey!, node])); |
| 67 | assert.equal(existing.size, 60, "fixture spans multiple legacy 24-node chunks"); |
| 68 | const selectedParagraph = harness.container.querySelector<HTMLElement>('[data-chat-anchor-key="a6"] .md p')!; |
| 69 | const selectedText = selectedParagraph.firstChild!; |
| 70 | const range = harness.dom.window.document.createRange(); |
| 71 | range.selectNodeContents(selectedText); |
| 72 | const selection = harness.dom.window.getSelection()!; |
| 73 | selection.removeAllRanges(); |
| 74 | selection.addRange(range); |
| 75 | const selectedBefore = selection.toString(); |
| 76 | await harness.render([ |
| 77 | { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 }, |
| 78 | { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false }, |
| 79 | ...restored, |
| 80 | ], { geometrySessionKey: "history-identity" }); |
| 81 | await harness.settle(); |
| 82 | for (const [key, node] of existing) { |
| 83 | assert.equal(harness.container.querySelector(`[data-chat-anchor-key="${key}"]`), node, `history prepend preserves ${key} DOM identity`); |
| 84 | } |
| 85 | assert.equal(selection.toString(), selectedBefore, "history prepend preserves the native text selection"); |
| 86 | |
| 87 | const deepHistory: Item[] = []; |
| 88 | for (let turn = 1; turn <= 15; turn += 1) { |
| 89 | deepHistory.push( |
| 90 | { kind: "user", id: `old-u${turn}`, text: `old prompt ${turn}`, checkpointTurn: -turn }, |
| 91 | { kind: "assistant", id: `old-a${turn}`, text: `old answer ${turn}`, reasoning: "", streaming: false }, |
| 92 | ); |
| 93 | } |
| 94 | await harness.render([ |
| 95 | ...deepHistory, |
| 96 | { kind: "user", id: "u0", text: "older prompt", checkpointTurn: 0 }, |
| 97 | { kind: "assistant", id: "a0", text: "older answer", reasoning: "", streaming: false }, |
| 98 | ...restored, |
| 99 | ], { geometrySessionKey: "history-identity" }); |
| 100 | assert.equal(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), null, "deep history is still mounting after two bounded frames"); |
| 101 | assert.equal(harness.container.querySelector('[data-nav-turn="old-u1"]'), null, "navigation omits history without a mounted target"); |
| 102 | await harness.settle(); |
| 103 | assert.ok(harness.container.querySelector('[data-chat-anchor-key="old-u1"]'), "deep history eventually mounts"); |
| 104 | assert.ok(harness.container.querySelector('[data-nav-turn="old-u1"]'), "navigation publishes the target after its DOM commit"); |
| 105 | let newerLoads = 0; |
| 106 | await harness.render(restored, { |
| 107 | geometrySessionKey: "history-identity", |
| 108 | hasNewerHistory: true, |
| 109 | historyStartTurn: 4, |
| 110 | historyEndTurn: 12, |
| 111 | totalTurns: 20, |
| 112 | onLoadNewerHistory: async () => { newerLoads += 1; return "loaded"; }, |
| 113 | }); |
| 114 | const newer = harness.container.querySelector<HTMLButtonElement>(".chat-history-newer .btn")!; |
| 115 | await act(async () => newer.click()); |
| 116 | assert.equal(newerLoads, 0, "a native transcript selection protects its resident page from reclaim"); |
| 117 | assert.ok(harness.container.querySelector(".chat-history-selection"), "selection protection explains why paging paused"); |
| 118 | await act(async () => { |
| 119 | selection.removeAllRanges(); |
| 120 | harness.dom.window.document.dispatchEvent(new harness.dom.window.Event("selectionchange")); |
| 121 | }); |
| 122 | await act(async () => newer.click()); |
| 123 | assert.equal(newerLoads, 1, "newer paging resumes after the selection is cleared"); |
| 124 | assert.match(harness.container.querySelector(".chat-history-window")?.textContent ?? "", /5.*12.*20/, "the bounded window reports its visible turn range"); |
| 125 | console.log("chat natural flow: process disclosure, details, stable history identity, mounted navigation and session isolation passed"); |
| 126 | } finally { await harness.unmount(); await harness.close(); } |
| 127 |