mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-14 20:28:51 +00:00
fix (windowed payload): Restrict frontend windowed payload contract to a single flight
This commit is contained in:
parent
e459e827cb
commit
f786d68c49
8 changed files with 687 additions and 131 deletions
242
src/script.js
242
src/script.js
|
|
@ -29,6 +29,8 @@ import {
|
|||
clearWindowedChatState,
|
||||
DEFAULT_CHAT_WINDOW_LINES,
|
||||
getWindowedChatState,
|
||||
getWindowedChatKey,
|
||||
mergeWindowedChatCursorOffset,
|
||||
setWindowedChatState,
|
||||
shiftWindowedMessageSaveState,
|
||||
} from './scripts/tauri/chat/windowed-state.js';
|
||||
|
|
@ -1631,6 +1633,9 @@ export async function replaceCurrentChat() {
|
|||
}
|
||||
}
|
||||
|
||||
/** @type {{ state: any, promise: Promise<void> } | null} */
|
||||
let windowedShowMoreMessagesPending = null;
|
||||
|
||||
export async function showMoreMessages(messagesToLoad = null) {
|
||||
const windowState = getWindowedChatState();
|
||||
if (windowState && isTauriChatPayloadTransportEnabled()) {
|
||||
|
|
@ -1638,78 +1643,100 @@ export async function showMoreMessages(messagesToLoad = null) {
|
|||
throw new Error('Windowed chat cursor is missing');
|
||||
}
|
||||
|
||||
const existingPending = windowedShowMoreMessagesPending;
|
||||
if (existingPending?.state === windowState) {
|
||||
return existingPending.promise;
|
||||
}
|
||||
|
||||
const count = Number(messagesToLoad || DEFAULT_CHAT_WINDOW_LINES);
|
||||
const prevHeight = chatElement.prop('scrollHeight');
|
||||
const showMoreButton = $('#show_more_messages');
|
||||
const isButtonInView = showMoreButton[0] && isElementInViewport(showMoreButton[0]);
|
||||
|
||||
const result = windowState.kind === 'group'
|
||||
? await loadGroupChatPayloadBefore({
|
||||
id: windowState.id,
|
||||
cursor: windowState.cursor,
|
||||
maxLines: count,
|
||||
})
|
||||
: await loadCharacterChatPayloadBefore({
|
||||
characterName: windowState.characterName,
|
||||
avatarUrl: windowState.avatarUrl,
|
||||
fileName: windowState.fileName,
|
||||
cursor: windowState.cursor,
|
||||
maxLines: count,
|
||||
});
|
||||
const run = (async () => {
|
||||
const result = windowState.kind === 'group'
|
||||
? await loadGroupChatPayloadBefore({
|
||||
id: windowState.id,
|
||||
cursor: windowState.cursor,
|
||||
maxLines: count,
|
||||
})
|
||||
: await loadCharacterChatPayloadBefore({
|
||||
characterName: windowState.characterName,
|
||||
avatarUrl: windowState.avatarUrl,
|
||||
fileName: windowState.fileName,
|
||||
cursor: windowState.cursor,
|
||||
maxLines: count,
|
||||
});
|
||||
|
||||
const messages = result?.messages;
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
showMoreButton.remove();
|
||||
if (getWindowedChatState() !== windowState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = result?.messages;
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
showMoreButton.remove();
|
||||
setWindowedChatState({
|
||||
...windowState,
|
||||
cursor: result?.cursor ?? windowState.cursor,
|
||||
hasMoreBefore: false,
|
||||
});
|
||||
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
|
||||
return;
|
||||
}
|
||||
|
||||
messages.forEach(ensureMessageMediaIsArray);
|
||||
chat.splice(0, 0, ...messages);
|
||||
if (this_edit_mes_id >= 0) {
|
||||
this_edit_mes_id = Number(this_edit_mes_id) + messages.length;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let id = 0; id < messages.length; id += 1) {
|
||||
const messageElement = updateMessageElement(chat[id], { messageId: id });
|
||||
fragment.appendChild(messageElement[0]);
|
||||
}
|
||||
|
||||
if (showMoreButton[0]) {
|
||||
showMoreButton[0].after(fragment);
|
||||
} else {
|
||||
chatElement[0].prepend(fragment);
|
||||
}
|
||||
|
||||
updateViewMessageIds(0);
|
||||
refreshSwipeButtons();
|
||||
|
||||
const hasMoreBefore = Boolean(result?.hasMoreBefore);
|
||||
const shiftedState = shiftWindowedMessageSaveState(windowState, messages.length, 'chat');
|
||||
setWindowedChatState({
|
||||
...windowState,
|
||||
cursor: result?.cursor ?? windowState.cursor,
|
||||
hasMoreBefore: false,
|
||||
...shiftedState,
|
||||
cursor: result.cursor,
|
||||
hasMoreBefore,
|
||||
});
|
||||
|
||||
if (!hasMoreBefore) {
|
||||
showMoreButton.remove();
|
||||
}
|
||||
|
||||
if (isButtonInView) {
|
||||
const newHeight = chatElement.prop('scrollHeight');
|
||||
chatElement.scrollTop(newHeight - prevHeight);
|
||||
}
|
||||
|
||||
applyStylePins();
|
||||
applyCharacterTagsToMessageDivs();
|
||||
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
|
||||
return;
|
||||
})();
|
||||
|
||||
windowedShowMoreMessagesPending = { state: windowState, promise: run };
|
||||
|
||||
try {
|
||||
await run;
|
||||
} finally {
|
||||
if (windowedShowMoreMessagesPending?.promise === run) {
|
||||
windowedShowMoreMessagesPending = null;
|
||||
}
|
||||
}
|
||||
|
||||
messages.forEach(ensureMessageMediaIsArray);
|
||||
chat.splice(0, 0, ...messages);
|
||||
if (this_edit_mes_id >= 0) {
|
||||
this_edit_mes_id = Number(this_edit_mes_id) + messages.length;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let id = 0; id < messages.length; id += 1) {
|
||||
const messageElement = updateMessageElement(chat[id], { messageId: id });
|
||||
fragment.appendChild(messageElement[0]);
|
||||
}
|
||||
|
||||
if (showMoreButton[0]) {
|
||||
showMoreButton[0].after(fragment);
|
||||
} else {
|
||||
chatElement[0].prepend(fragment);
|
||||
}
|
||||
|
||||
updateViewMessageIds(0);
|
||||
refreshSwipeButtons();
|
||||
|
||||
const hasMoreBefore = Boolean(result?.hasMoreBefore);
|
||||
const shiftedState = shiftWindowedMessageSaveState(windowState, messages.length, 'chat');
|
||||
setWindowedChatState({
|
||||
...shiftedState,
|
||||
cursor: result.cursor,
|
||||
hasMoreBefore,
|
||||
});
|
||||
|
||||
if (!hasMoreBefore) {
|
||||
showMoreButton.remove();
|
||||
}
|
||||
|
||||
if (isButtonInView) {
|
||||
const newHeight = chatElement.prop('scrollHeight');
|
||||
chatElement.scrollTop(newHeight - prevHeight);
|
||||
}
|
||||
|
||||
applyStylePins();
|
||||
applyCharacterTagsToMessageDivs();
|
||||
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1887,6 +1914,9 @@ export function cancelDebouncedChatSave() {
|
|||
export async function clearChat({ clearData = false } = {}) {
|
||||
cancelDebouncedChatSave();
|
||||
cancelDebouncedMetadataSave();
|
||||
if (clearData) {
|
||||
clearWindowedChatState();
|
||||
}
|
||||
closeMessageEditor();
|
||||
getCodeHighlightCoordinator().reset();
|
||||
extension_prompts = {};
|
||||
|
|
@ -7719,15 +7749,17 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
|
|||
String(error?.code || '').toLowerCase() === 'integrity'
|
||||
|| /integrity/i.test(String(error?.message || ''));
|
||||
|
||||
try {
|
||||
if (isTauriChatPayloadTransportEnabled()) {
|
||||
const windowState = getWindowedChatState();
|
||||
if (windowState?.kind === 'character' && windowState.cursor && windowState.fileName === fileName) {
|
||||
const {
|
||||
patch,
|
||||
savedMessageCount: nextSavedMessageCount,
|
||||
dirtyFromIndex: nextDirtyFromIndex,
|
||||
} = buildWindowedPayloadPatch(trimmedChat, windowState, 'chat');
|
||||
try {
|
||||
if (isTauriChatPayloadTransportEnabled()) {
|
||||
const windowState = getWindowedChatState();
|
||||
if (windowState?.kind === 'character' && windowState.cursor && windowState.fileName === fileName) {
|
||||
const expectedWindowKey = getWindowedChatKey(windowState);
|
||||
const expectedCursorOffset = windowState.cursor.offset;
|
||||
const {
|
||||
patch,
|
||||
savedMessageCount: nextSavedMessageCount,
|
||||
dirtyFromIndex: nextDirtyFromIndex,
|
||||
} = buildWindowedPayloadPatch(trimmedChat, windowState, 'chat');
|
||||
|
||||
const cursor = await patchCharacterChatPayloadWindowed({
|
||||
characterName: characters[this_chid].name,
|
||||
|
|
@ -7735,21 +7767,30 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
|
|||
fileName: fileName,
|
||||
cursor: windowState.cursor,
|
||||
header: JSON.stringify(chatHeader),
|
||||
patch,
|
||||
force: Boolean(force),
|
||||
});
|
||||
patch,
|
||||
force: Boolean(force),
|
||||
});
|
||||
|
||||
setWindowedChatState({
|
||||
...windowState,
|
||||
cursor,
|
||||
savedMessageCount: nextSavedMessageCount,
|
||||
dirtyFromIndex: nextDirtyFromIndex,
|
||||
});
|
||||
} else {
|
||||
await saveCharacterChatPayload({
|
||||
characterName: characters[this_chid].name,
|
||||
avatarUrl: characters[this_chid].avatar,
|
||||
fileName: fileName,
|
||||
const activeWindowState = getWindowedChatState();
|
||||
if (getWindowedChatKey(activeWindowState) === expectedWindowKey) {
|
||||
const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor);
|
||||
const nextWindowState = {
|
||||
...activeWindowState,
|
||||
cursor: mergedCursor,
|
||||
};
|
||||
|
||||
if (activeWindowState?.cursor?.offset === expectedCursorOffset) {
|
||||
nextWindowState.savedMessageCount = nextSavedMessageCount;
|
||||
nextWindowState.dirtyFromIndex = nextDirtyFromIndex;
|
||||
}
|
||||
|
||||
setWindowedChatState(nextWindowState);
|
||||
}
|
||||
} else {
|
||||
await saveCharacterChatPayload({
|
||||
characterName: characters[this_chid].name,
|
||||
avatarUrl: characters[this_chid].avatar,
|
||||
fileName: fileName,
|
||||
payload,
|
||||
force: Boolean(force),
|
||||
});
|
||||
|
|
@ -7975,8 +8016,13 @@ export async function unshallowCharacter(characterId) {
|
|||
}
|
||||
|
||||
export async function getChat() {
|
||||
const startedChid = this_chid;
|
||||
const startedSelectedGroup = selected_group;
|
||||
const startedCharacter = startedChid !== undefined ? characters[startedChid] : null;
|
||||
const startedChatFile = startedCharacter?.chat;
|
||||
|
||||
try {
|
||||
await unshallowCharacter(this_chid);
|
||||
await unshallowCharacter(startedChid);
|
||||
const usePayloadTransport = isTauriChatPayloadTransportEnabled();
|
||||
let data;
|
||||
let windowedCursor = null;
|
||||
|
|
@ -7984,9 +8030,9 @@ export async function getChat() {
|
|||
|
||||
if (usePayloadTransport) {
|
||||
const window = await loadCharacterChatPayloadTail({
|
||||
characterName: characters[this_chid].name,
|
||||
avatarUrl: characters[this_chid].avatar,
|
||||
fileName: characters[this_chid].chat,
|
||||
characterName: startedCharacter?.name,
|
||||
avatarUrl: startedCharacter?.avatar,
|
||||
fileName: startedChatFile,
|
||||
maxLines: DEFAULT_CHAT_WINDOW_LINES,
|
||||
allowNotFound: true,
|
||||
});
|
||||
|
|
@ -8001,9 +8047,9 @@ export async function getChat() {
|
|||
headers: getRequestHeaders(),
|
||||
cache: 'no-cache',
|
||||
body: JSON.stringify({
|
||||
ch_name: characters[this_chid].name,
|
||||
file_name: characters[this_chid].chat,
|
||||
avatar_url: characters[this_chid].avatar,
|
||||
ch_name: startedCharacter?.name,
|
||||
file_name: startedChatFile,
|
||||
avatar_url: startedCharacter?.avatar,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -8014,6 +8060,14 @@ export async function getChat() {
|
|||
data = await response.json();
|
||||
}
|
||||
|
||||
const currentCharacter = startedChid !== undefined ? characters[startedChid] : null;
|
||||
const stillActive = startedSelectedGroup === selected_group
|
||||
&& startedChid === this_chid
|
||||
&& currentCharacter?.chat === startedChatFile;
|
||||
if (!stillActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
/** @type {ChatHeader} */
|
||||
const chatHeader = data.shift();
|
||||
|
|
@ -8029,9 +8083,9 @@ export async function getChat() {
|
|||
if (usePayloadTransport && windowedCursor) {
|
||||
setWindowedChatState({
|
||||
kind: 'character',
|
||||
characterName: characters[this_chid].name,
|
||||
avatarUrl: characters[this_chid].avatar,
|
||||
fileName: characters[this_chid].chat,
|
||||
characterName: currentCharacter.name,
|
||||
avatarUrl: currentCharacter.avatar,
|
||||
fileName: currentCharacter.chat,
|
||||
cursor: windowedCursor,
|
||||
hasMoreBefore: windowedHasMoreBefore,
|
||||
savedMessageCount: chat.length,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ import {
|
|||
clearWindowedChatState,
|
||||
DEFAULT_CHAT_WINDOW_LINES,
|
||||
getWindowedChatState,
|
||||
getWindowedChatKey,
|
||||
mergeWindowedChatCursorOffset,
|
||||
setWindowedChatState,
|
||||
} from './tauri/chat/windowed-state.js';
|
||||
|
||||
|
|
@ -206,7 +208,7 @@ async function regenerateGroup() {
|
|||
* @param {string} chatId Chat ID
|
||||
* @returns {Promise<ChatFile>} Array of chat messages
|
||||
*/
|
||||
async function loadGroupChat(chatId) {
|
||||
async function loadGroupChat(chatId, { updateWindowState = false } = {}) {
|
||||
const normalizedChatId = String(chatId || '').trim();
|
||||
if (!normalizedChatId) {
|
||||
return [];
|
||||
|
|
@ -219,18 +221,20 @@ async function loadGroupChat(chatId) {
|
|||
allowNotFound: true,
|
||||
});
|
||||
|
||||
if (window.cursor) {
|
||||
const messageCount = Array.isArray(window.payload) ? Math.max(0, window.payload.length - 1) : 0;
|
||||
setWindowedChatState({
|
||||
kind: 'group',
|
||||
id: normalizedChatId,
|
||||
cursor: window.cursor,
|
||||
hasMoreBefore: window.hasMoreBefore,
|
||||
savedMessageCount: messageCount,
|
||||
dirtyFromIndex: messageCount,
|
||||
});
|
||||
} else {
|
||||
clearWindowedChatState();
|
||||
if (updateWindowState && String(getCurrentChatId() || '').trim() === normalizedChatId) {
|
||||
if (window.cursor) {
|
||||
const messageCount = Array.isArray(window.payload) ? Math.max(0, window.payload.length - 1) : 0;
|
||||
setWindowedChatState({
|
||||
kind: 'group',
|
||||
id: normalizedChatId,
|
||||
cursor: window.cursor,
|
||||
hasMoreBefore: window.hasMoreBefore,
|
||||
savedMessageCount: messageCount,
|
||||
dirtyFromIndex: messageCount,
|
||||
});
|
||||
} else {
|
||||
clearWindowedChatState();
|
||||
}
|
||||
}
|
||||
|
||||
return window.payload;
|
||||
|
|
@ -303,12 +307,25 @@ export async function getGroupChat(groupId, reload = false) {
|
|||
return;
|
||||
}
|
||||
|
||||
const startedGroupId = groupId;
|
||||
const startedChatId = group.chat_id;
|
||||
const isStillActive = () => selected_group === startedGroupId && getCurrentChatId() === startedChatId;
|
||||
|
||||
// Run validation before any loading
|
||||
await validateGroup(group);
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
await unshallowGroupMembers(groupId);
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chat_id = group.chat_id;
|
||||
const data = await loadGroupChat(chat_id);
|
||||
const data = await loadGroupChat(chat_id, { updateWindowState: true });
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
const metadata = data?.[0]?.chat_metadata ?? {};
|
||||
const freshChat = !metadata.tainted && (!Array.isArray(data) || !data.length);
|
||||
|
||||
|
|
@ -323,17 +340,26 @@ export async function getGroupChat(groupId, reload = false) {
|
|||
}
|
||||
|
||||
await loadItemizedPrompts(getCurrentChatId());
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (group && Array.isArray(group.members) && freshChat) {
|
||||
chat.splice(0, chat.length);
|
||||
chatElement.find('.mes').remove();
|
||||
for (let member of group.members) {
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
const character = characters.find(x => x.avatar === member || x.name === member);
|
||||
if (!character) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mes = await getFirstCharacterMessage(character);
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No first message
|
||||
if (!(mes?.mes)) {
|
||||
|
|
@ -347,10 +373,16 @@ export async function getGroupChat(groupId, reload = false) {
|
|||
}
|
||||
await saveGroupChat(groupId, false);
|
||||
} else if (Array.isArray(data) && data.length) {
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
chat.splice(0, chat.length, ...data);
|
||||
chat.forEach(ensureMessageMediaIsArray);
|
||||
chatElement.find('.mes').remove();
|
||||
await printMessages();
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowState = getWindowedChatState();
|
||||
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chat_id) {
|
||||
|
|
@ -363,6 +395,9 @@ export async function getGroupChat(groupId, reload = false) {
|
|||
}
|
||||
|
||||
updateChatMetadata(metadata, true);
|
||||
if (!isStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reload) {
|
||||
select_group_chats(groupId, true);
|
||||
|
|
@ -695,35 +730,46 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
|
|||
String(error?.code || '').toLowerCase() === 'integrity'
|
||||
|| /integrity/i.test(String(error?.message || ''));
|
||||
|
||||
try {
|
||||
if (isTauriChatPayloadTransportEnabled()) {
|
||||
const windowState = getWindowedChatState();
|
||||
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chatId) {
|
||||
const {
|
||||
patch,
|
||||
savedMessageCount: nextSavedMessageCount,
|
||||
dirtyFromIndex: nextDirtyFromIndex,
|
||||
} = buildWindowedPayloadPatch(chat, windowState, 'group chat');
|
||||
try {
|
||||
if (isTauriChatPayloadTransportEnabled()) {
|
||||
const windowState = getWindowedChatState();
|
||||
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chatId) {
|
||||
const expectedWindowKey = getWindowedChatKey(windowState);
|
||||
const expectedCursorOffset = windowState.cursor.offset;
|
||||
const {
|
||||
patch,
|
||||
savedMessageCount: nextSavedMessageCount,
|
||||
dirtyFromIndex: nextDirtyFromIndex,
|
||||
} = buildWindowedPayloadPatch(chat, windowState, 'group chat');
|
||||
|
||||
const cursor = await patchGroupChatPayloadWindowed({
|
||||
id: chatId,
|
||||
cursor: windowState.cursor,
|
||||
header: JSON.stringify(chatHeader),
|
||||
patch,
|
||||
force: Boolean(force),
|
||||
});
|
||||
patch,
|
||||
force: Boolean(force),
|
||||
});
|
||||
|
||||
setWindowedChatState({
|
||||
...windowState,
|
||||
cursor,
|
||||
savedMessageCount: nextSavedMessageCount,
|
||||
dirtyFromIndex: nextDirtyFromIndex,
|
||||
});
|
||||
} else {
|
||||
await saveGroupChatPayload({
|
||||
id: chatId,
|
||||
payload,
|
||||
force: Boolean(force),
|
||||
const activeWindowState = getWindowedChatState();
|
||||
if (getWindowedChatKey(activeWindowState) === expectedWindowKey) {
|
||||
const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor);
|
||||
const nextWindowState = {
|
||||
...activeWindowState,
|
||||
cursor: mergedCursor,
|
||||
};
|
||||
|
||||
if (activeWindowState?.cursor?.offset === expectedCursorOffset) {
|
||||
nextWindowState.savedMessageCount = nextSavedMessageCount;
|
||||
nextWindowState.dirtyFromIndex = nextDirtyFromIndex;
|
||||
}
|
||||
|
||||
setWindowedChatState(nextWindowState);
|
||||
}
|
||||
} else {
|
||||
await saveGroupChatPayload({
|
||||
id: chatId,
|
||||
payload,
|
||||
force: Boolean(force),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,33 @@ export function clearWindowedChatState() {
|
|||
currentWindow = null;
|
||||
}
|
||||
|
||||
export function getWindowedChatKey(windowState) {
|
||||
if (!windowState) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (windowState.kind === 'group') {
|
||||
return `group:${String(windowState.id || '').trim()}`;
|
||||
}
|
||||
|
||||
return `character:${String(windowState.characterName || '').trim()}|${String(windowState.avatarUrl || '').trim()}|${String(windowState.fileName || '').trim()}`;
|
||||
}
|
||||
|
||||
export function mergeWindowedChatCursorOffset(activeCursor, nextCursor) {
|
||||
if (!nextCursor) {
|
||||
return activeCursor ?? null;
|
||||
}
|
||||
|
||||
if (!activeCursor) {
|
||||
return nextCursor;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextCursor,
|
||||
offset: activeCursor.offset,
|
||||
};
|
||||
}
|
||||
|
||||
function requireWindowedCounter(value, label) {
|
||||
const normalized = Number(value);
|
||||
if (!Number.isFinite(normalized) || normalized < 0) {
|
||||
|
|
|
|||
59
tests/windowed-payload-cursor-errors.test.mjs
Normal file
59
tests/windowed-payload-cursor-errors.test.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||
return import(url);
|
||||
}
|
||||
|
||||
test('prompt-backfill: isWindowedCursorInvalidError matches signature mismatch', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||
const { isWindowedCursorInvalidError } = mod;
|
||||
|
||||
assert.equal(isWindowedCursorInvalidError(new Error('Cursor signature mismatch')), true);
|
||||
assert.equal(isWindowedCursorInvalidError('Bad Request: Cursor signature mismatch'), true);
|
||||
});
|
||||
|
||||
test('prompt-backfill: isWindowedCursorInvalidError matches out-of-bounds and line boundary', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||
const { isWindowedCursorInvalidError } = mod;
|
||||
|
||||
assert.equal(
|
||||
isWindowedCursorInvalidError('Internal error: Cursor offset is out of bounds for /tmp/chat.jsonl'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isWindowedCursorInvalidError('Validation error: Cursor offset is not a line boundary'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt-backfill: isWindowedCursorInvalidError matches before-chat-payload-body error', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||
const { isWindowedCursorInvalidError } = mod;
|
||||
|
||||
assert.equal(
|
||||
isWindowedCursorInvalidError('Cursor offset is before chat payload body for /tmp/chat.jsonl'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt-backfill: isWindowedCursorInvalidError ignores non-cursor errors', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||
const { isWindowedCursorInvalidError } = mod;
|
||||
|
||||
assert.equal(isWindowedCursorInvalidError(new Error('boom')), false);
|
||||
assert.equal(isWindowedCursorInvalidError('permission denied: no'), false);
|
||||
});
|
||||
|
||||
test('prompt-backfill: isWindowedCursorInvalidError requires known cursor failure hints', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
|
||||
const { isWindowedCursorInvalidError } = mod;
|
||||
|
||||
assert.equal(isWindowedCursorInvalidError('cursor is present but message is unrelated'), false);
|
||||
assert.equal(isWindowedCursorInvalidError({ message: 'cursor: ???' }), false);
|
||||
});
|
||||
54
tests/windowed-payload-guards-contract.test.mjs
Normal file
54
tests/windowed-payload-guards-contract.test.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
test('windowed payload: showMoreMessages implements single-flight + CAS commit', async () => {
|
||||
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||
|
||||
assert.match(source, /\bwindowedShowMoreMessagesPending\b/);
|
||||
assert.match(source, /existingPending\?\.\s*state\s*===\s*windowState/);
|
||||
assert.match(source, /getWindowedChatState\(\)\s*!==\s*windowState/);
|
||||
});
|
||||
|
||||
test('windowed payload: clearChat(clearData:true) invalidates windowed state immediately', async () => {
|
||||
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||
|
||||
const clearChatStart = source.indexOf('export async function clearChat');
|
||||
assert.ok(clearChatStart >= 0);
|
||||
const slice = source.slice(clearChatStart, clearChatStart + 600);
|
||||
|
||||
assert.match(slice, /cancelDebouncedMetadataSave\(\);/);
|
||||
assert.match(slice, /if\s*\(\s*clearData\s*\)\s*\{\s*clearWindowedChatState\(\);\s*\}/s);
|
||||
});
|
||||
|
||||
test('windowed payload: getChat drops stale tail-load results before committing UI/state', async () => {
|
||||
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||
|
||||
const getChatStart = source.indexOf('export async function getChat()');
|
||||
assert.ok(getChatStart >= 0);
|
||||
const slice = source.slice(getChatStart, getChatStart + 3200);
|
||||
|
||||
assert.match(slice, /const\s+startedChid\s*=\s*this_chid\s*;/);
|
||||
assert.match(slice, /\bconst\s+stillActive\s*=\s*[\s\S]*?;\s*if\s*\(!stillActive\)\s*\{\s*return;\s*\}/s);
|
||||
});
|
||||
|
||||
test('windowed payload: group tail-load does not mutate window state for background reads', async () => {
|
||||
const source = await readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8');
|
||||
|
||||
assert.match(source, /async function loadGroupChat\(\s*chatId\s*,\s*\{\s*updateWindowState\s*=\s*false\s*\}\s*=\s*\{\s*\}\s*\)/);
|
||||
assert.match(source, /if\s*\(\s*updateWindowState[\s\S]*?setWindowedChatState\s*\(/s);
|
||||
});
|
||||
|
||||
test('windowed payload: windowed patch commit is guarded and preserves active cursor offset', async () => {
|
||||
const script = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
|
||||
const groupChats = await readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8');
|
||||
|
||||
assert.match(script, /\bgetWindowedChatKey\b/);
|
||||
assert.match(script, /\bmergeWindowedChatCursorOffset\b/);
|
||||
assert.match(groupChats, /\bgetWindowedChatKey\b/);
|
||||
assert.match(groupChats, /\bmergeWindowedChatCursorOffset\b/);
|
||||
});
|
||||
111
tests/windowed-payload-jsonl.test.mjs
Normal file
111
tests/windowed-payload-jsonl.test.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||
return import(url);
|
||||
}
|
||||
|
||||
test('jsonl: payloadToJsonl formats newline-delimited JSON', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { payloadToJsonl } = mod;
|
||||
|
||||
const payload = [{ a: 1 }, { b: 2 }];
|
||||
assert.equal(payloadToJsonl(payload), `${JSON.stringify(payload[0])}\n${JSON.stringify(payload[1])}`);
|
||||
});
|
||||
|
||||
test('jsonl: payloadToJsonl rejects non-object entries', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { payloadToJsonl } = mod;
|
||||
|
||||
assert.throws(() => payloadToJsonl([null]), /must be an object/i);
|
||||
assert.throws(() => payloadToJsonl([1]), /must be an object/i);
|
||||
});
|
||||
|
||||
test('jsonl: jsonlToPayload handles BOM on first payload line', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { jsonlToPayload } = mod;
|
||||
|
||||
const bom = '\uFEFF';
|
||||
const payload = jsonlToPayload(`${bom}{"a":1}\n{"b":2}`);
|
||||
assert.deepEqual(payload, [{ a: 1 }, { b: 2 }]);
|
||||
});
|
||||
|
||||
test('jsonl: jsonlToPayload ignores whitespace-only lines', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { jsonlToPayload } = mod;
|
||||
|
||||
const payload = jsonlToPayload('\n \n{"a":1}\n\t\n');
|
||||
assert.deepEqual(payload, [{ a: 1 }]);
|
||||
});
|
||||
|
||||
test('jsonl: jsonlToPayload surfaces line numbers on invalid JSON', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { jsonlToPayload } = mod;
|
||||
|
||||
assert.throws(() => jsonlToPayload('{"a":1}\n{bad}\n'), /Invalid JSONL at line 2/);
|
||||
});
|
||||
|
||||
test('jsonl: payloadToJsonlByteChunks round-trips and respects maxChunkBytes', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { payloadToJsonl, payloadToJsonlByteChunks } = mod;
|
||||
|
||||
const payload = [{ a: 1 }, { b: 2 }, { c: 3 }];
|
||||
const maxChunkBytes = 16;
|
||||
const chunks = Array.from(payloadToJsonlByteChunks(payload, { maxChunkBytes }));
|
||||
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.byteLength <= maxChunkBytes);
|
||||
}
|
||||
|
||||
const combined = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||
assert.equal(combined.toString('utf8'), payloadToJsonl(payload));
|
||||
});
|
||||
|
||||
test('jsonl: jsonlStreamToPayload parses chunked stream input', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { jsonlStreamToPayload } = mod;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode('{"a":1}\n{"b":'));
|
||||
controller.enqueue(encoder.encode('2}\n{"c":3}\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await jsonlStreamToPayload(stream);
|
||||
assert.deepEqual(payload, [{ a: 1 }, { b: 2 }, { c: 3 }]);
|
||||
});
|
||||
|
||||
test('jsonl: jsonlStreamToPayload cancels reader on parse error during streaming', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
|
||||
const { jsonlStreamToPayload } = mod;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let pushed = false;
|
||||
let canceled = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (pushed) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushed = true;
|
||||
controller.enqueue(encoder.encode('{"a":1}\n{bad}\n{"c":3}\n'));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(() => jsonlStreamToPayload(stream), /Invalid JSONL at line 2/);
|
||||
assert.equal(canceled, true);
|
||||
});
|
||||
166
tests/windowed-payload-state.test.mjs
Normal file
166
tests/windowed-payload-state.test.mjs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||
return import(url);
|
||||
}
|
||||
|
||||
function buildMessages(count) {
|
||||
return Array.from({ length: count }, (_, index) => ({ id: index, mes: `m-${index}` }));
|
||||
}
|
||||
|
||||
test('windowed-state: get/set/clear windowed chat state', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { clearWindowedChatState, getWindowedChatState, setWindowedChatState } = mod;
|
||||
|
||||
clearWindowedChatState();
|
||||
assert.equal(getWindowedChatState(), null);
|
||||
|
||||
const state = { kind: 'character', fileName: 'a', savedMessageCount: 0, dirtyFromIndex: 0 };
|
||||
setWindowedChatState(state);
|
||||
assert.equal(getWindowedChatState(), state);
|
||||
|
||||
clearWindowedChatState();
|
||||
assert.equal(getWindowedChatState(), null);
|
||||
});
|
||||
|
||||
test('windowed-state: getWindowedChatKey trims and separates kinds', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { getWindowedChatKey } = mod;
|
||||
|
||||
assert.equal(getWindowedChatKey(null), '');
|
||||
assert.equal(getWindowedChatKey({ kind: 'group', id: ' grp-1 ' }), 'group:grp-1');
|
||||
|
||||
assert.equal(getWindowedChatKey({
|
||||
kind: 'character',
|
||||
characterName: ' Alice ',
|
||||
avatarUrl: ' /User%20Avatars/a.png ',
|
||||
fileName: ' chat-1 ',
|
||||
}), 'character:Alice|/User%20Avatars/a.png|chat-1');
|
||||
});
|
||||
|
||||
test('windowed-state: mergeWindowedChatCursorOffset preserves active offset', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { mergeWindowedChatCursorOffset } = mod;
|
||||
|
||||
assert.equal(mergeWindowedChatCursorOffset(null, null), null);
|
||||
assert.deepEqual(
|
||||
mergeWindowedChatCursorOffset(null, { offset: 10, size: 1, modifiedMillis: 2 }),
|
||||
{ offset: 10, size: 1, modifiedMillis: 2 },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
mergeWindowedChatCursorOffset({ offset: 10, size: 1, modifiedMillis: 2 }, null),
|
||||
{ offset: 10, size: 1, modifiedMillis: 2 },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
mergeWindowedChatCursorOffset(
|
||||
{ offset: 10, size: 1, modifiedMillis: 2 },
|
||||
{ offset: 999, size: 2, modifiedMillis: 3 },
|
||||
),
|
||||
{ offset: 10, size: 2, modifiedMillis: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
test('windowed-state: buildWindowedPayloadPatch rewriteFromIndex when dirtyFromIndex < savedMessageCount', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { buildWindowedPayloadPatch } = mod;
|
||||
|
||||
const messages = buildMessages(5);
|
||||
const windowState = { savedMessageCount: 5, dirtyFromIndex: 2 };
|
||||
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||
|
||||
assert.equal(result.patch.kind, 'rewriteFromIndex');
|
||||
assert.equal(result.patch.startIndex, 2);
|
||||
assert.deepEqual(result.patch.lines, messages.slice(2).map((entry) => JSON.stringify(entry)));
|
||||
assert.equal(result.savedMessageCount, messages.length);
|
||||
assert.equal(result.dirtyFromIndex, messages.length);
|
||||
});
|
||||
|
||||
test('windowed-state: buildWindowedPayloadPatch truncates when messages shorter than savedMessageCount', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { buildWindowedPayloadPatch } = mod;
|
||||
|
||||
const messages = buildMessages(3);
|
||||
const windowState = { savedMessageCount: 5, dirtyFromIndex: 5 };
|
||||
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||
|
||||
assert.deepEqual(result.patch, { kind: 'rewriteFromIndex', startIndex: 3, lines: [] });
|
||||
assert.equal(result.savedMessageCount, messages.length);
|
||||
assert.equal(result.dirtyFromIndex, messages.length);
|
||||
});
|
||||
|
||||
test('windowed-state: buildWindowedPayloadPatch appends when messages longer than savedMessageCount', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { buildWindowedPayloadPatch } = mod;
|
||||
|
||||
const messages = buildMessages(5);
|
||||
const windowState = { savedMessageCount: 3, dirtyFromIndex: 3 };
|
||||
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||
|
||||
assert.equal(result.patch.kind, 'append');
|
||||
assert.deepEqual(result.patch.lines, messages.slice(3).map((entry) => JSON.stringify(entry)));
|
||||
assert.equal(result.savedMessageCount, messages.length);
|
||||
assert.equal(result.dirtyFromIndex, messages.length);
|
||||
});
|
||||
|
||||
test('windowed-state: buildWindowedPayloadPatch full rewrite when unchanged but non-empty', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { buildWindowedPayloadPatch } = mod;
|
||||
|
||||
const messages = buildMessages(2);
|
||||
const windowState = { savedMessageCount: 2, dirtyFromIndex: 2 };
|
||||
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
|
||||
|
||||
assert.deepEqual(result.patch, {
|
||||
kind: 'rewriteFromIndex',
|
||||
startIndex: 0,
|
||||
lines: messages.map((entry) => JSON.stringify(entry)),
|
||||
});
|
||||
});
|
||||
|
||||
test('windowed-state: buildWindowedPayloadPatch returns empty append for empty message list', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { buildWindowedPayloadPatch } = mod;
|
||||
|
||||
const result = buildWindowedPayloadPatch([], { savedMessageCount: 0, dirtyFromIndex: 0 }, 'chat');
|
||||
assert.deepEqual(result.patch, { kind: 'append', lines: [] });
|
||||
assert.equal(result.savedMessageCount, 0);
|
||||
assert.equal(result.dirtyFromIndex, 0);
|
||||
});
|
||||
|
||||
test('windowed-state: shiftWindowedMessageSaveState shifts counters without mutating original', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { shiftWindowedMessageSaveState } = mod;
|
||||
|
||||
const windowState = { kind: 'character', savedMessageCount: 2, dirtyFromIndex: 1, foo: 'bar' };
|
||||
const shifted = shiftWindowedMessageSaveState(windowState, 3, 'chat');
|
||||
|
||||
assert.notEqual(shifted, windowState);
|
||||
assert.equal(shifted.foo, 'bar');
|
||||
assert.equal(shifted.savedMessageCount, 5);
|
||||
assert.equal(shifted.dirtyFromIndex, 4);
|
||||
|
||||
assert.equal(windowState.savedMessageCount, 2);
|
||||
assert.equal(windowState.dirtyFromIndex, 1);
|
||||
});
|
||||
|
||||
test('windowed-state: readWindowedMessageSaveState throws on missing counters', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
|
||||
const { readWindowedMessageSaveState } = mod;
|
||||
|
||||
assert.throws(
|
||||
() => readWindowedMessageSaveState({}, 'chat'),
|
||||
/savedMessageCount is missing/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => readWindowedMessageSaveState({ savedMessageCount: 0 }, 'chat'),
|
||||
/dirtyFromIndex is missing/i,
|
||||
);
|
||||
});
|
||||
39
tests/windowed-payload-transport-normalize.test.mjs
Normal file
39
tests/windowed-payload-transport-normalize.test.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
|
||||
return import(url);
|
||||
}
|
||||
|
||||
test('transport: normalizeChatFileName strips .jsonl and trims', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
|
||||
const { normalizeChatFileName } = mod;
|
||||
|
||||
assert.equal(normalizeChatFileName(' hello.jsonl '), 'hello');
|
||||
assert.equal(normalizeChatFileName('world.JSONL'), 'world');
|
||||
assert.equal(normalizeChatFileName('already-normalized'), 'already-normalized');
|
||||
assert.equal(normalizeChatFileName(''), '');
|
||||
assert.equal(normalizeChatFileName(null), '');
|
||||
});
|
||||
|
||||
test('transport: resolveCharacterDirectoryId prefers avatar internal id', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
|
||||
const { resolveCharacterDirectoryId } = mod;
|
||||
|
||||
assert.equal(resolveCharacterDirectoryId('Alice', 'User Avatars/abc123.png'), 'abc123');
|
||||
assert.equal(resolveCharacterDirectoryId('Alice', 'thumbnail?file=foo.png'), 'foo');
|
||||
assert.equal(resolveCharacterDirectoryId('Alice', 'thumbnail?file=my%20avatar.png'), 'my avatar');
|
||||
});
|
||||
|
||||
test('transport: resolveCharacterDirectoryId falls back to character name when avatar is missing', async () => {
|
||||
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
|
||||
const { resolveCharacterDirectoryId } = mod;
|
||||
|
||||
assert.equal(resolveCharacterDirectoryId(' Alice ', null), 'Alice');
|
||||
assert.equal(resolveCharacterDirectoryId(' Alice ', ''), 'Alice');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue