import test from "node:test"; import assert from "node:assert/strict"; import {readFile} from "node:fs/promises"; import {regenerateInline} from "../public/js/regeneration.js"; class Element { constructor(tag = "div") { this.tag = tag; this.children = []; this.style = {}; this.dataset = {}; this.attributes = {}; this.scrollTop = 0; this.bottom = 500; this.height = 200; this.top = 0; } get childNodes() { return this.children; } get childElementCount() { return this.children.length; } append(...children) { children.forEach(child => { child.parent = this; this.children.push(child); }); } appendChild(child) { this.append(child); } replaceChildren(...children) { this.children = []; this.append(...children); } insertBefore(child, before) { child.parent = this; this.children.splice(this.children.indexOf(before), 0, child); } remove() { this.parent.children = this.parent.children.filter(child => child !== this); } cloneNode() { const clone = new Element(this.tag); clone.textContent = this.textContent; return clone; } setAttribute(key, value) { this.attributes[key] = value; } removeAttribute(key) { delete this.attributes[key]; } getBoundingClientRect() { return {top: this.top, bottom: this.bottom, height: this.height}; } querySelector(selector) { for (const child of this.children) { if (selector === child.tag || child.className?.split(" ").some(name => selector === `.${name}`)) return child; const nested = child.querySelector(selector); if (nested) return nested; } return null; } } test("inline renderer streams in place, follows only nearby output, commits or restores all original nodes", async t => { const oldDocument = globalThis.document, oldFetch = globalThis.fetch; t.after(() => { globalThis.document = oldDocument; globalThis.fetch = oldFetch; }); globalThis.document = {createElement: tag => new Element(tag)}; for (const outcome of ["success", "error", "disconnect"]) { const scroller = new Element(), card = new Element(), later = new Element(); const header = new Element(), oldBody = new Element(), actions = new Element(), oldSources = new Element(); oldBody.textContent = "Old answer"; oldBody.className = "msg-content"; oldSources.className = "msg-sources"; card.append(header, oldBody, oldSources, actions); scroller.append(card, later); const original = [...card.childNodes]; let controller; globalThis.fetch = async () => new Response(new ReadableStream({start(value) { controller = value; }})); let completed; const pending = regenerateInline({card, scroller, endpoint: "/retry/stream", payload: {}, renderContent: (body, content) => { body.textContent = content; card.bottom += 20; }, complete: message => { completed = message; card.replaceChildren(header, new Element(), actions); }}); const event = async (name, data) => { controller.enqueue(new TextEncoder().encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)); await new Promise(resolve => setTimeout(resolve, 0)); }; assert.equal(card.attributes["aria-busy"], "true"); assert.equal(card.querySelector(".msg-sources"), null); assert.ok(!card.childNodes.includes(actions)); await event("status", {status: "searching"}); assert.equal(card.querySelector(".loading-shell").textContent, "Searching the web..."); await event("status", {status: "generating", sources: [{title: "New source", url: "https://example.org/new"}]}); assert.notEqual(card.querySelector(".msg-sources"), oldSources); await event("thinking", {fullThinking: "New reasoning"}); assert.equal(card.querySelector(".msg-reasoning-content").textContent, "New reasoning"); await event("chunk", {fullReply: "Partial"}); assert.equal(card.querySelector(".msg-content").textContent, "Partial"); assert.equal(scroller.scrollTop, 20); assert.equal(completed, undefined); assert.deepEqual(scroller.childNodes, [card, later]); card.bottom = 1000; // User has scrolled away from this response. await event("chunk", {fullReply: "Partial answer"}); assert.equal(scroller.scrollTop, 20); if (outcome === "success") { await event("done", {reply: "Partial answer", message: {id: 2, content: "Partial answer"}}); await pending; assert.equal(completed.content, "Partial answer"); assert.notDeepEqual(card.childNodes, original); } else { const rejection = assert.rejects(pending, outcome === "error" ? /Model failed/ : /interrupted/); if (outcome === "error") await event("error", {error: "Model failed"}); else controller.close(); await rejection; assert.deepEqual(card.childNodes, original); } assert.equal(card.attributes["aria-busy"], undefined); assert.equal(card.style.minHeight, undefined); assert.deepEqual(scroller.childNodes, [card, later]); } }); test("Regenerate uses inline activity and never reloads or appends a chat message", async () => { const source = await readFile(new URL("../public/js/script.js", import.meta.url), "utf8"); const regenerate = source.slice(source.indexOf("async function retryLatestAssistantMessage"), source.indexOf("async function switchResponseVersion")); assert.match(regenerate, /setLoadingState\(true, false\)/); assert.match(regenerate, /regenerateInline/); assert.match(regenerate, /detached: true/); assert.doesNotMatch(regenerate, /setActiveChat\(|renderMessages\(|Generating another version/); });