diff --git a/src/main/AiProviders.js b/src/main/AiProviders.js index bd3e50a..74e46a1 100644 --- a/src/main/AiProviders.js +++ b/src/main/AiProviders.js @@ -12,6 +12,10 @@ * - `ollama` → http://localhost:11434/v1 (OpenAI-compatible) * - `lmstudio` → http://localhost:1234/v1 (OpenAI-compatible) * - `openai-compatible`→ any baseUrl speaking the OpenAI chat schema + * - `anthropic-compatible` → any baseUrl speaking the Anthropic messages + * schema (LiteLLM proxies, Bedrock gateways, local Claude-compatible + * servers); baseUrl is required, the API key is optional because many + * proxies are keyless or front their own auth * * The module takes an injectable `fetchImpl` (defaulting to global fetch) so * tests can stub the network without monkey-patching. @@ -33,6 +37,7 @@ const PROVIDER_DEFAULTS = { ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1' }, lmstudio: { baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model' }, 'openai-compatible': { baseUrl: '', defaultModel: '' }, + 'anthropic-compatible': { baseUrl: '', defaultModel: 'claude-3-5-sonnet-latest' }, }; /** Provider ids that speak the OpenAI chat-completions schema. */ @@ -72,8 +77,13 @@ function resolveSettings({ ); } - // Local providers (ollama/lmstudio) don't need a key; remote ones do. - const needsKey = requireKey && provider !== 'ollama' && provider !== 'lmstudio'; + // Local providers (ollama/lmstudio) and self-hosted *-compatible proxies + // don't need a key; branded cloud endpoints do. + const needsKey = + requireKey && + provider !== 'ollama' && + provider !== 'lmstudio' && + provider !== 'anthropic-compatible'; if (needsKey && !apiKey) { throw new AiProviderError( `The "${provider}" provider needs an API key. Add one in AI Assistant settings.`, @@ -158,13 +168,27 @@ async function callAnthropic(settings, { system, messages }, fetchImpl, timeoutM system: system || undefined, messages: messages.map((m) => ({ role: m.role, content: m.content })), }; - const response = await fetchImpl(`${settings.baseUrl}/v1/messages`, { + const headers = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }; + if (settings.apiKey) { + // Official API keys travel via x-api-key; many compatible gateways + // (LiteLLM proxies, Bedrock fronts) expect a Bearer token instead. + // Sending both is harmless for the official endpoint and maximizes + // proxy compatibility. + headers['x-api-key'] = settings.apiKey; + headers.Authorization = `Bearer ${settings.apiKey}`; + } + // Bases may or may not already carry the /v1 prefix — handle both so a + // "http://host:4000" proxy base and an "http://host:4000/v1" style base + // both land on a single /v1/messages path. + const messagesUrl = settings.baseUrl.endsWith('/v1') + ? `${settings.baseUrl}/messages` + : `${settings.baseUrl}/v1/messages`; + const response = await fetchImpl(messagesUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': settings.apiKey, - 'anthropic-version': '2023-06-01', - }, + headers, body: JSON.stringify(body), signal: controller.signal, }); diff --git a/src/plugins/built-in/ai-assistant/index.js b/src/plugins/built-in/ai-assistant/index.js index 74287e6..046b7f6 100644 --- a/src/plugins/built-in/ai-assistant/index.js +++ b/src/plugins/built-in/ai-assistant/index.js @@ -3,7 +3,10 @@ * * Brings LLM assistance into the editor with multi-provider support: * - OpenAI / Anthropic (cloud, API key required) - * - Ollama / LM Studio / any OpenAI-compatible endpoint (local, no key) + * - Ollama / LM Studio (local, no key) + * - Any OpenAI-compatible or Anthropic-compatible endpoint (custom base + * URL; Anthropic-compatible gateways like LiteLLM proxies work with or + * without a key) * * All provider traffic is proxied through the main process * (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter @@ -231,11 +234,18 @@ class AiAssistantPlugin extends PluginAPI { + - - + +

Settings are stored locally in settings.json. API keys never leave this machine.

