Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<title>Conversation navigation regression tests</title>
|
||||
<h1>Conversation navigation regression tests</h1>
|
||||
<div id="testTitle"></div>
|
||||
<input id="testModel" aria-label="Model">
|
||||
<input id="chatSearchInput" aria-label="Search">
|
||||
<textarea id="msgInput" aria-label="Draft"></textarea>
|
||||
<div id="messages"></div>
|
||||
<pre id="results">Running...</pre>
|
||||
<script type="module">
|
||||
import {registerConversationTests} from "/browser-tests/conversationCases.js";
|
||||
const cases = [], results = [];
|
||||
const assert = {
|
||||
equal(actual, expected) { if (!Object.is(actual, expected)) throw new Error(`Expected ${expected}, received ${actual}`); },
|
||||
deepEqual(actual, expected) { this.equal(JSON.stringify(actual), JSON.stringify(expected)); }
|
||||
};
|
||||
registerConversationTests((name, run) => cases.push({name, run}), assert);
|
||||
for (const {name, run} of cases) {
|
||||
try { await run(); results.push(`PASS ${name}`); }
|
||||
catch (error) { results.push(`FAIL ${name}: ${error.stack}`); }
|
||||
}
|
||||
document.getElementById("results").textContent = results.join("\n");
|
||||
document.title = `${results.filter(result => result.startsWith("PASS")).length}/${cases.length} navigation tests passed`;
|
||||
</script>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
/** The same navigation regressions run in a real browser and Node's lightweight DOM fixture. */
|
||||
import {createConversationController} from "../public/js/conversationController.js";
|
||||
import {createConversationState} from "../public/js/conversationState.js";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function fixture() {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const pending = [];
|
||||
globalThis.fetch = (url, options) => new Promise(resolve => pending.push({url, options, resolve}));
|
||||
const state = createConversationState();
|
||||
state.chatSessions = [1, 2, 3].map(id => ({id, title: `Chat ${id}`}));
|
||||
state.activeChatId = 1;
|
||||
state.currentMessages = [{id: 11, content: "Chat A's answer"}];
|
||||
state.messageRetryState.set(11, {retryActiveIndex: 1});
|
||||
state.chatDrafts.set(2, "Draft B");
|
||||
state.chatDrafts.set(3, "Draft C");
|
||||
history.replaceState(null, "", "/app/chats/1");
|
||||
const input = document.getElementById("msgInput");
|
||||
const messages = document.getElementById("messages");
|
||||
const title = document.getElementById("testTitle");
|
||||
const model = document.getElementById("testModel");
|
||||
input.value = "Draft A";
|
||||
messages.textContent = "Chat A's answer";
|
||||
title.textContent = "Chat 1";
|
||||
model.value = "Model A";
|
||||
const attachmentDrafts = new Map([[2, ["Image B"]], [3, ["Image C"]]]);
|
||||
const attachments = {pending: false, attachments: ["Image A"],
|
||||
saveDraft(id) { attachmentDrafts.set(id, this.attachments); },
|
||||
restoreDraft(id) { this.attachments = attachmentDrafts.get(id) || []; }};
|
||||
const ui = {loading: false, activity: false, notices: [], renders: []};
|
||||
const controller = createConversationController({state, attachmentComposer: attachments,
|
||||
renderChatList: noop, resizeComposerInput: noop, isMobileLayout: () => false, closeMobileSidebar: noop,
|
||||
setChatLoading: value => { ui.loading = value; }, setChatActivity: value => { ui.activity = value; },
|
||||
updateWorkspaceCopy: () => { title.textContent = `Chat ${state.activeChatId}`; }, updateChatActionState: noop,
|
||||
setNotice: message => ui.notices.push(message),
|
||||
populateGenerationSettings: chat => { model.value = chat.preferred_model; },
|
||||
renderMessages: () => { messages.textContent = state.currentMessages.map(message => message.content).join("\n"); ui.renders.push(state.activeChatId); },
|
||||
updateJumpToLatest: noop, updateSendState: noop, loadSummary: noop});
|
||||
const reply = async (index, body, status = 200) => {
|
||||
const response = new Response(JSON.stringify(body), {status, headers: {"Content-Type": "application/json"}});
|
||||
// Avoid browser Blob-reading tasks: this fixture controls navigation order,
|
||||
// while api.test.js covers actual response-body reads and their deadlines.
|
||||
response.text = async () => JSON.stringify(body);
|
||||
pending[index].resolve(response);
|
||||
// Fetch, response body parsing and the controller each await a promise.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
const result = id => ({chat: {id, preferred_model: `Model ${id}`}, messages: [{id: id * 10 + 1, content: `Answer ${id}`}]});
|
||||
return {state, ui, input, messages, title, model, attachments, attachmentDrafts, pending, reply, result, ...controller,
|
||||
cleanup: () => { globalThis.fetch = originalFetch; }};
|
||||
}
|
||||
|
||||
export function registerConversationTests(test, assert) {
|
||||
const unchanged = s => {
|
||||
assert.equal(s.state.activeChatId, 1);
|
||||
assert.equal(location.pathname, "/app/chats/1");
|
||||
assert.equal(s.messages.textContent, "Chat A's answer");
|
||||
assert.equal(s.title.textContent, "Chat 1");
|
||||
assert.equal(s.model.value, "Model A");
|
||||
assert.equal(s.input.value, "Draft A");
|
||||
assert.deepEqual(s.attachments.attachments, ["Image A"]);
|
||||
assert.equal(s.state.messageRetryState.get(11).retryActiveIndex, 1);
|
||||
};
|
||||
test("failed navigation preserves chat identity, URL, messages, settings and both drafts", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const opening = s.setActiveChat(2);
|
||||
unchanged(s);
|
||||
await s.reply(0, {error: "Chat B failed to load"}, 503);
|
||||
const result = await opening;
|
||||
assert.equal(result.error, "Chat B failed to load");
|
||||
assert.equal(result.status, 503);
|
||||
unchanged(s);
|
||||
assert.equal(s.state.chatDrafts.get(2), "Draft B");
|
||||
assert.deepEqual(s.attachmentDrafts.get(2), ["Image B"]);
|
||||
assert.deepEqual(s.ui.notices, ["Chat B failed to load"]);
|
||||
assert.equal(s.ui.loading, false);
|
||||
assert.equal(s.ui.activity, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
for (const order of ["older-first", "newer-first"]) {
|
||||
for (const staleFails of [false, true]) {
|
||||
test(`overlapping navigation (${order}, stale ${staleFails ? "failure" : "success"}) commits only the latest chat`, async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
|
||||
unchanged(s);
|
||||
const replyOlder = () => s.reply(0, staleFails ? {error: "Stale error"} : s.result(2), staleFails ? 500 : 200);
|
||||
if (order === "older-first") {
|
||||
await replyOlder(); await older;
|
||||
unchanged(s);
|
||||
assert.equal(s.ui.loading, true);
|
||||
assert.equal(s.ui.activity, true);
|
||||
// Edits made while loading belong to the still-visible chat A.
|
||||
s.input.value = "Draft A updated";
|
||||
await s.reply(1, s.result(3)); await newer;
|
||||
} else {
|
||||
await s.reply(1, s.result(3)); await newer;
|
||||
await replyOlder(); await older;
|
||||
}
|
||||
assert.equal(s.state.activeChatId, 3);
|
||||
assert.equal(location.pathname, "/app/chats/3");
|
||||
assert.equal(s.messages.textContent, "Answer 3");
|
||||
assert.equal(s.title.textContent, "Chat 3");
|
||||
assert.equal(s.model.value, "Model 3");
|
||||
assert.equal(s.input.value, "Draft C");
|
||||
assert.equal(s.state.chatDrafts.get(1), order === "older-first" ? "Draft A updated" : "Draft A");
|
||||
assert.equal(s.state.chatDrafts.get(2), "Draft B");
|
||||
assert.deepEqual(s.attachments.attachments, ["Image C"]);
|
||||
assert.deepEqual(s.attachmentDrafts.get(1), ["Image A"]);
|
||||
assert.deepEqual(s.ui.renders, [3]);
|
||||
assert.deepEqual(s.ui.notices, []);
|
||||
assert.equal(s.state.messageRetryState.size, 0);
|
||||
assert.equal(s.ui.loading, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("a late success cannot replace A after the newer navigation fails", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const older = s.setActiveChat(2), newer = s.setActiveChat(3);
|
||||
await s.reply(1, {error: "Chat C failed"}, 500); await newer;
|
||||
await s.reply(0, s.result(2)); await older;
|
||||
unchanged(s);
|
||||
assert.deepEqual(s.ui.notices, ["Chat C failed"]);
|
||||
assert.equal(s.ui.loading, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
for (const failure of ["activation", "reload"]) {
|
||||
test(`search-result branch ${failure} failure preserves the displayed chat`, async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const opening = s.setActiveChat(2, {matchMessageId: 25});
|
||||
await s.reply(0, s.result(2));
|
||||
assert.equal(s.pending[1].url, "/chats/2/branches/25/activate");
|
||||
await s.reply(1, failure === "activation" ? {error: "Branch failed"} : {}, failure === "activation" ? 500 : 200);
|
||||
if (failure === "reload") await s.reply(2, {error: "Reload failed"}, 500);
|
||||
await opening;
|
||||
unchanged(s);
|
||||
assert.equal(s.ui.notices.length, 1);
|
||||
assert.equal(s.ui.loading, false);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("a stale branch activation cannot reload or clear a newer navigation's activity", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
const older = s.setActiveChat(2, {matchMessageId: 25});
|
||||
await s.reply(0, s.result(2));
|
||||
const newer = s.setActiveChat(3);
|
||||
await s.reply(1, {}); await older;
|
||||
assert.equal(s.pending.length, 3);
|
||||
assert.equal(s.ui.loading, true);
|
||||
unchanged(s);
|
||||
await s.reply(2, s.result(3)); await newer;
|
||||
assert.equal(s.state.activeChatId, 3);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
test("older messages prepend once and a failed page keeps the current history and draft", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
|
||||
const older = s.loadOlderMessages();
|
||||
await s.loadOlderMessages();
|
||||
assert.equal(s.pending.length, 1);
|
||||
await s.reply(0, {messages: [{id: 9, content: "Older answer"}], pagination: {hasMore: true, before: 9, leafId: 11}});
|
||||
await older;
|
||||
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
|
||||
assert.equal(s.input.value, "Draft A");
|
||||
const failed = s.loadOlderMessages();
|
||||
await s.reply(1, {error: "Page failed"}, 503); await failed;
|
||||
assert.deepEqual(s.state.currentMessages.map(message => message.id), [9, 11]);
|
||||
assert.equal(s.state.historyLoading, false);
|
||||
assert.equal(s.ui.notices.at(-1), "Page failed");
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
|
||||
test("an older page arriving after navigation cannot contaminate the new conversation", async () => {
|
||||
const s = fixture();
|
||||
try {
|
||||
s.state.historyPage = {hasMore: true, before: 11, leafId: 11};
|
||||
const older = s.loadOlderMessages(), opening = s.setActiveChat(2);
|
||||
await s.reply(1, s.result(2)); await opening;
|
||||
await s.reply(0, {messages: [{id: 9, content: "Stale answer"}], pagination: {hasMore: false}}); await older;
|
||||
assert.deepEqual(s.state.currentMessages.map(message => message.id), [21]);
|
||||
assert.equal(s.state.historyLoading, false);
|
||||
assert.equal(s.state.activeChatId, 2);
|
||||
} finally { s.cleanup(); }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user