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