112 lines
6.0 KiB
JavaScript
112 lines
6.0 KiB
JavaScript
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");
|
|
});
|