import test from "node:test"; import assert from "node:assert/strict"; import {createComposerController} from "../public/js/composerController.js"; import {createConversationState} from "../public/js/conversationState.js"; const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; }; const noop = () => {}; const element = () => ({value: "Hello", style: {}, scrollHeight: 0, scrollTop: 0, clientHeight: 0, classList: {toggle: noop, add: noop}, setAttribute: noop, focus: noop, querySelectorAll: () => []}); function fixture(overrides = {}) { const state = createConversationState(); const elements = {msgInput: element(), sendBtn: element(), messagesDiv: element(), modelSelect: {value: "original-model"}}; const calls = {create: 0, streams: [], refresh: [], notices: []}; const controller = createComposerController({state, elements, attachmentComposer: {pending: false, attachments: [], setBusy: noop, clear: noop, clearSentDraft: noop}, webSearchButton: element(), reasoningToggle: {enabled: true, setBusy: noop, reset: noop}, webSearchToggle: {enabled: true, reset: noop}, setChatActivity: noop, updateComposerPlaceholder: noop, setNotice: message => calls.notices.push(message), createNewChat: async () => { calls.create++; const chat = overrides.creation ? await overrides.creation : {id: 1, title: "New chat"}; if (chat?.id) { state.activeChatId = chat.id; state.chatSessions.push(chat); } return chat; }, 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); 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); if (overrides.failure) throw new Error(overrides.failure); handlers.onDone({reply: "Done"}); }} }); return {state, elements, calls, ...controller}; } test("rapid sends lock before chat creation and keep the captured chat/model after navigation during rename", async () => { const creation = deferred(), rename = deferred(); const s = fixture({creation: creation.promise, rename: rename.promise}); const first = s.sendMessage(); await s.sendMessage(); assert.equal(s.state.isProcessing, true); assert.equal(s.calls.create, 1); creation.resolve({id: 7, title: "New chat"}); await new Promise(resolve => setImmediate(resolve)); await s.sendMessage(); s.state.activeChatId = 8; s.elements.modelSelect.value = "changed-model"; s.elements.msgInput.value = "Draft in another conversation"; rename.resolve({}); await first; assert.equal(s.calls.streams.length, 1); assert.equal(s.calls.streams[0].chatId, 7); assert.equal(s.calls.streams[0].model, "original-model"); assert.equal(s.calls.streams[0].reasoning, true); assert.deepEqual(s.calls.refresh, []); assert.deepEqual(s.state.currentMessages, []); assert.equal(s.elements.msgInput.value, "Draft in another conversation"); assert.equal(s.state.isProcessing, false); }); test("creation, rename and stream failures unlock sending and retain the draft", async () => { for (const options of [{creation: Promise.resolve({error: "Creation failed"})}, {rename: Promise.resolve({error: "Rename failed"})}, {failure: "Stream failed"}]) { const s = fixture(options); await s.sendMessage(); assert.equal(s.state.isProcessing, false); assert.equal(s.elements.msgInput.disabled, false); assert.equal(s.elements.msgInput.value, "Hello"); assert.match(s.calls.notices.at(-1), /failed/); if (options.failure) assert.equal(s.state.currentMessages[0].deliveryStatus, "failed"); else assert.equal(s.calls.streams.length, 0); } }); test("Stop during chat creation prevents a later stream and releases the send lock", async () => { const creation = deferred(); const s = fixture({creation: creation.promise}); const pending = s.sendMessage(); await s.stopGeneration(); creation.resolve({id: 4, title: "New chat"}); await pending; assert.equal(s.calls.streams.length, 0); 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"); });