diff --git a/extension/manifest.chrome.json b/extension/manifest.chrome.json index 1de7e5f..edf1042 100644 --- a/extension/manifest.chrome.json +++ b/extension/manifest.chrome.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "LightSession Pro for ChatGPT", - "version": "1.7.3", + "version": "1.7.4", "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", "icons": { "16": "icons/icon-16.png", diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json index 31b6efb..8035432 100644 --- a/extension/manifest.firefox.json +++ b/extension/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "LightSession Pro for ChatGPT", - "version": "1.7.3", + "version": "1.7.4", "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", "icons": { "16": "icons/icon-16.png", diff --git a/extension/src/content/chat-view.ts b/extension/src/content/chat-view.ts index 5cc0890..4be48d5 100644 --- a/extension/src/content/chat-view.ts +++ b/extension/src/content/chat-view.ts @@ -18,6 +18,22 @@ export function hasConversationTurns(root: ParentNode): boolean { return false; } +export function countConversationTurns(root: ParentNode): number { + const main = root.querySelector('main'); + if (!main) { + return 0; + } + + for (const selector of ['[data-message-author-role]', '[data-message-id]', '[data-testid="conversation-turn"]', 'article']) { + const count = main.querySelectorAll(selector).length; + if (count > 0) { + return count; + } + } + + return 0; +} + export function isEmptyChatView(root: ParentNode): boolean { const main = root.querySelector('main'); if (!main) { diff --git a/extension/src/content/content.ts b/extension/src/content/content.ts index db8c6d8..342c609 100644 --- a/extension/src/content/content.ts +++ b/extension/src/content/content.ts @@ -18,11 +18,14 @@ import { updateStatusBar, resetAccumulatedTrimmed, refreshStatusBar, + showBootstrapStatus, setStatusBarVisibility, } from './status-bar'; -import { isEmptyChatView } from './chat-view'; +import { countConversationTurns, isEmptyChatView } from './chat-view'; import { installUserCollapse, type UserCollapseController } from './user-collapse'; import { isLightSessionRejection } from './rejection-filter'; +import { isProxyReadySatisfied } from '../shared/proxy-ready'; +import { extractConversationPageId } from '../shared/url'; // ============================================================================ @@ -60,6 +63,8 @@ let emptyChatState = false; let emptyChatCheckTimer: number | null = null; let emptyChatObserver: MutationObserver | null = null; let userCollapse: UserCollapseController | null = null; +let hasAuthoritativeStatus = false; +let requestedBootstrapSyncConversationId: string | null = null; // ============================================================================ // Page Script Communication @@ -110,6 +115,8 @@ function handleTrimStatus(event: CustomEvent): void { } logDebug('Received trim status:', status); + hasAuthoritativeStatus = true; + requestedBootstrapSyncConversationId = extractConversationPageId(location.href); // Convert page script status format to status bar format updateStatusBar({ @@ -139,7 +146,7 @@ function handleProxyReady(): void { * Shows a warning in the status bar if not. */ function checkProxyStatus(): void { - if (!proxyReady) { + if (!isProxyReadySatisfied(proxyReady)) { logWarn('Fetch proxy did not signal ready within timeout'); // Don't show warning to user - proxy may still work, just didn't send ready message } @@ -250,8 +257,11 @@ function setupNavigationDetection(): void { lastUrl = location.href; logDebug(`${source} navigation:`, lastUrl); + hasAuthoritativeStatus = false; + requestedBootstrapSyncConversationId = null; resetAccumulatedTrimmed(); refreshStatusBar(); + scheduleEmptyChatCheck(); // Re-bind DOM observers for per-chat containers (SPA navigation can replace the message list DOM). // Make the settings intent explicit; userCollapse being non-null is an implementation detail. @@ -301,11 +311,44 @@ function setupNavigationDetection(): void { function checkEmptyChatView(): void { const isEmpty = isEmptyChatView(document); - if (isEmpty && !emptyChatState) { - resetAccumulatedTrimmed(); - refreshStatusBar(); + if (isEmpty) { + if (!emptyChatState) { + hasAuthoritativeStatus = false; + resetAccumulatedTrimmed(); + refreshStatusBar(); + } + emptyChatState = true; + return; + } + + emptyChatState = false; + + if (!currentSettings?.enabled || hasAuthoritativeStatus) { + return; + } + + const conversationId = extractConversationPageId(location.href); + if (conversationId && conversationId !== requestedBootstrapSyncConversationId) { + requestedBootstrapSyncConversationId = conversationId; + window.dispatchEvent( + new CustomEvent('lightsession-bootstrap-sync', { + detail: JSON.stringify({ conversationId }), + }) + ); + } + + const visibleTurns = countConversationTurns(document); + if (visibleTurns > 0 && visibleTurns <= currentSettings.keep) { + updateStatusBar({ + totalMessages: visibleTurns, + visibleMessages: visibleTurns, + trimmedMessages: 0, + keepLastN: currentSettings.keep, + }); + return; } - emptyChatState = isEmpty; + + showBootstrapStatus(); } function scheduleEmptyChatCheck(): void { diff --git a/extension/src/content/status-bar.ts b/extension/src/content/status-bar.ts index 8946c86..a99d8dd 100644 --- a/extension/src/content/status-bar.ts +++ b/extension/src/content/status-bar.ts @@ -7,6 +7,7 @@ import { TIMING } from '../shared/constants'; const STATUS_BAR_ID = 'lightsession-status-bar'; const WAITING_TEXT = 'LightSession · waiting for messages…'; +const PENDING_TEXT = 'LightSession · sync pending…'; export interface StatusBarStats { totalMessages: number; @@ -15,7 +16,7 @@ export interface StatusBarStats { keepLastN: number; } -type StatusBarState = 'active' | 'waiting' | 'all-visible' | 'unrecognized'; +type StatusBarState = 'active' | 'waiting' | 'pending' | 'all-visible' | 'unrecognized'; let currentStats: StatusBarStats | null = null; let isVisible = true; @@ -124,6 +125,11 @@ function applyStateStyles(bar: HTMLElement, state: StatusBarState): void { case 'waiting': bar.style.color = '#9ca3af'; break; + case 'pending': + bar.style.color = '#bfdbfe'; + bar.style.backgroundColor = 'rgba(30, 41, 59, 0.92)'; + bar.style.borderColor = 'rgba(96, 165, 250, 0.45)'; + break; case 'all-visible': // Keep neutral styling break; @@ -169,6 +175,12 @@ function renderWaitingStatusBar(bar: HTMLElement): void { lastUpdateTime = performance.now(); } +function renderPendingStatusBar(bar: HTMLElement): void { + bar.textContent = PENDING_TEXT; + applyStateStyles(bar, 'pending'); + lastUpdateTime = performance.now(); +} + /** * Update the status bar with new stats (throttled, with change detection) */ @@ -311,6 +323,26 @@ export function resetAccumulatedTrimmed(): void { renderWaitingStatusBar(bar); } +export function showBootstrapStatus(): void { + currentStats = null; + pendingStats = null; + if (pendingUpdateTimer !== null) { + clearTimeout(pendingUpdateTimer); + pendingUpdateTimer = null; + } + + if (!isVisible) { + return; + } + + const bar = getOrCreateStatusBar(); + if (!bar) { + return; + } + + renderPendingStatusBar(bar); +} + /** * Refresh status bar after SPA navigation or DOM resets */ diff --git a/extension/src/page/page-script.ts b/extension/src/page/page-script.ts index 2aa7fca..f68f7a2 100644 --- a/extension/src/page/page-script.ts +++ b/extension/src/page/page-script.ts @@ -18,6 +18,8 @@ import { trimMapping, type ConversationData, } from '../shared/trimmer'; +import { TIMING } from '../shared/constants'; +import { markProxyReady } from '../shared/proxy-ready'; import type { TrimStatus } from '../shared/types'; // ============================================================================ @@ -39,6 +41,7 @@ declare global { __LS_CONFIG__?: LsConfig; __LS_PROXY_PATCHED__?: boolean; __LS_DEBUG__?: boolean; + __LS_BOOTSTRAP_SYNC_LISTENER__?: boolean; } } @@ -93,6 +96,8 @@ async function ensureConfigReady(timeoutMs = 50): Promise { let configReceived = false; const CONFIG_FALLBACK_TIMEOUT_MS = 2000; const configStartTime = Date.now(); +const completedBootstrapSyncIds = new Set(); +const inFlightBootstrapSyncIds = new Set(); /** * localStorage key - must match storage.ts LOCAL_STORAGE_KEY @@ -146,6 +151,63 @@ function dispatchStatus(status: TrimStatus): void { ); } +function extractConversationRequestId(url: URL): string | null { + const match = url.pathname.match( + /^\/backend-api\/(?:conversation|shared_conversation)\/([^/]+)\/?$/ + ); + return match?.[1] ?? null; +} + +function looksLikeConversationData( + json: ConversationData | null +): json is ConversationData & { + mapping: NonNullable; + current_node: string; +} { + return !!json && typeof json === 'object' && !!json.mapping && typeof json.current_node === 'string'; +} + +async function attemptAuthoritativeConversationSync(conversationId: string): Promise { + if ( + !conversationId || + completedBootstrapSyncIds.has(conversationId) || + inFlightBootstrapSyncIds.has(conversationId) + ) { + return; + } + + inFlightBootstrapSyncIds.add(conversationId); + + try { + for (const delayMs of TIMING.NEW_CHAT_SYNC_RETRY_DELAYS_MS) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + + if (completedBootstrapSyncIds.has(conversationId)) { + return; + } + + try { + const response = await window.fetch( + `/backend-api/conversation/${encodeURIComponent(conversationId)}` + ); + if (!response.ok || !isJsonResponse(response)) { + continue; + } + + const json = (await response.clone().json().catch(() => null)) as ConversationData | null; + if (looksLikeConversationData(json)) { + completedBootstrapSyncIds.add(conversationId); + return; + } + } catch (error) { + log('Bootstrap authoritative sync attempt failed:', error); + } + } + } finally { + inFlightBootstrapSyncIds.delete(conversationId); + } +} + // ============================================================================ // Fetch Proxy // ============================================================================ @@ -312,10 +374,15 @@ async function interceptedFetch( } // Check if this looks like conversation data - if (!json.mapping || !json.current_node) { + if (!looksLikeConversationData(json)) { return res; } + const requestConversationId = extractConversationRequestId(url); + if (requestConversationId) { + completedBootstrapSyncIds.add(requestConversationId); + } + // Trim the mapping const trimmed = trimMapping(json, cfg.limit); @@ -396,6 +463,7 @@ function patchFetch(): void { window.__LS_PROXY_PATCHED__ = true; log('Fetch proxy installed'); + markProxyReady(); // Notify content script that proxy is ready (use origin for security) window.postMessage({ type: 'lightsession-proxy-ready' }, location.origin); @@ -446,6 +514,31 @@ function setupConfigListener(): void { }) as EventListener); } +function setupBootstrapSyncListener(): void { + if (window.__LS_BOOTSTRAP_SYNC_LISTENER__) { + return; + } + window.__LS_BOOTSTRAP_SYNC_LISTENER__ = true; + + window.addEventListener('lightsession-bootstrap-sync', ((event: CustomEvent) => { + if (typeof event.detail !== 'string') { + return; + } + + try { + const parsed = JSON.parse(event.detail) as { conversationId?: string }; + const conversationId = parsed.conversationId?.trim(); + if (!conversationId) { + return; + } + + void attemptAuthoritativeConversationSync(conversationId); + } catch { + // Ignore malformed bootstrap sync events + } + }) as EventListener); +} + // ============================================================================ // Entry Point // ============================================================================ @@ -465,6 +558,7 @@ function setupConfigListener(): void { } setupConfigListener(); + setupBootstrapSyncListener(); patchFetch(); log('Fetch Proxy loaded'); diff --git a/extension/src/shared/constants.ts b/extension/src/shared/constants.ts index 6790337..8d01825 100644 --- a/extension/src/shared/constants.ts +++ b/extension/src/shared/constants.ts @@ -120,6 +120,11 @@ export const TIMING = { * - Status bar is informational, doesn't need real-time updates */ STATUS_BAR_THROTTLE_MS: 500, + + /** + * Delays before bounded authoritative-sync retries for a new chat (ms). + */ + NEW_CHAT_SYNC_RETRY_DELAYS_MS: [800, 1600, 3200] as const, } as const; // ============================================================================ diff --git a/extension/src/shared/proxy-ready.ts b/extension/src/shared/proxy-ready.ts new file mode 100644 index 0000000..ca16035 --- /dev/null +++ b/extension/src/shared/proxy-ready.ts @@ -0,0 +1,21 @@ +const PROXY_READY_ATTR = 'data-ls-proxy-ready'; +const PROXY_READY_VALUE = '1'; + +export function markProxyReady(root: Element = document.documentElement): void { + root.setAttribute(PROXY_READY_ATTR, PROXY_READY_VALUE); +} + +export function clearProxyReady(root: Element = document.documentElement): void { + root.removeAttribute(PROXY_READY_ATTR); +} + +export function hasProxyReadyMarker(root: Element = document.documentElement): boolean { + return root.getAttribute(PROXY_READY_ATTR) === PROXY_READY_VALUE; +} + +export function isProxyReadySatisfied( + postMessageObserved: boolean, + root: Element = document.documentElement +): boolean { + return postMessageObserved || hasProxyReadyMarker(root); +} diff --git a/extension/src/shared/trimmer.ts b/extension/src/shared/trimmer.ts index a790096..b0fc6b2 100644 --- a/extension/src/shared/trimmer.ts +++ b/extension/src/shared/trimmer.ts @@ -170,14 +170,12 @@ export function trimMapping( } const keptRaw = path.slice(cutIndex); - - // Filter to ONLY user/assistant nodes (remove system/tool that got included) - const kept = keptRaw.filter((id) => { + const keptVisible = keptRaw.filter((id) => { const node = mapping[id]; return node && isVisibleMessage(node); }); - if (kept.length === 0) { + if (keptVisible.length === 0) { return null; } @@ -186,7 +184,12 @@ export function trimMapping( const originalRootNode = originalRootId ? mapping[originalRootId] : null; const hasOriginalRoot = originalRootId && originalRootNode && !isVisibleMessage(originalRootNode); - // Build new mapping with kept nodes + original root + const keptPath = + hasOriginalRoot && originalRootId && keptRaw[0] === originalRootId + ? keptRaw.slice(1) + : keptRaw; + + // Build new mapping with kept suffix + original root const newMapping: ChatMapping = {}; let turnsKept = 0; let prevRole: string | null = null; @@ -196,19 +199,20 @@ export function trimMapping( newMapping[originalRootId] = { ...originalRootNode, parent: null, - children: kept[0] ? [kept[0]] : [], + children: keptPath[0] ? [keptPath[0]] : [], }; } - // Add kept visible nodes - for (let i = 0; i < kept.length; i++) { - const id = kept[i]; + // Add all nodes in the kept suffix, including hidden nodes that belong + // to the visible turns we keep. ChatGPT can depend on these for rendering. + for (let i = 0; i < keptPath.length; i++) { + const id = keptPath[i]; if (!id) continue; // First kept node's parent is originalRoot (if exists), otherwise null const prevId = - i === 0 ? (hasOriginalRoot ? originalRootId : null) : kept[i - 1]; - const nextId = kept[i + 1] ?? null; + i === 0 ? (hasOriginalRoot ? originalRootId : null) : keptPath[i - 1]; + const nextId = keptPath[i + 1] ?? null; const originalNode = mapping[id]; if (originalNode) { @@ -229,8 +233,8 @@ export function trimMapping( const visibleKept = turnsKept; // Use original root if available, otherwise first kept node - const newRoot = hasOriginalRoot ? originalRootId : kept[0]; - const newCurrentNode = kept[kept.length - 1]; + const newRoot = hasOriginalRoot ? originalRootId : keptPath[0]; + const newCurrentNode = keptPath[keptPath.length - 1]; // These should always be defined since kept.length > 0, but TypeScript needs assurance if (!newRoot || !newCurrentNode) { @@ -241,7 +245,7 @@ export function trimMapping( mapping: newMapping, current_node: newCurrentNode, root: newRoot, - keptCount: kept.length, + keptCount: Object.keys(newMapping).length, totalCount, visibleKept, visibleTotal, diff --git a/extension/src/shared/url.ts b/extension/src/shared/url.ts index fcd539a..c241926 100644 --- a/extension/src/shared/url.ts +++ b/extension/src/shared/url.ts @@ -16,3 +16,21 @@ export function isChatGptUrl(url?: string | null): boolean { return false; } } + +export function extractConversationPageId(url?: string | null): string | null { + if (!url) { + return null; + } + + try { + const parsed = new URL(url); + if (!CHATGPT_HOSTS.has(parsed.hostname)) { + return null; + } + + const match = parsed.pathname.match(/^\/c\/([^/]+)\/?$/); + return match?.[1] ?? null; + } catch { + return null; + } +} diff --git a/package-lock.json b/package-lock.json index bbf1dcc..1ca345d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "light-session", - "version": "1.7.0", + "version": "1.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "light-session", - "version": "1.7.0", + "version": "1.7.4", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/package.json b/package.json index 96cae3c..d5dd5bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "light-session", - "version": "1.7.3", + "version": "1.7.4", "type": "module", "description": "LightSession Pro - Browser extension to optimize ChatGPT performance", "engines": { diff --git a/tests/unit/chat-view.test.ts b/tests/unit/chat-view.test.ts index d84b31b..0262f4d 100644 --- a/tests/unit/chat-view.test.ts +++ b/tests/unit/chat-view.test.ts @@ -4,7 +4,11 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { hasConversationTurns, isEmptyChatView } from '../../extension/src/content/chat-view'; +import { + countConversationTurns, + hasConversationTurns, + isEmptyChatView, +} from '../../extension/src/content/chat-view'; describe('chat view helpers', () => { beforeEach(() => { @@ -44,4 +48,24 @@ describe('chat view helpers', () => { expect(hasConversationTurns(document)).toBe(true); expect(isEmptyChatView(document)).toBe(false); }); + + it('counts visible turns using author-role nodes first', () => { + document.body.innerHTML = + '
'; + + expect(countConversationTurns(document)).toBe(2); + }); + + it('falls back to message-id nodes when author-role nodes are absent', () => { + document.body.innerHTML = + '
'; + + expect(countConversationTurns(document)).toBe(3); + }); + + it('returns zero when main has no detected turns', () => { + document.body.innerHTML = '
No messages
'; + + expect(countConversationTurns(document)).toBe(0); + }); }); diff --git a/tests/unit/page-script-bootstrap-dedupe.test.ts b/tests/unit/page-script-bootstrap-dedupe.test.ts new file mode 100644 index 0000000..a19c44b --- /dev/null +++ b/tests/unit/page-script-bootstrap-dedupe.test.ts @@ -0,0 +1,118 @@ +/** + * Isolated test for bootstrap-sync deduplication. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../extension/src/shared/trimmer', () => ({ + trimMapping: vi.fn(), +})); + +import { trimMapping } from '../../extension/src/shared/trimmer'; +import { clearProxyReady } from '../../extension/src/shared/proxy-ready'; + +function createMockResponse( + body: unknown, + options: { + status?: number; + contentType?: string; + url?: string; + } = {} +): Response { + const { + status = 200, + contentType = 'application/json', + url = 'https://chatgpt.com/backend-api/conversation/123', + } = options; + + const headers = new Headers(); + headers.set('content-type', contentType); + + const response = new Response(JSON.stringify(body), { + status, + headers, + }); + + Object.defineProperty(response, 'url', { value: url }); + + return response; +} + +function createConversationData(nodeCount: number = 4) { + const mapping: Record = {}; + const nodes: string[] = []; + + for (let i = 0; i < nodeCount; i++) { + const id = `node-${i}`; + nodes.push(id); + mapping[id] = { + parent: i === 0 ? null : `node-${i - 1}`, + children: i === nodeCount - 1 ? [] : [`node-${i + 1}`], + message: { + author: { role: i % 2 === 0 ? 'user' : 'assistant' }, + }, + }; + } + + return { + mapping, + current_node: nodes[nodes.length - 1], + }; +} + +describe('bootstrap authoritative sync deduplication', () => { + const mockedTrimMapping = vi.mocked(trimMapping); + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + document.body.innerHTML = ''; + clearProxyReady(); + delete (window as unknown as { __LS_PROXY_PATCHED__?: boolean }).__LS_PROXY_PATCHED__; + delete (window as unknown as { __LS_CONFIG__?: unknown }).__LS_CONFIG__; + delete (window as unknown as { __LS_DEBUG__?: boolean }).__LS_DEBUG__; + delete (window as unknown as { __LS_BOOTSTRAP_SYNC_LISTENER__?: boolean }).__LS_BOOTSTRAP_SYNC_LISTENER__; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('deduplicates bootstrap sync once authoritative conversation data is already known', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 2, debug: false })); + + const conversationData = createConversationData(4); + const successResponse = createMockResponse(conversationData, { + url: 'https://chatgpt.com/backend-api/conversation/bootstrap-456', + }); + const nativeFetch = vi.fn().mockResolvedValue(successResponse); + + mockedTrimMapping.mockReturnValue({ + mapping: conversationData.mapping, + current_node: 'node-3', + root: 'node-0', + keptCount: 2, + totalCount: 4, + visibleKept: 2, + visibleTotal: 4, + }); + + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + await import('../../extension/src/page/page-script'); + await window.fetch('https://chatgpt.com/backend-api/conversation/bootstrap-456'); + const callCountAfterAuthoritativeFetch = nativeFetch.mock.calls.length; + + window.dispatchEvent( + new CustomEvent('lightsession-bootstrap-sync', { + detail: JSON.stringify({ conversationId: 'bootstrap-456' }), + }) + ); + + await vi.runAllTimersAsync(); + expect(nativeFetch.mock.calls.length).toBe(callCountAfterAuthoritativeFetch); + }); +}); diff --git a/tests/unit/page-script-bootstrap.test.ts b/tests/unit/page-script-bootstrap.test.ts new file mode 100644 index 0000000..13c6380 --- /dev/null +++ b/tests/unit/page-script-bootstrap.test.ts @@ -0,0 +1,143 @@ +/** + * Focused tests for page-script bootstrap authoritative sync. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../extension/src/shared/trimmer', () => ({ + trimMapping: vi.fn(), +})); + +import { trimMapping } from '../../extension/src/shared/trimmer'; +import { clearProxyReady } from '../../extension/src/shared/proxy-ready'; + +function createMockResponse( + body: unknown, + options: { + status?: number; + contentType?: string; + url?: string; + } = {} +): Response { + const { + status = 200, + contentType = 'application/json', + url = 'https://chatgpt.com/backend-api/conversation/123', + } = options; + + const headers = new Headers(); + headers.set('content-type', contentType); + + const response = new Response(JSON.stringify(body), { + status, + headers, + }); + + Object.defineProperty(response, 'url', { value: url }); + + return response; +} + +function createConversationData(nodeCount: number = 4) { + const mapping: Record = {}; + const nodes: string[] = []; + + for (let i = 0; i < nodeCount; i++) { + const id = `node-${i}`; + nodes.push(id); + mapping[id] = { + parent: i === 0 ? null : `node-${i - 1}`, + children: i === nodeCount - 1 ? [] : [`node-${i + 1}`], + message: { + author: { role: i % 2 === 0 ? 'user' : 'assistant' }, + }, + }; + } + + return { + mapping, + current_node: nodes[nodes.length - 1], + }; +} + +function extractFetchUrlArg(input: unknown): string { + if (input instanceof Request) { + return input.url; + } + if (input instanceof URL) { + return input.href; + } + return String(input); +} + +describe('bootstrap authoritative sync', () => { + const mockedTrimMapping = vi.mocked(trimMapping); + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + document.body.innerHTML = ''; + clearProxyReady(); + delete (window as unknown as { __LS_PROXY_PATCHED__?: boolean }).__LS_PROXY_PATCHED__; + delete (window as unknown as { __LS_CONFIG__?: unknown }).__LS_CONFIG__; + delete (window as unknown as { __LS_DEBUG__?: boolean }).__LS_DEBUG__; + delete (window as unknown as { __LS_BOOTSTRAP_SYNC_LISTENER__?: boolean }).__LS_BOOTSTRAP_SYNC_LISTENER__; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('performs bounded retry for bootstrap sync and dispatches status when a later conversation GET succeeds', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 2, debug: false })); + + const notReadyResponse = createMockResponse({ detail: 'conversation_not_found' }, { status: 404 }); + const conversationData = createConversationData(4); + const successResponse = createMockResponse(conversationData, { + url: 'https://chatgpt.com/backend-api/conversation/bootstrap-123', + }); + const nativeFetch = vi + .fn() + .mockResolvedValueOnce(notReadyResponse) + .mockResolvedValueOnce(notReadyResponse) + .mockResolvedValueOnce(successResponse); + + mockedTrimMapping.mockReturnValue({ + mapping: conversationData.mapping, + current_node: 'node-3', + root: 'node-0', + keptCount: 2, + totalCount: 4, + visibleKept: 2, + visibleTotal: 4, + }); + + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + const statusEvents: unknown[] = []; + window.addEventListener('lightsession-status', ((e: CustomEvent) => { + statusEvents.push(e.detail); + }) as EventListener); + + await import('../../extension/src/page/page-script'); + + window.dispatchEvent( + new CustomEvent('lightsession-bootstrap-sync', { + detail: JSON.stringify({ conversationId: 'bootstrap-123' }), + }) + ); + + await vi.runAllTimersAsync(); + + const bootstrapCalls = nativeFetch.mock.calls.filter(([input]) => { + return extractFetchUrlArg(input) === '/backend-api/conversation/bootstrap-123'; + }); + + expect(bootstrapCalls).toHaveLength(3); + expect(statusEvents.length).toBeGreaterThanOrEqual(1); + }); + +}); diff --git a/tests/unit/page-script.test.ts b/tests/unit/page-script.test.ts index 9d5379e..1e13c78 100644 --- a/tests/unit/page-script.test.ts +++ b/tests/unit/page-script.test.ts @@ -13,6 +13,7 @@ vi.mock('../../extension/src/shared/trimmer', () => ({ })); import { trimMapping } from '../../extension/src/shared/trimmer'; +import { clearProxyReady, hasProxyReadyMarker } from '../../extension/src/shared/proxy-ready'; // ============================================================================ // Test Utilities @@ -74,6 +75,7 @@ function createConversationData(nodeCount: number = 4) { }; } + // ============================================================================ // Helper Function Tests (extracted for testability) // ============================================================================ @@ -89,6 +91,12 @@ describe('isConversationRequest logic', () => { expect(isConversationRequest('GET', '/backend-api/conversation/123')).toBe(true); expect(isConversationRequest('GET', '/backend-api/conversation/123/')).toBe(true); expect(isConversationRequest('GET', '/backend-api/shared_conversation/abc-xyz')).toBe(true); + expect( + isConversationRequest( + 'GET', + '/backend-api/conversation/69d40b5d-10cc-8386-85fa-1751adb7d01b' + ) + ).toBe(true); }); it('returns false for non-GET methods', () => { @@ -114,6 +122,23 @@ describe('isConversationRequest logic', () => { }); }); +describe('extractConversationPageId logic', () => { + it('extracts a conversation id from chatgpt conversation pages', async () => { + const { extractConversationPageId } = await import('../../extension/src/shared/url'); + + expect(extractConversationPageId('https://chatgpt.com/c/abc123')).toBe('abc123'); + expect(extractConversationPageId('https://chat.openai.com/c/test-id/')).toBe('test-id'); + }); + + it('returns null for non-conversation pages', async () => { + const { extractConversationPageId } = await import('../../extension/src/shared/url'); + + expect(extractConversationPageId('https://chatgpt.com/')).toBeNull(); + expect(extractConversationPageId('https://chatgpt.com/gg/abc')).toBeNull(); + expect(extractConversationPageId('https://example.com/c/abc')).toBeNull(); + }); +}); + describe('isJsonResponse logic', () => { // Testing the logic that would be in isJsonResponse const isJsonResponse = (contentType: string | null): boolean => { @@ -511,9 +536,11 @@ describe('fetch interception no-trim path (visibleKept === visibleTotal)', () => vi.resetModules(); vi.clearAllMocks(); localStorage.clear(); + clearProxyReady(); delete (window as unknown as { __LS_PROXY_PATCHED__?: boolean }).__LS_PROXY_PATCHED__; delete (window as unknown as { __LS_CONFIG__?: unknown }).__LS_CONFIG__; delete (window as unknown as { __LS_DEBUG__?: boolean }).__LS_DEBUG__; + delete (window as unknown as { __LS_BOOTSTRAP_SYNC_LISTENER__?: boolean }).__LS_BOOTSTRAP_SYNC_LISTENER__; }); afterEach(() => { @@ -585,6 +612,30 @@ describe('fetch interception no-trim path (visibleKept === visibleTotal)', () => expect(last.removed).toBe(0); }); + it('marks the document as proxy-ready when the fetch proxy is installed', async () => { + localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 10, debug: false })); + + const conversationData = createConversationData(1); + const nativeFetch = vi.fn(async () => createMockResponse(conversationData)); + (globalThis as unknown as { fetch: typeof fetch }).fetch = nativeFetch; + + mockedTrimMapping.mockReturnValue({ + mapping: conversationData.mapping, + current_node: 'node-0', + root: 'node-0', + keptCount: 1, + totalCount: 1, + visibleKept: 1, + visibleTotal: 1, + }); + + expect(hasProxyReadyMarker()).toBe(false); + + await import('../../extension/src/page/page-script'); + + expect(hasProxyReadyMarker()).toBe(true); + }); + it('returns original response when visibleKept === visibleTotal (exact limit: 5 of 5)', async () => { localStorage.setItem('ls_config', JSON.stringify({ enabled: true, limit: 5, debug: false })); @@ -650,9 +701,11 @@ describe('config gating in fetch interception', () => { vi.clearAllMocks(); localStorage.clear(); document.body.innerHTML = ''; + clearProxyReady(); delete (window as unknown as { __LS_PROXY_PATCHED__?: boolean }).__LS_PROXY_PATCHED__; delete (window as unknown as { __LS_CONFIG__?: unknown }).__LS_CONFIG__; delete (window as unknown as { __LS_DEBUG__?: boolean }).__LS_DEBUG__; + delete (window as unknown as { __LS_BOOTSTRAP_SYNC_LISTENER__?: boolean }).__LS_BOOTSTRAP_SYNC_LISTENER__; }); it('skips trimming when config is not received', async () => { @@ -694,4 +747,5 @@ describe('config gating in fetch interception', () => { expect(nativeFetch).toHaveBeenCalledTimes(1); expect(mockedTrimMapping).toHaveBeenCalledTimes(1); }); + }); diff --git a/tests/unit/proxy-ready.test.ts b/tests/unit/proxy-ready.test.ts new file mode 100644 index 0000000..4828a27 --- /dev/null +++ b/tests/unit/proxy-ready.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + clearProxyReady, + hasProxyReadyMarker, + isProxyReadySatisfied, + markProxyReady, +} from '../../extension/src/shared/proxy-ready'; + +describe('proxy-ready marker', () => { + beforeEach(() => { + clearProxyReady(); + }); + + it('marks and clears the durable proxy-ready marker on the document root', () => { + expect(hasProxyReadyMarker()).toBe(false); + markProxyReady(); + expect(hasProxyReadyMarker()).toBe(true); + clearProxyReady(); + expect(hasProxyReadyMarker()).toBe(false); + }); + + it('treats the marker as sufficient even when the postMessage handshake was missed', () => { + expect(isProxyReadySatisfied(false)).toBe(false); + markProxyReady(); + expect(isProxyReadySatisfied(false)).toBe(true); + }); + + it('treats an observed postMessage handshake as sufficient without the marker', () => { + expect(isProxyReadySatisfied(true)).toBe(true); + }); +}); diff --git a/tests/unit/status-bar.test.ts b/tests/unit/status-bar.test.ts index da4626e..d8c66c2 100644 --- a/tests/unit/status-bar.test.ts +++ b/tests/unit/status-bar.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TIMING } from '../../extension/src/shared/constants'; import { showStatusBar, + showBootstrapStatus, updateStatusBar, resetAccumulatedTrimmed, refreshStatusBar, @@ -14,6 +15,7 @@ import { } from '../../extension/src/content/status-bar'; const WAITING_TEXT = 'LightSession · waiting for messages…'; +const PENDING_TEXT = 'LightSession · sync pending…'; describe('status bar behavior', () => { beforeEach(() => { @@ -107,4 +109,12 @@ describe('status bar behavior', () => { expect(refreshed).not.toBeNull(); expect(refreshed?.textContent).toBe('LightSession · last 2 · 2 trimmed'); }); + + it('shows pending bootstrap text before authoritative status arrives', () => { + showStatusBar(); + showBootstrapStatus(); + + const bar = document.getElementById('lightsession-status-bar'); + expect(bar?.textContent).toBe(PENDING_TEXT); + }); }); diff --git a/tests/unit/trimmer.test.ts b/tests/unit/trimmer.test.ts index 4178ba7..5b701ad 100644 --- a/tests/unit/trimmer.test.ts +++ b/tests/unit/trimmer.test.ts @@ -276,6 +276,52 @@ describe('trimMapping - hidden roles', () => { expect(result!.visibleKept).toBe(2); expect(result!.visibleTotal).toBe(2); }); + + it('preserves hidden nodes that belong to the kept suffix when trimming', () => { + const { mapping, current_node } = buildConversation([ + null, + 'user', + 'thinking', + 'tool', + 'assistant', + 'user', + 'thinking', + 'tool', + 'assistant', + ]); + const result = trimMapping({ mapping, current_node }, 2); + + expect(result).not.toBeNull(); + expect(result!.mapping['node-5']).toBeDefined(); + expect(result!.mapping['node-6']).toBeDefined(); + expect(result!.mapping['node-7']).toBeDefined(); + expect(result!.mapping['node-8']).toBeDefined(); + expect(result!.mapping['node-0']?.children).toEqual(['node-5']); + expect(result!.mapping['node-5']?.children).toEqual(['node-6']); + expect(result!.mapping['node-6']?.children).toEqual(['node-7']); + expect(result!.mapping['node-7']?.children).toEqual(['node-8']); + }); + + it('keeps a hidden first node in the suffix as the root child when trimming to one visible turn', () => { + const { mapping, current_node } = buildConversation([ + null, + 'user', + 'assistant', + 'user', + 'thinking', + 'tool', + 'assistant', + ]); + const result = trimMapping({ mapping, current_node }, 1); + + expect(result).not.toBeNull(); + expect(result!.root).toBe('node-0'); + expect(result!.mapping['node-0']?.children).toEqual(['node-4']); + expect(result!.mapping['node-4']?.message?.author?.role).toBe('thinking'); + expect(result!.mapping['node-5']?.message?.author?.role).toBe('tool'); + expect(result!.current_node).toBe('node-6'); + expect(result!.visibleKept).toBe(1); + }); }); // ============================================================================