diff --git a/CHANGELOG.md b/CHANGELOG.md index 952c3e0..179d854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,10 @@ version with its date and start a fresh empty `[Unreleased]` above it. collapse), the toolbar wraps instead of clipping, and the permission mode and model dropdowns shrink to stay inside the sidebar, with long model names ellipsized. +- Startup session restore no longer fails silently: when the tab + layout, an individual tab, session metadata, or conversation history + cannot be read, Qoderian now shows a single notice with the issue + count and logs per-stage details to the developer console. ## [1.0.4] - 2026-08-12 diff --git a/src/app/storage/app-storage.ts b/src/app/storage/app-storage.ts index 14ad3f8..b5edbb8 100644 --- a/src/app/storage/app-storage.ts +++ b/src/app/storage/app-storage.ts @@ -1,6 +1,7 @@ import type { Plugin } from 'obsidian'; import { Notice } from 'obsidian'; +import { reportRestoreIssue } from '../../core/diagnostics/restore-report'; import { VaultFileAdapter } from '../../core/storage/vault-file-adapter'; import type { AppTabManagerState } from '../../core/types/services'; import { @@ -14,6 +15,10 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export class QoderianStorage { readonly qoderianSettings: QoderianSettingsStorage; readonly sessions: SessionStorage; @@ -62,11 +67,17 @@ export class QoderianStorage { try { const data: unknown = await this.plugin.loadData(); if (!isRecord(data) || !data.tabManagerState) { + await this.reportUnreadablePluginData(); return null; } - return this.validateTabManagerState(data.tabManagerState); - } catch { + const state = this.validateTabManagerState(data.tabManagerState); + if (!state) { + reportRestoreIssue('layout', 'Persisted tab layout failed validation.'); + } + return state; + } catch (error) { + reportRestoreIssue('layout', `Failed to read persisted tab layout: ${errorMessage(error)}`); return null; } } @@ -75,6 +86,26 @@ export class QoderianStorage { return this.adapter; } + /** + * Obsidian may return null from loadData for corrupt JSON instead of + * throwing; a non-empty raw data file therefore means unreadable content. + */ + private async reportUnreadablePluginData(): Promise { + const pluginId = this.plugin.manifest?.id ?? 'qoderian'; + const dataPath = `${this.plugin.app.vault.configDir}/plugins/${pluginId}/data.json`; + try { + const raw = await this.adapter.read(dataPath); + if (raw.trim().length > 0) { + reportRestoreIssue( + 'layout', + `Plugin data file "${dataPath}" could not be read; loaded an empty layout instead.`, + ); + } + } catch { + // Missing file: first run, nothing to report. + } + } + private async ensureDirectories(): Promise { await this.adapter.ensureFolder(QODERIAN_STORAGE_PATH); await this.adapter.ensureFolder(SESSIONS_PATH); diff --git a/src/app/storage/session-storage.ts b/src/app/storage/session-storage.ts index 412d8fd..92e9c1b 100644 --- a/src/app/storage/session-storage.ts +++ b/src/app/storage/session-storage.ts @@ -1,3 +1,4 @@ +import { reportRestoreIssue } from '../../core/diagnostics/restore-report'; import type { VaultFileAdapter } from '../../core/storage/vault-file-adapter'; import type { SessionMetadata } from '../../core/types'; import { SESSIONS_PATH } from './storage-paths'; @@ -21,7 +22,8 @@ export class SessionStorage { try { const content = await this.adapter.read(this.getMetadataPath(id)); return JSON.parse(content) as SessionMetadata; - } catch { + } catch (error) { + reportRestoreIssue('metadata', `Failed to read session metadata "${id}": ${errorMessage(error)}`); return null; } } @@ -37,8 +39,9 @@ export class SessionStorage { try { const content = await this.adapter.read(filePath); metas.push(JSON.parse(content) as SessionMetadata); - } catch { - // Skip files that fail to load. + } catch (error) { + // Skip files that fail to load, but surface the skip. + reportRestoreIssue('metadata', `Failed to read session metadata file "${filePath}": ${errorMessage(error)}`); } } @@ -49,8 +52,13 @@ export class SessionStorage { try { const files = await this.adapter.listFiles(SESSIONS_PATH); return files.filter((filePath) => filePath.endsWith('.meta.json')); - } catch { + } catch (error) { + reportRestoreIssue('metadata', `Failed to list session metadata files: ${errorMessage(error)}`); return []; } } } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/core/diagnostics/restore-report.ts b/src/core/diagnostics/restore-report.ts new file mode 100644 index 0000000..2a9f347 --- /dev/null +++ b/src/core/diagnostics/restore-report.ts @@ -0,0 +1,52 @@ +/** + * Startup restore diagnostics. + * + * The restore pipeline (tab layout read, per-tab rebuild, session metadata, + * conversation history hydration) used to swallow failures silently, leaving + * users with missing tabs or empty conversations and no explanation. Each + * stage reports issues here; the chat view drains the collected issues once + * restore finishes and surfaces a single aggregated notice. + */ + +export type RestoreStage = 'layout' | 'tab' | 'metadata' | 'history'; + +export interface RestoreIssue { + stage: RestoreStage; + detail: string; +} + +let activeIssues: RestoreIssue[] | null = null; + +/** Opens the collection window (called once on plugin load). */ +export function beginRestoreReport(): void { + activeIssues = []; +} + +/** + * Records a restore issue. Always logged for debugging; only collected into + * the user-facing report while the window is open. + */ +export function reportRestoreIssue(stage: RestoreStage, detail: string): void { + console.error(`[qoderian-restore:${stage}] ${detail}`); + activeIssues?.push({ stage, detail }); +} + +/** + * Closes the window (restore finished) and returns the collected issues. + * Duplicates are dropped: some stages run twice during startup (e.g. the + * tab layout is read by both loadSettings and the chat view), and counting + * the same root cause twice would inflate the aggregated notice. + */ +export function finishRestoreReport(): RestoreIssue[] { + const issues = activeIssues ?? []; + activeIssues = null; + const seen = new Set(); + return issues.filter((issue) => { + const key = `${issue.stage}:${issue.detail}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 5df345a..968f267 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -1,7 +1,9 @@ import type { EventRef, WorkspaceLeaf } from 'obsidian'; import { ItemView, Notice, Scope, setIcon } from 'obsidian'; +import { finishRestoreReport } from '../../core/diagnostics/restore-report'; import { VIEW_TYPE_QODERIAN } from '../../core/types'; +import { t } from '../../i18n/i18n'; import type QoderianPlugin from '../../main'; import { fetchCreditsUsage } from '../../qoder/services/credits-usage'; import { @@ -607,17 +609,24 @@ export class QoderianView extends ItemView { // ============================================ private async restoreOrCreateTabs(): Promise { - if (!this.tabManager) return; - - // Try to restore from persisted state - const persistedState = await this.plugin.storage.getTabManagerState(); - if (persistedState && persistedState.openTabs.length > 0) { - await this.tabManager.restoreState(persistedState); - return; + try { + if (!this.tabManager) return; + + // Try to restore from persisted state + const persistedState = await this.plugin.storage.getTabManagerState(); + if (persistedState && persistedState.openTabs.length > 0) { + await this.tabManager.restoreState(persistedState); + } else { + // Fallback: create a new empty tab + await this.tabManager.createTab(); + } + } finally { + // Drain startup restore diagnostics and surface them once, aggregated. + const issues = finishRestoreReport(); + if (issues.length > 0) { + new Notice(t('restore.failed', { count: issues.length }), 10000); + } } - - // Fallback: create a new empty tab - await this.tabManager.createTab(); } /** diff --git a/src/features/chat/tabs/tab-manager.ts b/src/features/chat/tabs/tab-manager.ts index c6f82c8..c5c8c0e 100644 --- a/src/features/chat/tabs/tab-manager.ts +++ b/src/features/chat/tabs/tab-manager.ts @@ -1,5 +1,6 @@ import { Notice } from 'obsidian'; +import { reportRestoreIssue } from '../../../core/diagnostics/restore-report'; import type { ChatRuntime } from '../../../core/runtime/chat-runtime'; import { t } from '../../../i18n/i18n'; import type QoderianPlugin from '../../../main'; @@ -537,8 +538,9 @@ export class TabManager implements TabManagerInterface { activate: false, ...(typeof tabState.draftModel === 'string' ? { draftModel: tabState.draftModel } : {}), }); - } catch { - // Continue restoring other tabs + } catch (error) { + // Continue restoring other tabs, but surface the skipped one. + reportRestoreIssue('tab', `Failed to restore tab "${tabState.tabId}": ${errorMessage(error)}`); } } } finally { @@ -557,8 +559,8 @@ export class TabManager implements TabManagerInterface { if (targetTabId) { try { await this.switchToTab(targetTabId); - } catch { - // Ignore switch errors + } catch (error) { + reportRestoreIssue('tab', `Failed to activate restored tab "${targetTabId}": ${errorMessage(error)}`); } } @@ -634,3 +636,7 @@ export class TabManager implements TabManagerInterface { this.activeTabId = null; } } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 807469e..128c14f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -25,6 +25,9 @@ "send": "Nachricht senden", "stop": "Generierung stoppen" }, + "restore": { + "failed": "Einige Tabs oder Unterhaltungen konnten nicht wiederhergestellt werden ({count} Problem(e)). Details in der Entwicklerkonsole." + }, "commands": { "openView": "Chat-Ansicht öffnen", "inlineEdit": "Inline-Bearbeitung", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 09fe464..1646cc4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -25,6 +25,9 @@ "send": "Send message", "stop": "Stop generation" }, + "restore": { + "failed": "Some of your previous tabs or conversations could not be restored ({count} issue(s)). Details are in the developer console." + }, "commands": { "openView": "Open chat view", "inlineEdit": "Inline edit", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0c3051b..07d9e6c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -25,6 +25,9 @@ "send": "Enviar mensaje", "stop": "Detener generación" }, + "restore": { + "failed": "No se pudieron restaurar algunas pestañas o conversaciones ({count} problema(s)). Detalles en la consola de desarrollador." + }, "commands": { "openView": "Abrir vista de chat", "inlineEdit": "Edición en línea", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f91c9f6..77ac30a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -25,6 +25,9 @@ "send": "Envoyer le message", "stop": "Arrêter la génération" }, + "restore": { + "failed": "Certains onglets ou conversations n'ont pas pu être restaurés ({count} problème(s)). Détails dans la console développeur." + }, "commands": { "openView": "Ouvrir la vue de discussion", "inlineEdit": "Édition en ligne", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7dc3572..4d7910b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -25,6 +25,9 @@ "send": "メッセージを送信", "stop": "生成を停止" }, + "restore": { + "failed": "一部のタブまたは会話を復元できませんでした({count} 件の問題)。詳細は開発者コンソールを確認してください。" + }, "commands": { "openView": "チャットビューを開く", "inlineEdit": "インライン編集", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 7033a0f..8271196 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -25,6 +25,9 @@ "send": "메시지 보내기", "stop": "생성 중지" }, + "restore": { + "failed": "이전 탭 또는 대화를 완전히 복원하지 못했습니다(문제 {count}건). 자세한 내용은 개발자 콘솔을 확인하세요." + }, "commands": { "openView": "채팅 뷰 열기", "inlineEdit": "인라인 편집", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a7d637d..be38e07 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -25,6 +25,9 @@ "send": "Enviar mensagem", "stop": "Parar geração" }, + "restore": { + "failed": "Algumas abas ou conversas não puderam ser restauradas ({count} problema(s)). Detalhes no console do desenvolvedor." + }, "commands": { "openView": "Abrir visualização de chat", "inlineEdit": "Edição inline", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f240c8c..71874bc 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -25,6 +25,9 @@ "send": "Отправить сообщение", "stop": "Остановить генерацию" }, + "restore": { + "failed": "Не удалось восстановить часть вкладок или бесед (проблем: {count}). Подробности — в консоли разработчика." + }, "commands": { "openView": "Открыть представление чата", "inlineEdit": "Встроенное редактирование", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5309a52..6c9644d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -25,6 +25,9 @@ "send": "发送消息", "stop": "停止生成" }, + "restore": { + "failed": "部分标签或会话未能恢复({count} 个问题)。详情已输出到开发者控制台。" + }, "commands": { "openView": "打开聊天视图", "inlineEdit": "内联编辑", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 22cb72e..7d04f1c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -25,6 +25,9 @@ "send": "傳送訊息", "stop": "停止生成" }, + "restore": { + "failed": "部分分頁或工作階段未能恢復({count} 個問題)。詳情已輸出到開發者主控台。" + }, "commands": { "openView": "開啟聊天視圖", "inlineEdit": "行內編輯", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 777a5a8..3c4aed1 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -36,6 +36,7 @@ export type TranslationKey = // Composer - send/stop action button | 'composer.send' | 'composer.stop' + | 'restore.failed' // Chat - Rewind | 'chat.rewind.confirmMessage' diff --git a/src/main.ts b/src/main.ts index d2855b1..170b1b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import type { Editor, WorkspaceLeaf } from 'obsidian'; import { addIcon, MarkdownView, Notice, Plugin } from 'obsidian'; import { QoderianStorage } from './app/storage/app-storage'; +import { beginRestoreReport } from './core/diagnostics/restore-report'; import { buildCursorContext } from './core/editor/editor-context'; import { getVaultPath } from './core/fs/path'; import type { @@ -398,6 +399,8 @@ export default class QoderianPlugin extends Plugin { } async loadSettings() { + // Open the restore diagnostics window before any persisted state is read. + beginRestoreReport(); this.storage = new QoderianStorage(this); const { qoderian } = await this.storage.initialize(); this.lastKnownTabManagerState = await this.storage.getTabManagerState(); diff --git a/src/qoder/history/qoder-conversation-history-service.ts b/src/qoder/history/qoder-conversation-history-service.ts index ae46e1e..4a077a8 100644 --- a/src/qoder/history/qoder-conversation-history-service.ts +++ b/src/qoder/history/qoder-conversation-history-service.ts @@ -1,3 +1,4 @@ +import { reportRestoreIssue } from '../../core/diagnostics/restore-report'; import type { AsyncSubagentStatus, ChatMessage, @@ -411,6 +412,17 @@ export class QoderConversationHistoryService { } const allSessionsMissing = missingSessionCount === allSessionIds.length; + if (errorCount > 0) { + reportRestoreIssue( + 'history', + `Conversation "${conversation.id}": ${errorCount} of ${allSessionIds.length} session file(s) failed to load.`, + ); + } else if (allSessionsMissing) { + reportRestoreIssue( + 'history', + `Conversation "${conversation.id}": session file(s) missing on disk.`, + ); + } const hasLoadErrors = errorCount > 0 && successCount === 0 && !allSessionsMissing; if (hasLoadErrors) { return; diff --git a/tests/unit/app/storage/app-storage.test.ts b/tests/unit/app/storage/app-storage.test.ts index d002ba7..c1d63d2 100644 --- a/tests/unit/app/storage/app-storage.test.ts +++ b/tests/unit/app/storage/app-storage.test.ts @@ -1,4 +1,8 @@ import { QoderianStorage } from '@/app/storage/app-storage'; +import { + beginRestoreReport, + finishRestoreReport, +} from '@/core/diagnostics/restore-report'; describe('QoderianStorage', () => { it('serializes tab layout read-modify-write operations', async () => { @@ -37,3 +41,97 @@ describe('QoderianStorage', () => { expect(data).toEqual({ unrelated: true, tabManagerState: second }); }); }); + +describe('QoderianStorage tab layout restore diagnostics', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + finishRestoreReport(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function makeStorage(loadData: () => Promise): QoderianStorage { + const plugin = { + app: { vault: { adapter: {} } }, + loadData: jest.fn(loadData), + saveData: jest.fn(async () => {}), + } as any; + return new QoderianStorage(plugin); + } + + it('reports a layout issue when loadData throws', async () => { + const storage = makeStorage(async () => { + throw new Error('data.json corrupt'); + }); + + beginRestoreReport(); + expect(await storage.getTabManagerState()).toBeNull(); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + stage: 'layout', + detail: expect.stringContaining('data.json corrupt'), + }); + }); + + it('reports a layout issue when the persisted layout fails validation', async () => { + const storage = makeStorage(async () => ({ tabManagerState: { openTabs: 'nope' } })); + + beginRestoreReport(); + expect(await storage.getTabManagerState()).toBeNull(); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0].stage).toBe('layout'); + }); + + it('does not report when no layout was persisted yet', async () => { + const storage = makeStorage(async () => ({})); + + beginRestoreReport(); + expect(await storage.getTabManagerState()).toBeNull(); + + expect(finishRestoreReport()).toEqual([]); + }); + + it('reports a layout issue when loadData yields null but the raw data file is non-empty', async () => { + const plugin = { + app: { + vault: { + adapter: { + read: jest.fn(async () => '{broken'), + }, + }, + }, + loadData: jest.fn(async () => null), + saveData: jest.fn(async () => {}), + } as any; + const storage = new QoderianStorage(plugin); + + beginRestoreReport(); + expect(await storage.getTabManagerState()).toBeNull(); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + stage: 'layout', + detail: expect.stringContaining('could not be read'), + }); + }); + + it('reports the same unreadable data file only once across repeated reads', async () => { + const storage = makeStorage(async () => { + throw new Error('data.json corrupt'); + }); + + beginRestoreReport(); + await storage.getTabManagerState(); + await storage.getTabManagerState(); + + // Duplicate issues are dropped when the report window closes. + expect(finishRestoreReport()).toHaveLength(1); + }); +}); diff --git a/tests/unit/app/storage/session-storage.test.ts b/tests/unit/app/storage/session-storage.test.ts new file mode 100644 index 0000000..41d98ed --- /dev/null +++ b/tests/unit/app/storage/session-storage.test.ts @@ -0,0 +1,82 @@ +import { SessionStorage } from '@/app/storage/session-storage'; +import { + beginRestoreReport, + finishRestoreReport, +} from '@/core/diagnostics/restore-report'; + +type FakeAdapter = { + read: jest.Mock; + listFiles: jest.Mock; +}; + +function makeStorage(read: jest.Mock, listFiles?: jest.Mock): SessionStorage { + const adapter: FakeAdapter = { read, listFiles: listFiles ?? jest.fn(async () => []) }; + return new SessionStorage(adapter as any); +} + +describe('SessionStorage restore diagnostics', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + finishRestoreReport(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reports a metadata issue when metadata cannot be read', async () => { + const storage = makeStorage(jest.fn(async () => { + throw new Error('file missing'); + })); + + beginRestoreReport(); + expect(await storage.loadMetadata('conv-1')).toBeNull(); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + stage: 'metadata', + detail: expect.stringContaining('conv-1'), + }); + }); + + it('reports a metadata issue for corrupt metadata json', async () => { + const storage = makeStorage(jest.fn(async () => '{oops')); + + beginRestoreReport(); + expect(await storage.loadMetadata('conv-2')).toBeNull(); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0].stage).toBe('metadata'); + }); + + it('does not report when metadata loads fine', async () => { + const storage = makeStorage(jest.fn(async () => '{"id":"conv-3"}')); + + beginRestoreReport(); + await storage.loadMetadata('conv-3'); + + expect(finishRestoreReport()).toEqual([]); + }); + + it('skips corrupt files in listMetadata but reports each skip', async () => { + const read = jest.fn(async (filePath: string) => { + if (filePath.endsWith('good.meta.json')) return '{"id":"good"}'; + throw new Error('corrupt'); + }); + const listFiles = jest.fn(async () => ['good.meta.json', 'bad.meta.json']); + const storage = makeStorage(read, listFiles); + + beginRestoreReport(); + const metas = await storage.listMetadata(); + + expect(metas).toHaveLength(1); + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + stage: 'metadata', + detail: expect.stringContaining('bad.meta.json'), + }); + }); +}); diff --git a/tests/unit/core/diagnostics/restore-report.test.ts b/tests/unit/core/diagnostics/restore-report.test.ts new file mode 100644 index 0000000..7b4b779 --- /dev/null +++ b/tests/unit/core/diagnostics/restore-report.test.ts @@ -0,0 +1,65 @@ +import { + beginRestoreReport, + finishRestoreReport, + reportRestoreIssue, +} from '@/core/diagnostics/restore-report'; + +describe('restore report', () => { + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + // Reset any window left open by a previous test. + finishRestoreReport(); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it('collects issues reported while the window is open', () => { + beginRestoreReport(); + reportRestoreIssue('layout', 'layout corrupt'); + reportRestoreIssue('tab', 'tab-1 failed'); + + expect(finishRestoreReport()).toEqual([ + { stage: 'layout', detail: 'layout corrupt' }, + { stage: 'tab', detail: 'tab-1 failed' }, + ]); + }); + + it('stops collecting once the window is closed', () => { + beginRestoreReport(); + reportRestoreIssue('history', 'inside window'); + expect(finishRestoreReport()).toHaveLength(1); + + reportRestoreIssue('history', 'after window'); + expect(finishRestoreReport()).toEqual([]); + }); + + it('logs issues reported outside the window without collecting them', () => { + reportRestoreIssue('metadata', 'no window'); + + expect(errorSpy).toHaveBeenCalledWith('[qoderian-restore:metadata] no window'); + expect(finishRestoreReport()).toEqual([]); + }); + + it('always logs issues to the console for debugging', () => { + beginRestoreReport(); + reportRestoreIssue('tab', 'boom'); + + expect(errorSpy).toHaveBeenCalledWith('[qoderian-restore:tab] boom'); + }); + + it('drops duplicate issues with the same stage and detail', () => { + beginRestoreReport(); + reportRestoreIssue('layout', 'data.json unreadable'); + reportRestoreIssue('layout', 'data.json unreadable'); + reportRestoreIssue('tab', 'tab-1 failed'); + + expect(finishRestoreReport()).toEqual([ + { stage: 'layout', detail: 'data.json unreadable' }, + { stage: 'tab', detail: 'tab-1 failed' }, + ]); + }); +}); diff --git a/tests/unit/features/chat/tabs/tab-manager-restore.test.ts b/tests/unit/features/chat/tabs/tab-manager-restore.test.ts new file mode 100644 index 0000000..cbb4701 --- /dev/null +++ b/tests/unit/features/chat/tabs/tab-manager-restore.test.ts @@ -0,0 +1,64 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import { + beginRestoreReport, + finishRestoreReport, +} from '@/core/diagnostics/restore-report'; +import { TabManager } from '@/features/chat/tabs/tab-manager'; + +describe('TabManager restore diagnostics', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + finishRestoreReport(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function makeManager(): TabManager { + const plugin = { + settings: { maxTabs: 5 }, + getConversationById: jest.fn(async () => undefined), + } as any; + const containerEl = createMockEl() as unknown as HTMLElement; + return new TabManager(plugin, containerEl, {} as any, {}); + } + + it('reports a tab issue for each tab that fails to restore and keeps going', async () => { + const manager = makeManager(); + jest.spyOn(manager, 'createTab').mockImplementation(async (_conversationId, tabId) => { + if (tabId === 'bad') throw new Error('metadata missing'); + return { id: tabId } as any; + }); + + beginRestoreReport(); + await manager.restoreState({ + openTabs: [ + { tabId: 'good', conversationId: 'conv-good' }, + { tabId: 'bad', conversationId: 'conv-bad' }, + ], + activeTabId: null, + }); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + stage: 'tab', + detail: expect.stringContaining('bad'), + }); + }); + + it('does not report when every tab restores', async () => { + const manager = makeManager(); + jest.spyOn(manager, 'createTab').mockResolvedValue({ id: 'tab' } as any); + + beginRestoreReport(); + await manager.restoreState({ + openTabs: [{ tabId: 'good', conversationId: 'conv-good' }], + activeTabId: null, + }); + + expect(finishRestoreReport()).toEqual([]); + }); +}); diff --git a/tests/unit/qoder/history/qoder-conversation-history-service.test.ts b/tests/unit/qoder/history/qoder-conversation-history-service.test.ts index d645888..b363e89 100644 --- a/tests/unit/qoder/history/qoder-conversation-history-service.test.ts +++ b/tests/unit/qoder/history/qoder-conversation-history-service.test.ts @@ -7,8 +7,10 @@ jest.mock('@/qoder/history/qoder-history-store', () => ({ sdkSessionExists: jest.fn(), })); +import { beginRestoreReport, finishRestoreReport } from '@/core/diagnostics/restore-report'; import type { Conversation } from '@/core/types'; import { QoderConversationHistoryService } from '@/qoder/history/qoder-conversation-history-service'; +import { loadSDKSessionMessages, sdkSessionExists } from '@/qoder/history/qoder-history-store'; describe('QoderConversationHistoryService deletion', () => { beforeEach(() => { @@ -52,3 +54,70 @@ describe('QoderConversationHistoryService deletion', () => { expect(mockDeleteSDKSessionArtifacts).not.toHaveBeenCalled(); }); }); + +describe('QoderConversationHistoryService restore diagnostics', () => { + const mockLoad = loadSDKSessionMessages as jest.Mock; + const mockExists = sdkSessionExists as jest.Mock; + + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + finishRestoreReport(); + mockLoad.mockReset(); + mockExists.mockReset(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const conversation = { + id: 'conv-1', + title: 'Conversation', + createdAt: 1, + updatedAt: 2, + sessionId: 'sess-1', + messages: [], + } as Conversation; + + it('reports a history issue when session files fail to load', async () => { + mockExists.mockReturnValue(true); + mockLoad.mockResolvedValue({ messages: [], skippedLines: 0, error: 'read failed' }); + const service = new QoderConversationHistoryService(); + + beginRestoreReport(); + await service.hydrateConversationHistory(conversation, '/vault'); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + stage: 'history', + detail: expect.stringContaining('conv-1'), + }); + }); + + it('reports a history issue when session files are missing on disk', async () => { + mockExists.mockReturnValue(false); + const service = new QoderConversationHistoryService(); + + beginRestoreReport(); + await service.hydrateConversationHistory(conversation, '/vault'); + + const issues = finishRestoreReport(); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + stage: 'history', + detail: expect.stringContaining('missing'), + }); + }); + + it('does not report when hydration succeeds', async () => { + mockExists.mockReturnValue(true); + mockLoad.mockResolvedValue({ messages: [], skippedLines: 0 }); + const service = new QoderConversationHistoryService(); + + beginRestoreReport(); + await service.hydrateConversationHistory(conversation, '/vault'); + + expect(finishRestoreReport()).toEqual([]); + }); +});