Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
CI / check (22) (push) Successful in 9m34s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 00:32:18 +02:00
parent 1c0f06716e
commit 387c0ac2e7
40 changed files with 2374 additions and 1314 deletions
+25 -1
View File
@@ -1,6 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
import {ApiError, get, post, stream} from "../public/js/api.js";
import {ApiError, get, post, stream, multipart, download} from "../public/js/api.js";
test("multipart and downloads share HTTP checks, cancellation and body deadlines", async t => {
const form = new FormData(); form.append("image", new Blob(["image"]), "image.png");
t.mock.method(globalThis, "fetch", async (_url, options) => {
assert.equal(options.body, form);
assert.equal(options.headers["Content-Type"], undefined);
return Response.json({id: "uploaded"});
});
assert.deepEqual(await multipart("/uploads", form), {id: "uploaded"});
t.mock.method(globalThis, "fetch", async () => Response.json({error: "Please log in", code: "LOGIN_REQUIRED"}, {status: 403}));
const failed = await download("/export");
assert.equal(failed.code, "LOGIN_REQUIRED");
assert.equal(failed.blob, undefined);
t.mock.method(globalThis, "fetch", async () => new Response("download content"));
assert.equal(await (await download("/export")).blob.text(), "download content");
t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream()));
assert.equal((await download("/stalled", {timeoutMs: 10})).code, "TIMEOUT");
t.mock.method(globalThis, "fetch", async () => new Promise(() => {}));
const controller = new AbortController();
const pending = multipart("/upload", form, {signal: controller.signal});
controller.abort();
assert.equal((await pending).code, "ABORTED");
assert.equal((await multipart("/upload", form, {timeoutMs: 10})).code, "TIMEOUT");
});
test("JSON requests retain HTTP metadata, handle empty success, and distinguish bad bodies from network errors", async t => {
let response;
+96
View File
@@ -116,6 +116,102 @@ async function request(baseUrl, path, {method = "GET", body, jar} = {}) {
return {status: response.status, json};
}
test("public health checks reach the backend without exposing diagnostics or requiring a session", async t => {
const server = await startTestServer();
t.after(() => server.close());
let checks = 0;
server.app.locals.modelService.checkHealth = async () => { checks++; return {ok: checks === 1, backendUrl: "private-backend", error: "private detail"}; };
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 200, json: {ok: true}});
assert.deepEqual(await request(server.baseUrl, "/health"), {status: 503, json: {ok: false}});
assert.equal(checks, 2);
assert.equal((await request(server.baseUrl, "/admin/diagnostics")).status, 403);
});
test("new passwords are limited to 72 UTF-8 bytes while legacy credentials remain usable", async t => {
const server = await startTestServer({config: {authRateLimitMax: 100}});
t.after(() => server.close());
for (const password of ["a".repeat(73), "é".repeat(37), "😀".repeat(19)]) {
const result = await request(server.baseUrl, "/register", {method: "POST", body: {username: "too-long", password}});
assert.equal(result.status, 400);
assert.equal(result.json.code, "PASSWORD_TOO_LONG");
assert.equal(server.app.locals.repositories.getUser("too-long"), undefined);
}
const jar = new CookieJar();
const boundary = "é".repeat(36);
assert.equal((await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "boundary", password: boundary}})).status, 200);
const change = newPassword => request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: boundary, newPassword}});
assert.equal((await change("a".repeat(73))).json.code, "PASSWORD_TOO_LONG");
assert.equal((await change("😀".repeat(19))).json.code, "PASSWORD_TOO_LONG");
assert.equal((await change("a".repeat(72))).status, 200);
const legacy = "a".repeat(72) + "existing suffix";
server.app.locals.repositories.insertUser("legacy", await bcrypt.hash(legacy, 4));
assert.equal((await request(server.baseUrl, "/login", {method: "POST", jar, body: {username: "legacy", password: legacy}})).status, 200);
assert.equal((await request(server.baseUrl, "/settings/password", {method: "PUT", jar, body: {currentPassword: legacy, newPassword: "new-password"}})).status, 200);
});
test("history pages are bounded, preserve branch identity and batch message metadata", async t => {
const server = await startTestServer(); t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "paged", password: "password123"}});
const repo = server.app.locals.repositories;
const chat = repo.createChat("paged", "Long conversation");
let parent = null;
const ids = [];
for (let index = 0; index < 125; index++) {
parent = Number(repo.insertBranchChatMessage(chat.id, index % 2 ? "bot" : "user", `Message ${index}`, null, "test", "complete", null, parent).lastInsertRowid);
ids.push(parent);
}
repo.saveMessageVersion(chat.id, ids[123], {content: "Alternate answer", modelName: "other-model"});
const sibling = Number(repo.insertBranchChatMessage(chat.id, "bot", "Other branch", null, "test", "complete", null, ids[122]).lastInsertRowid);
repo.updateChatActiveLeaf(chat.id, "paged", parent);
t.mock.method(repo, "listMessageVersions", () => assert.fail("History should batch versions"));
t.mock.method(repo, "listSiblingChatMessages", () => assert.fail("History should batch siblings"));
t.mock.method(server.app.locals.attachmentService, "list", () => assert.fail("History should batch attachments"));
const first = await request(server.baseUrl, `/chats/${chat.id}/messages`, {jar});
assert.equal(first.status, 200); assert.equal(first.json.messages.length, 50);
assert.equal(first.json.messages[0].id, ids[75]);
const answer = first.json.messages.find(message => message.id === ids[123]);
assert.equal(answer.versionCount, 2); assert.equal(answer.nextSiblingId, sibling);
const page = first.json.pagination;
const second = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar});
assert.deepEqual(second.json.messages.map(message => message.id), ids.slice(25, 75));
const third = await request(server.baseUrl, `/chats/${chat.id}/messages?before=${second.json.pagination.before}&leaf=${page.leafId}`, {jar});
assert.deepEqual(third.json.messages.map(message => message.id), ids.slice(0, 25));
assert.equal(third.json.pagination.hasMore, false);
const oldMatch = await request(server.baseUrl, `/chats/${chat.id}/messages?around=${ids[4]}`, {jar});
assert.equal(oldMatch.json.matchOnBranch, true); assert.equal(oldMatch.json.pagination.hasNewer, true);
assert.equal(oldMatch.json.messages.at(-1).id, ids[4]);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${sibling}`, {jar})).status, 400);
repo.updateChatActiveLeaf(chat.id, "paged", sibling);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?before=${page.before}&leaf=${page.leafId}`, {jar})).status, 409);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/messages?limit=101`, {jar})).status, 400);
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/branches`, {jar})).json.nodes.length, 126);
const stranger = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar: stranger, body: {username: "stranger", password: "password123"}});
for (const suffix of ["messages", "branches", `messages/${ids[123]}/versions`, "memories"]) {
assert.equal((await request(server.baseUrl, `/chats/${chat.id}/${suffix}`, {jar: stranger})).status, 404);
}
});
test("conversation memory exposes context estimates and supports validated summary corrections", async t => {
const server = await startTestServer(); t.after(() => server.close());
const jar = new CookieJar();
await request(server.baseUrl, "/register", {method: "POST", jar, body: {username: "memory-editor", password: "password123"}});
const repo = server.app.locals.repositories;
const chat = repo.createChat("memory-editor", "Memory");
repo.createChatMemory("memory-editor", chat, "Prefers concise answers.");
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", 0);
const loaded = (await request(server.baseUrl, `/chats/${chat.id}/memories`, {jar})).json;
assert.equal(loaded.summary, "Old summary"); assert.ok(loaded.context.estimatedTokens > 0);
const update = body => request(server.baseUrl, `/chats/${chat.id}/summary`, {method: "PUT", jar, body});
assert.equal((await update({summary: "Corrected summary", previousSummary: "Old summary"})).status, 200);
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, "Corrected summary");
assert.equal((await update({summary: "Stale edit", previousSummary: "Old summary"})).status, 409);
assert.equal((await update({summary: "x".repeat(loaded.summaryMaxChars + 1)})).status, 400);
assert.equal((await update({summary: "", previousSummary: "Corrected summary"})).status, 200);
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, null);
});
test("auth flow validates inputs and persists session", async () => {
const server = await startTestServer();
const jar = new CookieJar();
+29
View File
@@ -4,6 +4,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { appendAttachments, createAttachmentComposer } from "../public/js/attachments.js";
import {createDraftStore} from "../public/js/draftStore.js";
class Element {
constructor() { this.children = []; this.listeners = {}; this.classList = { add() { }, remove() { } }; }
@@ -13,6 +14,34 @@ class Element {
addEventListener(name, listener) { this.listeners[name] = listener; }
click() { this.clicked = true; }
}
test("uploaded draft references recover after reload and unavailable images cannot be sent", async t => {
const elements = Object.fromEntries(["attachImage", "imagePicker", "uploadPreviews", "composer"].map(id => [id, new Element()]));
const originalDocument = globalThis.document;
t.after(() => { globalThis.document = originalDocument; });
globalThis.document = {getElementById: id => elements[id], querySelector: () => elements.composer, createElement: () => new Element()};
t.mock.method(globalThis, "fetch", async () => Response.json({maxFiles: 5, maxSizeMb: 10}));
const values = new Map();
const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)};
const saved = createDraftStore("alice", {storage});
const metadata = {id: "12345678-1234-1234-1234-123456789abc", originalName: "draft.png"};
saved.setAttachments(7, [metadata]);
const composer = createAttachmentComposer({onChange() {}, onError: message => assert.fail(message)});
composer.setDraftStore(createDraftStore("alice", {storage}));
composer.restoreDraft(7);
assert.equal(elements.uploadPreviews.children[0].children[0].src, `/attachments/${metadata.id}`);
assert.equal(composer.pending, true);
elements.uploadPreviews.children[0].children[0].onload();
assert.equal(composer.pending, false);
assert.deepEqual(composer.attachments, [metadata]);
elements.uploadPreviews.children[0].children[0].onerror();
assert.equal(composer.pending, true);
assert.deepEqual(composer.attachments, []);
assert.deepEqual(createDraftStore("alice", {storage}).attachments(7), [metadata]);
elements.uploadPreviews.children[0].children.at(-1).onclick();
assert.equal(composer.pending, false);
assert.deepEqual(createDraftStore("alice", {storage}).attachments(7), []);
});
test("composer preview, pending state, upload failure/retry, removal, paste, and history rendering", async t => {
const originalDocument = globalThis.document, originalFetch = globalThis.fetch;
t.after(() => { globalThis.document = originalDocument; globalThis.fetch = originalFetch; });
+26 -1
View File
@@ -24,7 +24,7 @@ function fixture(overrides = {}) {
getChatById: id => state.chatSessions.find(chat => chat.id === id),
updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: noop,
addMessage: () => ({isConnected: true, querySelector: () => element(), remove: noop, replaceWith: noop}),
setMessageContent: noop, setActiveChat: async id => calls.refresh.push(id), loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop,
setMessageContent: noop, setActiveChat: async id => { calls.refresh.push(id); return overrides.refreshResult; }, loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop,
api: {put: async () => overrides.rename ? await overrides.rename : {}, post: async () => assert.fail("Send cancellation must abort its own request"),
stream: async (_url, payload, handlers) => {
calls.streams.push(payload);
@@ -84,3 +84,28 @@ test("Stop during chat creation prevents a later stream and releases the send lo
assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.value, "Hello");
});
test("a completed reply and draft survive a failed history refresh without a false success notice", async () => {
const s = fixture({refreshResult: {error: "History unavailable", status: 503}});
await s.sendMessage();
assert.equal(s.state.currentMessages.at(-1).content, "Done");
assert.equal(s.state.currentMessages.filter(message => message.role === "bot").length, 1);
assert.equal(s.elements.msgInput.value, "Hello");
assert.match(s.calls.notices.at(-1), /could not be refreshed: History unavailable/);
assert.ok(!s.calls.notices.includes("Reply received."));
assert.equal(s.state.isProcessing, false);
});
test("navigation during refresh clears only the sent chat's saved draft", async () => {
const refresh = deferred();
const s = fixture({refreshResult: refresh.promise});
const sending = s.sendMessage();
await new Promise(resolve => setImmediate(resolve));
s.state.chatDrafts.set(1, "Hello");
s.state.activeChatId = 2;
s.elements.msgInput.value = "Other chat draft";
refresh.resolve({ok: true});
await sending;
assert.equal(s.state.chatDrafts.has(1), false);
assert.equal(s.elements.msgInput.value, "Other chat draft");
});
+33
View File
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createDraftStore} from "../public/js/draftStore.js";
test("drafts recover text and uploaded references per account and conversation", () => {
const values = new Map();
const storage = {getItem: key => values.get(key), setItem: (key, value) => values.set(key, value)};
const alice = createDraftStore("alice", {storage});
const metadata = {id: "12345678-1234-1234-1234-123456789abc", originalName: "image.png"};
alice.textDrafts.set(1, "Unsent text"); alice.textDrafts.set("new", "New conversation draft");
alice.setAttachments(1, [metadata]);
const recovered = createDraftStore("alice", {storage});
assert.equal(recovered.textDrafts.get(1), "Unsent text");
assert.equal(recovered.textDrafts.get("new"), "New conversation draft");
assert.deepEqual(recovered.attachments(1), [metadata]);
assert.equal(createDraftStore("bob", {storage}).textDrafts.size, 0);
assert.deepEqual(createDraftStore("bob", {storage}).attachments(1), []);
recovered.textDrafts.delete(1);
assert.deepEqual(createDraftStore("alice", {storage}).attachments(1), [metadata]);
recovered.setAttachments(1, []);
const sent = createDraftStore("alice", {storage});
assert.equal(sent.textDrafts.has(1), false);
assert.deepEqual(sent.attachments(1), []);
assert.equal(sent.textDrafts.get("new"), "New conversation draft");
});
test("unavailable storage reports recovery failure once while keeping drafts usable", () => {
const notices = [];
const drafts = createDraftStore("alice", {storage: {getItem() { throw new Error("Blocked"); }, setItem() { throw new Error("Full"); }}, onError: message => notices.push(message)});
drafts.textDrafts.set(1, "Still here"); drafts.setAttachments(1, []);
assert.equal(drafts.textDrafts.get(1), "Still here");
assert.equal(notices.length, 1);
});
+1 -1
View File
@@ -96,7 +96,7 @@ test("inline renderer streams in place, follows only nearby output, commits or r
});
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 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/);
+17
View File
@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createWorkspaceTransfer} from "../public/js/workspaceTransfer.js";
test("export cancellation makes no request and failed downloads report structured server errors", async t => {
let requests = 0, format = null;
t.mock.method(globalThis, "fetch", async () => { requests++; return Response.json({error: "Session expired", code: "LOGIN_REQUIRED"}, {status: 403}); });
const notices = [];
const transfer = createWorkspaceTransfer({conversation: {activeChatId: 1, currentMessages: [{}]}, getChatById: () => ({id: 1, title: "Chat"}),
setNotice: (text, kind) => notices.push({text, kind}), promptPopup: async () => format});
await transfer.exportCurrentChat(); assert.equal(requests, 0);
format = "md";
await transfer.exportCurrentChat();
await transfer.backupWorkspace();
assert.equal(requests, 2);
assert.deepEqual(notices, [{text: "Session expired", kind: "error"}, {text: "Session expired", kind: "error"}]);
});