diff --git a/src/plugins/built-in/ai-assistant/manifest.json b/src/plugins/built-in/ai-assistant/manifest.json index 589543f..89d07f9 100644 --- a/src/plugins/built-in/ai-assistant/manifest.json +++ b/src/plugins/built-in/ai-assistant/manifest.json @@ -19,7 +19,7 @@ "key": "provider", "type": "string", "default": "", - "label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible)" + "label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible|anthropic-compatible)" }, { "key": "model", "type": "string", "default": "", "label": "Model name" }, { diff --git a/src/plugins/built-in/collaboration/comment-store.js b/src/plugins/built-in/collaboration/comment-store.js index a6af964..1b7aeb8 100644 --- a/src/plugins/built-in/collaboration/comment-store.js +++ b/src/plugins/built-in/collaboration/comment-store.js @@ -42,17 +42,20 @@ function newCommentId(now = Date.now()) { * Load all comments for a document. Missing file/corrupt JSON → [] (a fresh * document simply has no comments yet; a corrupt store must not crash the app). * + * Async: `io.readFile`/`io.fileExists` are IPC-backed Promises in the app + * (plain values in tests — `await` handles both). + * * @param {string} docPath Document path * @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool } * @param {Function} pathUtil injected path module - * @returns {Array} comments sorted by line then createdAt + * @returns {Promise>} comments sorted by line then createdAt */ -function loadComments(docPath, io, pathUtil) { +async function loadComments(docPath, io, pathUtil) { if (!docPath) return []; const file = commentsFilePathFor(docPath, pathUtil); try { - if (!io.fileExists(file)) return []; - const raw = io.readFile(file); + if (!(await io.fileExists(file))) return []; + const raw = await io.readFile(file); const data = JSON.parse(raw); if (!Array.isArray(data.comments)) return []; return normalizeComments(data.comments); @@ -69,10 +72,10 @@ function loadComments(docPath, io, pathUtil) { * @param {object} io - { writeFile, ensureDirectory } * @param {Function} pathUtil injected path module */ -function saveComments(docPath, comments, io, pathUtil) { +async function saveComments(docPath, comments, io, pathUtil) { const file = commentsFilePathFor(docPath, pathUtil); - io.ensureDirectory(pathUtil.dirname(file)); - io.writeFile( + await io.ensureDirectory(pathUtil.dirname(file)); + await io.writeFile( file, JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2) ); diff --git a/src/plugins/built-in/collaboration/comments-panel.js b/src/plugins/built-in/collaboration/comments-panel.js index 385053b..a125b37 100644 --- a/src/plugins/built-in/collaboration/comments-panel.js +++ b/src/plugins/built-in/collaboration/comments-panel.js @@ -31,7 +31,7 @@ function esc(text) { * @param {Function} deps.pathUtil injected path module * @param {string} [deps.author='me'] Display name for new comments */ -function renderCommentsPanel(container, deps) { +async function renderCommentsPanel(container, deps) { container.replaceChildren(); const { editor, io, pathUtil } = deps; const docPath = editor.getCurrentFilePath(); @@ -74,14 +74,14 @@ function renderCommentsPanel(container, deps) { const addBtn = document.createElement('button'); addBtn.className = 'ws-btn ws-btn-primary'; addBtn.textContent = 'Comment on line ' + editor.getCurrentLine(); - addBtn.addEventListener('click', () => { + addBtn.addEventListener('click', async () => { const text = textarea.value.trim(); if (!text) return; const line = editor.getCurrentLine(); const anchorText = (getLines(editor)[line - 1] || '').trim(); - const comments = store.loadComments(docPath, io, pathUtil); + const comments = await store.loadComments(docPath, io, pathUtil); store.addComment(comments, { line, anchorText, author: deps.author || 'me', text }); - store.saveComments(docPath, comments, io, pathUtil); + await store.saveComments(docPath, comments, io, pathUtil); renderCommentsPanel(container, deps); // re-render with fresh list }); composer.appendChild(textarea); @@ -89,7 +89,7 @@ function renderCommentsPanel(container, deps) { panel.appendChild(composer); // --- List ----------------------------------------------------------------- - const comments = store.loadComments(docPath, io, pathUtil); + const comments = await store.loadComments(docPath, io, pathUtil); const lines = getLines(editor); const list = document.createElement('div'); @@ -122,16 +122,16 @@ function renderCommentsPanel(container, deps) { item.querySelector('[data-action="jump"]').addEventListener('click', () => { editor.jumpToLine(comment.line); }); - item.querySelector('[data-action="resolve"]').addEventListener('click', () => { - const updated = store.loadComments(docPath, io, pathUtil); + item.querySelector('[data-action="resolve"]').addEventListener('click', async () => { + const updated = await store.loadComments(docPath, io, pathUtil); store.toggleResolved(updated, comment.id); - store.saveComments(docPath, updated, io, pathUtil); + await store.saveComments(docPath, updated, io, pathUtil); renderCommentsPanel(container, deps); }); - item.querySelector('[data-action="delete"]').addEventListener('click', () => { - const updated = store.loadComments(docPath, io, pathUtil); + item.querySelector('[data-action="delete"]').addEventListener('click', async () => { + const updated = await store.loadComments(docPath, io, pathUtil); store.deleteComment(updated, comment.id); - store.saveComments(docPath, updated, io, pathUtil); + await store.saveComments(docPath, updated, io, pathUtil); renderCommentsPanel(container, deps); }); list.appendChild(item); diff --git a/src/plugins/built-in/collaboration/index.js b/src/plugins/built-in/collaboration/index.js index 417ed2e..2f5e6f3 100644 --- a/src/plugins/built-in/collaboration/index.js +++ b/src/plugins/built-in/collaboration/index.js @@ -85,10 +85,10 @@ class CollaborationPlugin extends PluginAPI { document.addEventListener('keydown', this._f8Handler); } - _jumpToNextComment() { + async _jumpToNextComment() { const docPath = this.context.editor.getCurrentFilePath(); if (!docPath) return; - const comments = store.loadComments(docPath, this._io, require('path')); + const comments = await store.loadComments(docPath, this._io, require('path')); const fromLine = this.context.editor.getCurrentLine(); const next = store.nextUnresolved(comments, fromLine); if (next) this.context.editor.jumpToLine(next.line); diff --git a/src/plugins/built-in/writing-studio/goal-tracker.js b/src/plugins/built-in/writing-studio/goal-tracker.js index ec04d98..5dba330 100644 --- a/src/plugins/built-in/writing-studio/goal-tracker.js +++ b/src/plugins/built-in/writing-studio/goal-tracker.js @@ -1,5 +1,12 @@ const HISTORY_KEY = 'plugins.writing-studio.history'; +/** + * Tracks daily word-count history for goals, streaks, and the heatmap. + * + * All methods are async: the settings backend in the real app is the + * IPC-backed SettingsStore (get/set return Promises), while unit tests inject + * a plain synchronous object — `await` transparently handles both. + */ class GoalTracker { /** * @param {object} store - { get(key), set(key, value) } settings backend @@ -8,41 +15,41 @@ class GoalTracker { this.store = store; } - _getHistory() { - const raw = this.store.get(HISTORY_KEY); + async _getHistory() { + const raw = await this.store.get(HISTORY_KEY); return raw ? JSON.parse(raw) : {}; } - _setHistory(history) { - this.store.set(HISTORY_KEY, JSON.stringify(history)); + async _setHistory(history) { + await this.store.set(HISTORY_KEY, JSON.stringify(history)); } - _setHistoryDay(dateStr, data) { - const history = this._getHistory(); + async _setHistoryDay(dateStr, data) { + const history = await this._getHistory(); history[dateStr] = data; - this._setHistory(history); + await this._setHistory(history); } - addWords(count) { + async addWords(count) { const today = new Date().toISOString().split('T')[0]; - const history = this._getHistory(); + const history = await this._getHistory(); if (!history[today]) { history[today] = { words: 0, sessions: 0 }; } history[today].words += count; history[today].sessions += 1; - this._setHistory(history); + await this._setHistory(history); } - getDailyProgress(goal) { + async getDailyProgress(goal) { const today = new Date().toISOString().split('T')[0]; - const history = this._getHistory(); + const history = await this._getHistory(); const written = history[today]?.words || 0; return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 }; } - getStreak(goal) { - const history = this._getHistory(); + async getStreak(goal) { + const history = await this._getHistory(); let streak = 0; const d = new Date(); for (let i = 0; i < 365; i++) { @@ -58,8 +65,8 @@ class GoalTracker { return streak; } - getLast30Days() { - const history = this._getHistory(); + async getLast30Days() { + const history = await this._getHistory(); const days = []; const d = new Date(); for (let i = 0; i < 30; i++) { @@ -71,8 +78,8 @@ class GoalTracker { return days.reverse(); } - getWeeklyTotal() { - const history = this._getHistory(); + async getWeeklyTotal() { + const history = await this._getHistory(); let total = 0; const d = new Date(); for (let i = 0; i < 7; i++) { diff --git a/src/plugins/built-in/writing-studio/index.js b/src/plugins/built-in/writing-studio/index.js index 1fd5746..ea311ef 100644 --- a/src/plugins/built-in/writing-studio/index.js +++ b/src/plugins/built-in/writing-studio/index.js @@ -35,11 +35,14 @@ class WritingStudioPlugin extends PluginAPI { _registerCommands(context) { const { sprintEngine, snapshotManager, goalTracker } = this; + // NOTE: settings.get and all engine methods are async (IPC-backed + // backend) — every handler below awaits what it reads. + context.commands.register( 'start-sprint', 'Studio: Start Sprint', - () => { - const duration = context.settings.get('sprintDuration') || 25; + async () => { + const duration = (await context.settings.get('sprintDuration')) || 25; const content = context.editor.getContent() || ''; const words = content.split(/\s+/).filter(Boolean).length; sprintEngine.start(duration, words); @@ -50,12 +53,12 @@ class WritingStudioPlugin extends PluginAPI { context.commands.register( 'stop-sprint', 'Studio: Stop Sprint', - () => { + async () => { if (!sprintEngine.isActive()) return; const content = context.editor.getContent() || ''; const words = content.split(/\s+/).filter(Boolean).length; const result = sprintEngine.stop(words); - goalTracker.addWords(result.wordDelta); + await goalTracker.addWords(result.wordDelta); context.events.emit('sprint:stopped', result); }, 'Ctrl+Alt+Shift+S' @@ -64,9 +67,9 @@ class WritingStudioPlugin extends PluginAPI { context.commands.register( 'take-snapshot', 'Studio: Take Snapshot', - () => { + async () => { const content = context.editor.getContent() || ''; - snapshotManager.create(content, 'manual'); + await snapshotManager.create(content, 'manual'); context.events.emit('snapshot:created', {}); }, 'Ctrl+Alt+N' @@ -75,10 +78,10 @@ class WritingStudioPlugin extends PluginAPI { context.commands.register( 'restore-last-snapshot', 'Studio: Restore Last Snapshot', - () => { - const snaps = snapshotManager.list(); + async () => { + const snaps = await snapshotManager.list(); if (snaps.length === 0) return; - const content = snapshotManager.restore(snaps[0].id); + const content = await snapshotManager.restore(snaps[0].id); context.editor.insertAtCursor(content); }, 'Ctrl+Alt+Z' @@ -134,9 +137,9 @@ class WritingStudioPlugin extends PluginAPI { extension: 'txt', handler: async (markdownContent, outputPath) => { const fs = require('fs'); - const goal = context.settings.get('dailyGoal') || 1000; - const progress = goalTracker.getDailyProgress(goal); - const streak = goalTracker.getStreak(goal); + const goal = (await context.settings.get('dailyGoal')) || 1000; + const progress = await goalTracker.getDailyProgress(goal); + const streak = await goalTracker.getStreak(goal); const wordCount = (markdownContent || '').split(/\s+/).filter(Boolean).length; const lines = [ diff --git a/src/plugins/built-in/writing-studio/panels/goals-panel.js b/src/plugins/built-in/writing-studio/panels/goals-panel.js index 48bf40d..dda9ce5 100644 --- a/src/plugins/built-in/writing-studio/panels/goals-panel.js +++ b/src/plugins/built-in/writing-studio/panels/goals-panel.js @@ -1,9 +1,11 @@ -function renderGoalsPanel(container, { engines, settings }) { - const dailyGoal = settings.get('dailyGoal') || 1000; - const progress = engines.goals.getDailyProgress(dailyGoal); - const streak = engines.goals.getStreak(dailyGoal); - const weekly = engines.goals.getWeeklyTotal(); - const last30 = engines.goals.getLast30Days(); +async function renderGoalsPanel(container, { engines, settings }) { + // settings.get and the GoalTracker are async (IPC-backed store) — the whole + // panel renders after its data resolves. + const dailyGoal = (await settings.get('dailyGoal')) || 1000; + const progress = await engines.goals.getDailyProgress(dailyGoal); + const streak = await engines.goals.getStreak(dailyGoal); + const weekly = await engines.goals.getWeeklyTotal(); + const last30 = await engines.goals.getLast30Days(); container.replaceChildren(); diff --git a/src/plugins/built-in/writing-studio/panels/manuscript-panel.js b/src/plugins/built-in/writing-studio/panels/manuscript-panel.js index d11f30f..d825ce6 100644 --- a/src/plugins/built-in/writing-studio/panels/manuscript-panel.js +++ b/src/plugins/built-in/writing-studio/panels/manuscript-panel.js @@ -1,5 +1,12 @@ -function renderManuscriptPanel(container, { engines, editor, settings }) { - const projectDir = settings.get('projectDir'); +/** + * Manuscript sidebar panel (writing-studio). + * + * All project/settings access is async (IPC-backed store), and the + * "New Project" flow uses an inline dialog — Electron renderers have no + * window.prompt, so the old prompt() call threw on click. + */ +async function renderManuscriptPanel(container, { engines, editor, settings }) { + const projectDir = await settings.get('projectDir'); container.replaceChildren(); const panel = document.createElement('div'); @@ -16,10 +23,14 @@ function renderManuscriptPanel(container, { engines, editor, settings }) { btn.id = 'ws-new-project'; btn.textContent = 'New Project'; btn.addEventListener('click', () => { - const name = prompt('Project name:'); - if (!name) return; - settings.set('projectDir', name); - renderManuscriptPanel(container, { engines, editor, settings }); + // Inline name input (window.prompt is unavailable in Electron) + const name = askForProjectName(); + name.then((result) => { + if (!result) return; + settings.set('projectDir', result).then(() => { + renderManuscriptPanel(container, { engines, editor, settings }); + }); + }); }); empty.appendChild(btn); panel.appendChild(empty); @@ -27,7 +38,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) { return; } - const project = engines.projects.loadProject(projectDir); + const project = await engines.projects.loadProject(projectDir); if (!project) { const empty = document.createElement('div'); empty.className = 'ws-empty'; @@ -39,8 +50,9 @@ function renderManuscriptPanel(container, { engines, editor, settings }) { btn.id = 'ws-clear-project'; btn.textContent = 'Clear Project'; btn.addEventListener('click', () => { - settings.set('projectDir', null); - renderManuscriptPanel(container, { engines, editor, settings }); + settings.set('projectDir', null).then(() => { + renderManuscriptPanel(container, { engines, editor, settings }); + }); }); empty.appendChild(btn); panel.appendChild(empty); @@ -48,7 +60,7 @@ function renderManuscriptPanel(container, { engines, editor, settings }) { return; } - const stats = engines.projects.getStats(projectDir); + const stats = await engines.projects.getStats(projectDir); // Project title + progress const section1 = document.createElement('div'); @@ -112,8 +124,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) { compileBtn.className = 'ws-btn ws-btn-primary'; compileBtn.id = 'ws-compile'; compileBtn.textContent = 'Compile Manuscript'; - compileBtn.addEventListener('click', () => { - const compiled = engines.projects.compileManuscript(projectDir); + compileBtn.addEventListener('click', async () => { + const compiled = await engines.projects.compileManuscript(projectDir); editor.insertAtCursor(compiled); }); section3.appendChild(compileBtn); @@ -122,4 +134,42 @@ function renderManuscriptPanel(container, { engines, editor, settings }) { container.appendChild(panel); } +/** + * Minimal promise-based name input (Electron has no window.prompt). + * @returns {Promise} project name, or null when cancelled + */ +function askForProjectName() { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'ai-settings-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.innerHTML = ` +
+

New manuscript project

+ +
+ + +
+
`; + const input = overlay.querySelector('[data-role="name"]'); + const done = (value) => { + overlay.remove(); + resolve(value); + }; + overlay.querySelector('[data-role="ok"]').addEventListener('click', () => done(input.value.trim() || null)); + overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null)); + overlay.addEventListener('click', (event) => { + if (event.target === overlay) done(null); + }); + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') done(input.value.trim() || null); + if (event.key === 'Escape') done(null); + }); + document.body.appendChild(overlay); + input.focus(); + }); +} + module.exports = { renderManuscriptPanel }; diff --git a/src/plugins/built-in/writing-studio/panels/snapshots-panel.js b/src/plugins/built-in/writing-studio/panels/snapshots-panel.js index 9c865d1..075d29b 100644 --- a/src/plugins/built-in/writing-studio/panels/snapshots-panel.js +++ b/src/plugins/built-in/writing-studio/panels/snapshots-panel.js @@ -1,5 +1,5 @@ -function renderSnapshotsPanel(container, { engines, editor }) { - const snapshots = engines.snapshots.list(); +async function renderSnapshotsPanel(container, { engines, editor }) { + const snapshots = await engines.snapshots.list(); container.replaceChildren(); @@ -53,16 +53,16 @@ function renderSnapshotsPanel(container, { engines, editor }) { const actionBtn = document.createElement('button'); actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : ''); actionBtn.textContent = text; - actionBtn.addEventListener('click', () => { + actionBtn.addEventListener('click', async () => { if (action === 'restore') { - const content = engines.snapshots.restore(s.id); + const content = await engines.snapshots.restore(s.id); editor.insertAtCursor(content); } else if (action === 'delete') { - engines.snapshots.delete(s.id); + await engines.snapshots.delete(s.id); renderSnapshotsPanel(container, { engines, editor }); } else if (action === 'diff') { const current = editor.getContent() || ''; - const result = engines.snapshots.diff(s.id, current); + const result = await engines.snapshots.diff(s.id, current); alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed'); } }); @@ -75,9 +75,9 @@ function renderSnapshotsPanel(container, { engines, editor }) { container.appendChild(panel); // Take snapshot button handler - container.querySelector('#ws-take-snapshot').addEventListener('click', () => { + container.querySelector('#ws-take-snapshot').addEventListener('click', async () => { const content = editor.getContent() || ''; - engines.snapshots.create(content, 'manual'); + await engines.snapshots.create(content, 'manual'); renderSnapshotsPanel(container, { engines, editor }); }); } diff --git a/src/plugins/built-in/writing-studio/project-manager.js b/src/plugins/built-in/writing-studio/project-manager.js index dc42c29..18bd45d 100644 --- a/src/plugins/built-in/writing-studio/project-manager.js +++ b/src/plugins/built-in/writing-studio/project-manager.js @@ -1,3 +1,11 @@ +/** + * Manuscript project management (.project.json + chapter files). + * + * All methods are async: the fs backend injected by the plugin is the + * IPC-backed file API (readFile/writeFile/fileExists/listDir return + * Promises); `await` also works against the synchronous fakes used in unit + * tests. + */ class ProjectManager { /** * @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) } @@ -6,7 +14,7 @@ class ProjectManager { this.fs = fs; } - createProject(dir, opts) { + async createProject(dir, opts) { const project = { title: opts.title, type: opts.type || 'manuscript', @@ -14,51 +22,53 @@ class ProjectManager { chapters: [], metadata: opts.metadata || {}, }; - this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); + await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); return project; } - loadProject(dir) { - const raw = this.fs.readFile(dir + '/.project.json'); + async loadProject(dir) { + const exists = await this.fs.fileExists(dir + '/.project.json'); + if (!exists) return null; + const raw = await this.fs.readFile(dir + '/.project.json'); if (!raw) return null; return JSON.parse(raw); } - _saveProject(dir, project) { - this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); + async _saveProject(dir, project) { + await this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); } - addChapter(dir, chapter) { - const project = this.loadProject(dir); + async addChapter(dir, chapter) { + const project = await this.loadProject(dir); if (!project) throw new Error('Project not found'); project.chapters.push(chapter); - this._saveProject(dir, project); + await this._saveProject(dir, project); } - updateChapter(dir, index, updates) { - const project = this.loadProject(dir); + async updateChapter(dir, index, updates) { + const project = await this.loadProject(dir); if (!project) throw new Error('Project not found'); Object.assign(project.chapters[index], updates); - this._saveProject(dir, project); + await this._saveProject(dir, project); } - compileManuscript(dir) { - const project = this.loadProject(dir); + async compileManuscript(dir) { + const project = await this.loadProject(dir); if (!project) throw new Error('Project not found'); const parts = []; for (const ch of project.chapters) { - const content = this.fs.readFile(dir + '/' + ch.file); + const content = await this.fs.readFile(dir + '/' + ch.file); if (content) parts.push(content); } return parts.join('\n\n---\n\n'); } - getStats(dir) { - const project = this.loadProject(dir); + async getStats(dir) { + const project = await this.loadProject(dir); if (!project) throw new Error('Project not found'); let totalWords = 0; for (const ch of project.chapters) { - const content = this.fs.readFile(dir + '/' + ch.file); + const content = await this.fs.readFile(dir + '/' + ch.file); if (content) totalWords += content.split(/\s+/).filter(Boolean).length; } const target = project.target.words || 0; diff --git a/src/plugins/built-in/writing-studio/snapshot-manager.js b/src/plugins/built-in/writing-studio/snapshot-manager.js index 3cb7bf6..5082c72 100644 --- a/src/plugins/built-in/writing-studio/snapshot-manager.js +++ b/src/plugins/built-in/writing-studio/snapshot-manager.js @@ -1,3 +1,9 @@ +/** + * Content snapshots with a rolling window, stored via the plugin settings + * backend. All methods are async because the real backend is the IPC-backed + * SettingsStore (get/set return Promises); `await` also works against the + * synchronous fakes used in unit tests. + */ class SnapshotManager { /** * @param {object} store - { get(key), set(key, value) } @@ -8,17 +14,17 @@ class SnapshotManager { this.storeKey = storeKey; } - _getAll() { - const raw = this.store.get(this.storeKey); + async _getAll() { + const raw = await this.store.get(this.storeKey); return raw ? JSON.parse(raw) : []; } - _saveAll(snaps) { - this.store.set(this.storeKey, JSON.stringify(snaps)); + async _saveAll(snaps) { + await this.store.set(this.storeKey, JSON.stringify(snaps)); } - create(content, label = 'manual') { - const snaps = this._getAll(); + async create(content, label = 'manual') { + const snaps = await this._getAll(); const snap = { id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8), timestamp: new Date().toISOString(), @@ -27,31 +33,31 @@ class SnapshotManager { label, }; snaps.unshift(snap); - this._saveAll(snaps); + await this._saveAll(snaps); return snap; } - list() { + async list() { return this._getAll(); } - getById(id) { - return this._getAll().find((s) => s.id === id) || null; + async getById(id) { + return (await this._getAll()).find((s) => s.id === id) || null; } - restore(id) { - const snap = this.getById(id); + async restore(id) { + const snap = await this.getById(id); if (!snap) throw new Error('Snapshot not found'); return snap.content; } - delete(id) { - const snaps = this._getAll().filter((s) => s.id !== id); - this._saveAll(snaps); + async delete(id) { + const snaps = (await this._getAll()).filter((s) => s.id !== id); + await this._saveAll(snaps); } - diff(id, currentContent) { - const snap = this.getById(id); + async diff(id, currentContent) { + const snap = await this.getById(id); if (!snap) throw new Error('Snapshot not found'); const oldLines = snap.content.split('\n'); const newLines = currentContent.split('\n'); @@ -68,9 +74,9 @@ class SnapshotManager { return { added, removed }; } - prune(keepCount) { - const snaps = this._getAll(); - this._saveAll(snaps.slice(0, keepCount)); + async prune(keepCount) { + const snaps = await this._getAll(); + await this._saveAll(snaps.slice(0, keepCount)); } } diff --git a/src/renderer.js b/src/renderer.js index 94e182b..5d5ae39 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -4184,7 +4184,12 @@ ipcRenderer.on('show-table-generator', () => { // Show PDF Editor Dialog ipcRenderer.on('show-pdf-editor-dialog', (event, operation, openedFilePath) => { - currentPDFOperation = operation; + if (!operation) { + // File > Open PDF sends null — legit, but log unexpected variants so + // stray senders are traceable in the console. + console.info('[pdf-editor] opening with default section; operation =', operation); + } + currentPDFOperation = operation || 'merge'; showPDFEditorDialog(operation, openedFilePath); }); @@ -4373,8 +4378,13 @@ function showPDFEditorDialog(operation, openedFilePath = null) { break; } } - title.textContent = titleText; - document.getElementById(sectionId).classList.remove('hidden'); + + // Unknown/null operations (e.g. File > Open PDF sends null meaning "open + // the editor with this file") fall back to the merge section instead of + // crashing on a missing element. + const section = document.getElementById(sectionId || 'pdf-merge-section'); + title.textContent = titleText || 'PDF Editor'; + if (section) section.classList.remove('hidden'); window.modals.pdfEditorModal.open(); } function hidePDFEditorDialog() { diff --git a/src/sidebar/backlinks-panel.js b/src/sidebar/backlinks-panel.js index 188a1fb..4bd2d31 100644 --- a/src/sidebar/backlinks-panel.js +++ b/src/sidebar/backlinks-panel.js @@ -7,8 +7,8 @@ * @module BacklinksPanel */ -const { collectMarkdownFiles, findBacklinks } = require('../../utils/backlinks'); -const { docNameFor } = require('../../utils/wiki-links'); +const { collectMarkdownFiles, findBacklinks } = require('../utils/backlinks'); +const { docNameFor } = require('../utils/wiki-links'); /** * Render the backlinks panel. diff --git a/tests/collaboration-comment-store.test.js b/tests/collaboration-comment-store.test.js index 4442d1c..40549e3 100644 --- a/tests/collaboration-comment-store.test.js +++ b/tests/collaboration-comment-store.test.js @@ -10,11 +10,11 @@ function makeIo(initial = {}) { const files = new Map(Object.entries(initial)); return { files, - readFile: (p) => (files.has(p) ? files.get(p) : null), - writeFile: (p, c) => { + readFile: async (p) => (files.has(p) ? files.get(p) : null), + writeFile: async (p, c) => { files.set(p, c); }, - fileExists: (p) => files.has(p), + fileExists: async (p) => files.has(p), ensureDirectory: () => {}, }; } @@ -24,7 +24,7 @@ describe('comment-store', () => { const sidecar = path.join('/docs', '.comments', 'notes.md.json'); describe('commentsFilePathFor', () => { - it('places the sidecar in .comments/ next to the document', () => { + it('places the sidecar in .comments/ next to the document', async () => { expect(store.commentsFilePathFor(doc, path)).toBe(sidecar); expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe( path.join('/a/b/.comments', 'c.md.json') @@ -33,29 +33,29 @@ describe('comment-store', () => { }); describe('load/save round-trip', () => { - it('returns [] for documents with no sidecar yet', () => { - expect(store.loadComments(doc, makeIo(), path)).toEqual([]); + it('returns [] for documents with no sidecar yet', async () => { + expect(await store.loadComments(doc, makeIo(), path)).toEqual([]); }); - it('returns [] for corrupt JSON instead of throwing', () => { + it('returns [] for corrupt JSON instead of throwing', async () => { const io = makeIo({ [sidecar]: '{oops' }); - expect(store.loadComments(doc, io, path)).toEqual([]); + expect(await store.loadComments(doc, io, path)).toEqual([]); }); - it('persists and reloads comments sorted by line', () => { + it('persists and reloads comments sorted by line', async () => { const io = makeIo(); const comments = []; store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' }); store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' }); - store.saveComments(doc, comments, io, path); + await store.saveComments(doc, comments, io, path); - const loaded = store.loadComments(doc, io, path); + const loaded = await store.loadComments(doc, io, path); expect(loaded.map((c) => c.line)).toEqual([3, 12]); expect(loaded[0].text).toBe('strong opener'); expect(loaded[0].resolved).toBe(false); }); - it('normalizes malformed entries on load', () => { + it('normalizes malformed entries on load', async () => { const io = makeIo({ [sidecar]: JSON.stringify({ comments: [ @@ -64,7 +64,7 @@ describe('comment-store', () => { ], }), }); - const loaded = store.loadComments(doc, io, path); + const loaded = await store.loadComments(doc, io, path); // '7' (string line) is dropped; the valid one survives normalization expect(loaded).toHaveLength(1); expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' }); @@ -72,7 +72,7 @@ describe('comment-store', () => { }); describe('mutators', () => { - it('addComment clamps lines and caps field lengths', () => { + it('addComment clamps lines and caps field lengths', async () => { const comments = []; const c = store.addComment(comments, { line: -5, @@ -86,7 +86,7 @@ describe('comment-store', () => { expect(c.id).toBeTruthy(); }); - it('toggleResolved flips state and returns null for unknown ids', () => { + it('toggleResolved flips state and returns null for unknown ids', async () => { const comments = []; const c = store.addComment(comments, { line: 1, text: 'hi' }); expect(store.toggleResolved(comments, c.id)).toBe(true); @@ -94,7 +94,7 @@ describe('comment-store', () => { expect(store.toggleResolved(comments, 'missing')).toBeNull(); }); - it('deleteComment reports whether anything was removed', () => { + it('deleteComment reports whether anything was removed', async () => { const comments = []; const c = store.addComment(comments, { line: 1, text: 'hi' }); expect(store.deleteComment(comments, c.id)).toBe(true); @@ -104,7 +104,7 @@ describe('comment-store', () => { }); describe('nextUnresolved (F8 navigation)', () => { - it('returns the first open comment below the cursor line', () => { + it('returns the first open comment below the cursor line', async () => { const comments = [ { id: 'a', line: 5, resolved: false }, { id: 'b', line: 20, resolved: false }, @@ -112,7 +112,7 @@ describe('comment-store', () => { expect(store.nextUnresolved(comments, 7).id).toBe('b'); }); - it('wraps to the top when no open comments are below', () => { + it('wraps to the top when no open comments are below', async () => { const comments = [ { id: 'a', line: 5, resolved: false }, { id: 'b', line: 20, resolved: true }, @@ -120,7 +120,7 @@ describe('comment-store', () => { expect(store.nextUnresolved(comments, 10).id).toBe('a'); }); - it('returns null when everything is resolved or empty', () => { + it('returns null when everything is resolved or empty', async () => { expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull(); expect(store.nextUnresolved([], 1)).toBeNull(); }); @@ -129,20 +129,20 @@ describe('comment-store', () => { describe('anchorStatus (drift detection)', () => { const base = { line: 2, anchorText: 'original text' }; - it('reports ok when the line still matches', () => { + it('reports ok when the line still matches', async () => { expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok'); }); - it('reports changed when the line text differs', () => { + it('reports changed when the line text differs', async () => { expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed'); }); - it('reports missing when the line is beyond the document', () => { + it('reports missing when the line is beyond the document', async () => { expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing'); expect(store.anchorStatus(base, [])).toBe('missing'); }); - it('reports moved for comments without an anchor snippet', () => { + it('reports moved for comments without an anchor snippet', async () => { expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved'); }); }); diff --git a/tests/goal-tracker.test.js b/tests/goal-tracker.test.js index 5b71b6b..97dac97 100644 --- a/tests/goal-tracker.test.js +++ b/tests/goal-tracker.test.js @@ -1,5 +1,8 @@ const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker'); +// All GoalTracker methods are async (the real settings backend is the +// IPC-backed SettingsStore; these tests inject a synchronous fake, which +// `await` handles transparently). describe('GoalTracker', () => { let tracker; let store; @@ -14,71 +17,89 @@ describe('GoalTracker', () => { }); }); - test('addWords records words for today', () => { - tracker.addWords(500); + test('addWords records words for today', async () => { + await tracker.addWords(500); const today = new Date().toISOString().split('T')[0]; expect(store['plugins.writing-studio.history']).toBeDefined(); const history = JSON.parse(store['plugins.writing-studio.history']); expect(history[today].words).toBe(500); }); - test('addWords accumulates across multiple calls', () => { - tracker.addWords(300); - tracker.addWords(200); + test('addWords accumulates across multiple calls', async () => { + await tracker.addWords(300); + await tracker.addWords(200); const today = new Date().toISOString().split('T')[0]; const history = JSON.parse(store['plugins.writing-studio.history']); expect(history[today].words).toBe(500); }); - test('getDailyProgress returns 0 when no history', () => { - expect(tracker.getDailyProgress(1000)).toEqual({ written: 0, goal: 1000, pct: 0 }); + test('works with a Promise-returning (IPC-style) backend', async () => { + const asyncTracker = new GoalTracker({ + get: async (key) => store[key], + set: async (key, value) => { + store[key] = value; + }, + }); + await asyncTracker.addWords(250); + await expect(asyncTracker.getDailyProgress(1000)).resolves.toEqual({ + written: 250, + goal: 1000, + pct: 25, + }); }); - test('getDailyProgress returns percentage', () => { - tracker.addWords(500); - const progress = tracker.getDailyProgress(1000); + test('getDailyProgress returns 0 when no history', async () => { + await expect(tracker.getDailyProgress(1000)).resolves.toEqual({ + written: 0, + goal: 1000, + pct: 0, + }); + }); + + test('getDailyProgress returns percentage', async () => { + await tracker.addWords(500); + const progress = await tracker.getDailyProgress(1000); expect(progress.written).toBe(500); expect(progress.pct).toBe(50); }); - test('getStreak counts consecutive days meeting goal', () => { + test('getStreak counts consecutive days meeting goal', async () => { const today = new Date(); for (let i = 0; i < 3; i++) { const d = new Date(today); d.setDate(d.getDate() - i); const key = d.toISOString().split('T')[0]; - tracker._setHistoryDay(key, { words: 1200 }); + await tracker._setHistoryDay(key, { words: 1200 }); } - const streak = tracker.getStreak(1000); - expect(streak).toBe(3); + await expect(tracker.getStreak(1000)).resolves.toBe(3); }); - test('getStreak breaks on missed day', () => { + test('getStreak breaks on missed day', async () => { const today = new Date(); - tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 }); + await tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 }); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); - tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 }); + await tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 }); const dayBefore = new Date(today); dayBefore.setDate(dayBefore.getDate() - 2); - tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 }); - expect(tracker.getStreak(1000)).toBe(1); + await tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 }); + await expect(tracker.getStreak(1000)).resolves.toBe(1); }); - test('getLast30Days returns array of 30 entries', () => { - tracker.addWords(100); - const days = tracker.getLast30Days(); + test('getLast30Days returns array of 30 entries', async () => { + await tracker.addWords(100); + const days = await tracker.getLast30Days(); expect(days.length).toBe(30); expect(days[29].words).toBe(100); }); - test('getWeeklyTotal sums last 7 days', () => { + test('getWeeklyTotal sums last 7 days', async () => { const today = new Date(); for (let i = 0; i < 7; i++) { const d = new Date(today); d.setDate(d.getDate() - i); - tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 }); + await tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 }); } - expect(tracker.getWeeklyTotal()).toBe(1400); + await expect(tracker.getWeeklyTotal()).resolves.toBe(1400); }); }); diff --git a/tests/main/AiProviders.test.js b/tests/main/AiProviders.test.js index 90ba3e8..b906dbf 100644 --- a/tests/main/AiProviders.test.js +++ b/tests/main/AiProviders.test.js @@ -33,6 +33,10 @@ describe('AiProviders', () => { it('does not require an API key for local providers', () => { expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow(); expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow(); + // Self-hosted Anthropic-compatible proxies may front their own auth + expect(() => + resolveSettings({ provider: 'anthropic-compatible', baseUrl: 'http://localhost:4000' }) + ).not.toThrow(); }); it('requires an API key for remote providers', () => { @@ -109,6 +113,52 @@ describe('AiProviders', () => { expect(body.system).toBe('be nice'); }); + it('routes anthropic-compatible to the custom base with Bearer + x-api-key auth', async () => { + const fetchImpl = okFetch({ content: [{ type: 'text', text: 'proxy ok' }] }); + const result = await complete( + { + provider: 'anthropic-compatible', + baseUrl: 'http://localhost:4000', + apiKey: 'proxy-key', + messages, + }, + { fetchImpl } + ); + + expect(result.content).toBe('proxy ok'); + const [url, init] = fetchImpl.mock.calls[0]; + // Base without a /v1 suffix gets one appended + expect(url).toBe('http://localhost:4000/v1/messages'); + // Both auth styles are sent so LiteLLM/Bedrock-style gateways accept it + expect(init.headers['x-api-key']).toBe('proxy-key'); + expect(init.headers.Authorization).toBe('Bearer proxy-key'); + expect(init.headers['anthropic-version']).toBe('2023-06-01'); + }); + + it('does not double the /v1 segment when the base already ends with it', async () => { + const fetchImpl = okFetch({ content: [{ type: 'text', text: 'ok' }] }); + await complete( + { + provider: 'anthropic-compatible', + baseUrl: 'http://localhost:4000/v1/', + messages, + }, + { fetchImpl } + ); + // Trailing slash is stripped by resolveSettings, so the base ends in /v1 + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('http://localhost:4000/v1/messages'); + // Keyless proxies must work without auth headers + expect(init.headers.Authorization).toBeUndefined(); + expect(init.headers['x-api-key']).toBeUndefined(); + }); + + it('rejects anthropic-compatible without a base URL', async () => { + await expect( + complete({ provider: 'anthropic-compatible', messages }, { fetchImpl: okFetch({}) }) + ).rejects.toThrow(/Invalid API base URL/); + }); + it('surfaces HTTP failures as user-safe errors without the body', async () => { const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 }); await expect( diff --git a/tests/project-manager.test.js b/tests/project-manager.test.js index 71f19c9..7a7e538 100644 --- a/tests/project-manager.test.js +++ b/tests/project-manager.test.js @@ -1,5 +1,7 @@ const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager'); +// All ProjectManager methods are async (the real fs backend is the IPC-backed +// file API; these tests inject a synchronous fake, which `await` handles). describe('ProjectManager', () => { let pm; let files; @@ -19,8 +21,8 @@ describe('ProjectManager', () => { }); }); - test('createProject writes .project.json', () => { - const project = pm.createProject('/manuscripts/novel', { + test('createProject writes .project.json', async () => { + const project = await pm.createProject('/manuscripts/novel', { title: 'My Novel', type: 'manuscript', targetWords: 80000, @@ -31,7 +33,7 @@ describe('ProjectManager', () => { expect(parsed.target.words).toBe(80000); }); - test('loadProject reads and returns project data', () => { + test('loadProject reads and returns project data', async () => { files['/manuscripts/novel/.project.json'] = JSON.stringify({ title: 'Test', type: 'manuscript', @@ -39,15 +41,15 @@ describe('ProjectManager', () => { chapters: [], metadata: {}, }); - const project = pm.loadProject('/manuscripts/novel'); + const project = await pm.loadProject('/manuscripts/novel'); expect(project.title).toBe('Test'); }); - test('loadProject returns null if no project file', () => { - expect(pm.loadProject('/nonexistent')).toBeNull(); + test('loadProject returns null if no project file', async () => { + await expect(pm.loadProject('/nonexistent')).resolves.toBeNull(); }); - test('addChapter appends chapter and saves', () => { + test('addChapter appends chapter and saves', async () => { files['/manuscripts/novel/.project.json'] = JSON.stringify({ title: 'Test', type: 'manuscript', @@ -55,7 +57,7 @@ describe('ProjectManager', () => { chapters: [], metadata: {}, }); - pm.addChapter('/manuscripts/novel', { + await pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft', @@ -65,7 +67,7 @@ describe('ProjectManager', () => { expect(parsed.chapters[0].title).toBe('Chapter One'); }); - test('compileManuscript concatenates chapter files', () => { + test('compileManuscript concatenates chapter files', async () => { files['/manuscripts/novel/.project.json'] = JSON.stringify({ title: 'Test', type: 'manuscript', @@ -78,11 +80,11 @@ describe('ProjectManager', () => { }); files['/manuscripts/novel/01.md'] = 'First chapter content.'; files['/manuscripts/novel/02.md'] = 'Second chapter content.'; - const result = pm.compileManuscript('/manuscripts/novel'); + const result = await pm.compileManuscript('/manuscripts/novel'); expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.'); }); - test('compileManuscript skips missing files', () => { + test('compileManuscript skips missing files', async () => { files['/manuscripts/novel/.project.json'] = JSON.stringify({ title: 'Test', type: 'manuscript', @@ -94,11 +96,11 @@ describe('ProjectManager', () => { metadata: {}, }); files['/manuscripts/novel/01.md'] = 'Only chapter one.'; - const result = pm.compileManuscript('/manuscripts/novel'); + const result = await pm.compileManuscript('/manuscripts/novel'); expect(result).toBe('Only chapter one.'); }); - test('getStats returns total word count across chapters', () => { + test('getStats returns total word count across chapters', async () => { files['/manuscripts/novel/.project.json'] = JSON.stringify({ title: 'Test', type: 'manuscript', @@ -111,14 +113,14 @@ describe('ProjectManager', () => { }); files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim(); files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim(); - const stats = pm.getStats('/manuscripts/novel'); + const stats = await pm.getStats('/manuscripts/novel'); expect(stats.totalWords).toBeGreaterThan(0); expect(stats.chapterCount).toBe(2); expect(stats.targetWords).toBe(50000); expect(stats.pctComplete).toBeDefined(); }); - test('updateChapter modifies a chapter by index', () => { + test('updateChapter modifies a chapter by index', async () => { files['/manuscripts/novel/.project.json'] = JSON.stringify({ title: 'Test', type: 'manuscript', @@ -126,7 +128,7 @@ describe('ProjectManager', () => { chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }], metadata: {}, }); - pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' }); + await pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' }); const parsed = JSON.parse(files['/manuscripts/novel/.project.json']); expect(parsed.chapters[0].title).toBe('New Title'); expect(parsed.chapters[0].status).toBe('revised'); diff --git a/tests/snapshot-manager.test.js b/tests/snapshot-manager.test.js index 23feaa8..05e2556 100644 --- a/tests/snapshot-manager.test.js +++ b/tests/snapshot-manager.test.js @@ -1,5 +1,7 @@ const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager'); +// All SnapshotManager methods are async (the real settings backend is the +// IPC-backed SettingsStore; these tests inject a synchronous fake). describe('SnapshotManager', () => { let manager; let store; @@ -14,62 +16,63 @@ describe('SnapshotManager', () => { }); }); - test('create stores snapshot with timestamp, content, wordCount', () => { - const snap = manager.create('Hello world this is a test', 'auto'); + test('create stores snapshot with timestamp, content, wordCount', async () => { + const snap = await manager.create('Hello world this is a test', 'auto'); expect(snap).toHaveProperty('id'); expect(snap.content).toBe('Hello world this is a test'); expect(snap.wordCount).toBe(6); expect(snap.label).toBe('auto'); }); - test('list returns snapshots ordered newest first', () => { - manager.create('first', 'auto'); - manager.create('second', 'auto'); - const list = manager.list(); + test('list returns snapshots ordered newest first', async () => { + await manager.create('first', 'auto'); + await manager.create('second', 'auto'); + const list = await manager.list(); expect(list.length).toBe(2); expect(list[0].content).toBe('second'); }); - test('getById returns specific snapshot', () => { - const snap = manager.create('find me', 'manual'); - const found = manager.getById(snap.id); + test('getById returns specific snapshot', async () => { + const snap = await manager.create('find me', 'manual'); + const found = await manager.getById(snap.id); expect(found.content).toBe('find me'); }); - test('getById returns null for missing id', () => { - expect(manager.getById('nope')).toBeNull(); + test('getById returns null for missing id', async () => { + await expect(manager.getById('nope')).resolves.toBeNull(); }); - test('restore returns content of snapshot', () => { - const snap = manager.create('restore this', 'manual'); - expect(manager.restore(snap.id)).toBe('restore this'); + test('restore returns content of snapshot', async () => { + const snap = await manager.create('restore this', 'manual'); + await expect(manager.restore(snap.id)).resolves.toBe('restore this'); }); - test('restore throws for missing snapshot', () => { - expect(() => manager.restore('nope')).toThrow('Snapshot not found'); + test('restore throws for missing snapshot', async () => { + await expect(manager.restore('nope')).rejects.toThrow('Snapshot not found'); }); - test('delete removes a snapshot', () => { - const snap = manager.create('delete me', 'auto'); - manager.delete(snap.id); - expect(manager.getById(snap.id)).toBeNull(); + test('delete removes a snapshot', async () => { + const snap = await manager.create('delete me', 'auto'); + await manager.delete(snap.id); + await expect(manager.getById(snap.id)).resolves.toBeNull(); }); - test('diff returns added/removed line counts', () => { - const snap = manager.create('line one\nline two\nline three', 'auto'); - const result = manager.diff(snap.id, 'line one\nline modified\nline three\nline four'); + test('diff returns added/removed line counts', async () => { + const snap = await manager.create('line one\nline two\nline three', 'auto'); + const result = await manager.diff(snap.id, 'line one\nline modified\nline three\nline four'); expect(result.added).toBe(2); expect(result.removed).toBe(1); }); - test('diff throws for missing snapshot', () => { - expect(() => manager.diff('nope', 'new content')).toThrow('Snapshot not found'); + test('diff throws for missing snapshot', async () => { + await expect(manager.diff('nope', 'new content')).rejects.toThrow('Snapshot not found'); }); - test('prune keeps only the N most recent snapshots', () => { - for (let i = 0; i < 10; i++) manager.create('snap ' + i, 'auto'); - manager.prune(5); - expect(manager.list().length).toBe(5); - expect(manager.list()[0].content).toBe('snap 9'); + test('prune keeps only the N most recent snapshots', async () => { + for (let i = 0; i < 10; i++) await manager.create('snap ' + i, 'auto'); + await manager.prune(5); + const list = await manager.list(); + expect(list.length).toBe(5); + expect(list[0].content).toBe('snap 9'); }); });