Files
2026-10-06 00:32:18 +02:00

198 lines
10 KiB
JavaScript

/** 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(); }
});
}