From f786d68c49c0cc70fb28603245195be58ccf29cd Mon Sep 17 00:00:00 2001 From: Darkatse <39111369+Darkatse@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:39:25 -0400 Subject: [PATCH] fix (windowed payload): Restrict frontend windowed payload contract to a single flight --- src/script.js | 242 +++++++++++------- src/scripts/group-chats.js | 120 ++++++--- src/scripts/tauri/chat/windowed-state.js | 27 ++ tests/windowed-payload-cursor-errors.test.mjs | 59 +++++ .../windowed-payload-guards-contract.test.mjs | 54 ++++ tests/windowed-payload-jsonl.test.mjs | 111 ++++++++ tests/windowed-payload-state.test.mjs | 166 ++++++++++++ ...dowed-payload-transport-normalize.test.mjs | 39 +++ 8 files changed, 687 insertions(+), 131 deletions(-) create mode 100644 tests/windowed-payload-cursor-errors.test.mjs create mode 100644 tests/windowed-payload-guards-contract.test.mjs create mode 100644 tests/windowed-payload-jsonl.test.mjs create mode 100644 tests/windowed-payload-state.test.mjs create mode 100644 tests/windowed-payload-transport-normalize.test.mjs diff --git a/src/script.js b/src/script.js index 85c87f36..cd66d1c9 100644 --- a/src/script.js +++ b/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 } | 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, diff --git a/src/scripts/group-chats.js b/src/scripts/group-chats.js index 532146b9..c4e66796 100644 --- a/src/scripts/group-chats.js +++ b/src/scripts/group-chats.js @@ -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} 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 { diff --git a/src/scripts/tauri/chat/windowed-state.js b/src/scripts/tauri/chat/windowed-state.js index e847612c..5376aa2f 100644 --- a/src/scripts/tauri/chat/windowed-state.js +++ b/src/scripts/tauri/chat/windowed-state.js @@ -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) { diff --git a/tests/windowed-payload-cursor-errors.test.mjs b/tests/windowed-payload-cursor-errors.test.mjs new file mode 100644 index 00000000..4571ca29 --- /dev/null +++ b/tests/windowed-payload-cursor-errors.test.mjs @@ -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); +}); diff --git a/tests/windowed-payload-guards-contract.test.mjs b/tests/windowed-payload-guards-contract.test.mjs new file mode 100644 index 00000000..cb3441e8 --- /dev/null +++ b/tests/windowed-payload-guards-contract.test.mjs @@ -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/); +}); diff --git a/tests/windowed-payload-jsonl.test.mjs b/tests/windowed-payload-jsonl.test.mjs new file mode 100644 index 00000000..5b676e7f --- /dev/null +++ b/tests/windowed-payload-jsonl.test.mjs @@ -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); +}); diff --git a/tests/windowed-payload-state.test.mjs b/tests/windowed-payload-state.test.mjs new file mode 100644 index 00000000..a1a6e3c6 --- /dev/null +++ b/tests/windowed-payload-state.test.mjs @@ -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, + ); +}); diff --git a/tests/windowed-payload-transport-normalize.test.mjs b/tests/windowed-payload-transport-normalize.test.mjs new file mode 100644 index 00000000..c7cbabde --- /dev/null +++ b/tests/windowed-payload-transport-normalize.test.mjs @@ -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'); +});