106 lines
6.3 KiB
JavaScript
106 lines
6.3 KiB
JavaScript
/**
|
|
* @file Prüft die Neugenerierung innerhalb einer Nachrichtenkarte mit Scrollverhalten und Wiederherstellung.
|
|
*/
|
|
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)};
|
|
const frames = new Map();
|
|
let frameId = 0;
|
|
const oldRequestFrame = globalThis.requestAnimationFrame, oldCancelFrame = globalThis.cancelAnimationFrame;
|
|
globalThis.requestAnimationFrame = callback => { frames.set(++frameId, callback); return frameId; };
|
|
globalThis.cancelAnimationFrame = id => frames.delete(id);
|
|
t.after(() => { globalThis.requestAnimationFrame = oldRequestFrame; globalThis.cancelAnimationFrame = oldCancelFrame; });
|
|
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));
|
|
for (const callback of [...frames.values()]) callback();
|
|
};
|
|
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", {chunk: "New reasoning"});
|
|
assert.equal(card.querySelector(".msg-reasoning-content").textContent, "New reasoning");
|
|
await event("chunk", {chunk: "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", {chunk: " 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/messageActions.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/);
|
|
});
|