Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
126 commits
Select commit Hold shift + click to select a range
95e46b8
feat: add the TextGeneration port with a fake and contract tests
rsml Jul 21, 2026
4bbda7f
feat: add the KeyVault port with a fake and contract tests
rsml Jul 21, 2026
920be33
feat: add the ImageGeneration port with a fake and contract tests
rsml Jul 21, 2026
2a4aa54
feat: add the SpeechSynthesis port, fake, and contract tests
rsml Jul 21, 2026
0998fdd
feat: add the AudioAssembly port, fake, and contract tests
rsml Jul 21, 2026
b50902d
feat: add the DiagramRenderer port, fake, and contract tests
rsml Jul 21, 2026
3a759f2
feat: add the EpubImport and EpubExport ports with fakes and contracts
rsml Jul 21, 2026
6a997f9
feat: add the BackgroundTasks port with signal-only task handles
rsml Jul 21, 2026
f6f3115
feat: add the Clock and OsFileManager ports with fakes and contracts
rsml Jul 21, 2026
1b6f551
feat(ports): add BookRepository port, fake, and contract test
rsml Jul 21, 2026
aebda92
feat(ports): add ArtifactStore port, fake, and contract test
rsml Jul 21, 2026
05c97dc
refactor(shared): keep the book status predicates free of zod
rsml Jul 21, 2026
801ac1b
feat: add the profile response contract type
rsml Jul 21, 2026
55ee37b
refactor(ports): standardize the two port contracts the real adapters…
rsml Jul 21, 2026
f309f93
docs(plans): reconcile the phase 2 plan with what the code actually says
rsml Jul 21, 2026
fad3075
feat(adapters): add fs-paths helpers and the real BookRepository adapter
rsml Jul 21, 2026
a209736
feat(adapters): add the real ArtifactStore adapter
rsml Jul 21, 2026
f7e53ce
refactor(services): turn book-store into a thin shim over the two ada…
rsml Jul 21, 2026
5641881
test(services): rebuild book-store tests over a real temp dir
rsml Jul 21, 2026
593718a
feat(adapters): add the real in-memory BackgroundTasks adapter with c…
rsml Jul 21, 2026
2cdc272
refactor(services): make task-manager a thin shim over the Background…
rsml Jul 21, 2026
f13c7b4
feat(adapters): add the real system Clock adapter with contract tests
rsml Jul 21, 2026
fb7cb11
feat(adapters): add the real OsFileManager adapter with contract and …
rsml Jul 21, 2026
551ac62
feat(adapters): add the real HttpImageGeneration adapter with unit tests
rsml Jul 21, 2026
4024c99
refactor(services): make image-generation delegate to the HttpImageGe…
rsml Jul 21, 2026
0c61d13
refactor(ports): add bookTitle to ConcatToM4bRequest
rsml Jul 21, 2026
9a55096
feat(adapters): add the ffmpeg-backed AudioAssembly adapter
rsml Jul 21, 2026
406eed2
test(adapters): cover ffmpeg argv construction with a fake process ru…
rsml Jul 21, 2026
9ffefd4
refactor(services): delegate audiobook-generator ffmpeg work to the a…
rsml Jul 21, 2026
087cd67
feat(adapters): add the Kokoro-backed SpeechSynthesis adapter
rsml Jul 21, 2026
e742a1b
test(adapters): cover Kokoro voice list and install-probe delegation
rsml Jul 21, 2026
e70b452
refactor(services): turn kokoro-service into a thin shim over the ada…
rsml Jul 21, 2026
8943bbb
feat(adapters): add epub-gen-memory EPUB export adapter
rsml Jul 21, 2026
50a27f9
feat(adapters): add epub2 EPUB import adapter
rsml Jul 21, 2026
5566254
refactor(services): delegate epub-importer parsing to the epub2 impor…
rsml Jul 21, 2026
d929596
feat(adapters): add kroki.io diagram renderer adapter
rsml Jul 21, 2026
04f5bb8
refactor(server): delegate the mermaid renderer decoration to the kro…
rsml Jul 21, 2026
14ec5bb
feat(adapters): add Electron BrowserWindow diagram renderer adapter
rsml Jul 21, 2026
2b6e174
refactor(electron): delegate the mermaid renderer to the electron dia…
rsml Jul 21, 2026
e4c00ed
feat(adapters): add the file backed key vault adapter
rsml Jul 21, 2026
9748e97
refactor(services): shim the key store over the file key vault adapter
rsml Jul 21, 2026
acf2d05
feat(adapters): add the ai sdk text generation adapter
rsml Jul 21, 2026
6a18de7
refactor(services): delegate the model client to the ai sdk adapter
rsml Jul 21, 2026
f33782f
feat(server): add the composition root and thread ports through build…
rsml Jul 21, 2026
26535d7
refactor(services): delete the dead mermaid renderer module
rsml Jul 21, 2026
5d3347b
refactor(routes): extract shared helpers out of books.ts
rsml Jul 21, 2026
b10ca77
refactor(routes): extract library routes into their own module
rsml Jul 21, 2026
4aa7cd0
refactor(routes): extract reading routes into their own module
rsml Jul 21, 2026
1d8847d
refactor(routes): extract assessment routes into their own module
rsml Jul 21, 2026
47470b7
refactor(routes): extract suggestion routes into their own module
rsml Jul 21, 2026
f006310
refactor(routes): extract epub export routes into their own module
rsml Jul 21, 2026
c80e55c
refactor(routes): extract per-book audiobook routes into their own mo…
rsml Jul 21, 2026
8a008fa
refactor(routes): extract book generation routes into their own module
rsml Jul 21, 2026
4e4aa16
refactor(routes): extract authoring routes and delete books.ts
rsml Jul 21, 2026
4b33d5c
refactor(domain): add pure assertChapterInRange to chapter-range
rsml Jul 21, 2026
de4314b
refactor(domain): repoint skill-progress-report off the book-store shim
rsml Jul 21, 2026
1400a7c
feat(services): extract listLibrary from library.ts
rsml Jul 21, 2026
6544fd4
feat(services): extract searchLibrary from library.ts
rsml Jul 21, 2026
05456fc
feat(services): extract getBookDetail and getBookToc from library.ts
rsml Jul 21, 2026
0e92a71
feat(services): extract updateBookDetails from library.ts
rsml Jul 21, 2026
8d13d5c
feat(services): extract deleteBook from library.ts
rsml Jul 21, 2026
63f4ad8
feat(services): extract resetBook from library.ts
rsml Jul 21, 2026
fcc0232
feat(services): extract rateBook from library.ts
rsml Jul 21, 2026
8ddd691
feat(services): extract getSkillProgress from library.ts
rsml Jul 21, 2026
e2066dc
refactor(routes): rewire library.ts onto the extracted services
rsml Jul 21, 2026
2f7ce38
feat(services): extract readChapter from reading.ts
rsml Jul 21, 2026
0838357
feat(services): extract recordChapterProgress from reading.ts
rsml Jul 21, 2026
2faec9d
refactor(routes): rewire reading.ts onto the extracted services
rsml Jul 21, 2026
ec0e275
feat(services): extract createSkeleton from authoring.ts
rsml Jul 21, 2026
602d44d
feat(services): extract saveChapterContent from authoring.ts
rsml Jul 21, 2026
420caaf
feat(services): extract updateBookMeta from authoring.ts
rsml Jul 21, 2026
df960ef
feat(services): extract brief save/get from authoring.ts
rsml Jul 21, 2026
706f486
feat(services): extract summaries save/getAll from authoring.ts
rsml Jul 21, 2026
be6d304
feat(services): extract saveAuthoringToc from authoring.ts
rsml Jul 21, 2026
cf6c379
feat(services): extract references save/list/get from authoring.ts
rsml Jul 21, 2026
cea575c
feat(services): extract getAllFeedback from authoring.ts
rsml Jul 21, 2026
ddb7a3c
feat(services): extract saveQuiz from authoring.ts
rsml Jul 21, 2026
d8ba28c
refactor(routes): rewire authoring.ts onto the extracted services
rsml Jul 21, 2026
26f775e
test(services): assert resetBook also clears feedback
rsml Jul 21, 2026
b0a4733
feat(services): extract explainPassage from the chat route
rsml Jul 21, 2026
864d9d3
feat(services): extract getProfile and saveProfile from the profile r…
rsml Jul 21, 2026
c442454
feat(services): extract suggestSkills from the profile route
rsml Jul 21, 2026
e15c8ea
feat(services): extract interviewProfile from the profile route
rsml Jul 21, 2026
ac33114
refactor(routes): wire the profile route through its four services
rsml Jul 21, 2026
0284538
refactor(routes): extract listProviderModels and go through the KeyVa…
rsml Jul 21, 2026
9192fc4
refactor(routes): wire settings through the KeyVault port
rsml Jul 21, 2026
0162328
refactor(routes): wire tasks through the BackgroundTasks port
rsml Jul 21, 2026
6598d0a
test(ports): stop the ArtifactStore mtime assertion racing the disk
rsml Jul 21, 2026
dc41304
feat(domain): add final-quiz-plan, quiz-scoring, and learning-evidence
rsml Jul 21, 2026
25f4bdd
feat(services): add get-chapter-quiz service
rsml Jul 21, 2026
a522949
feat(services): add submit-feedback service
rsml Jul 21, 2026
fe5ca41
feat(services): add generate-final-quiz service
rsml Jul 21, 2026
01ba9f8
feat(services): add suggest-book-details service
rsml Jul 21, 2026
c2a7b2e
feat(services): add suggest-next-book service
rsml Jul 21, 2026
2c9dc21
feat(services): add suggest-profile-updates service
rsml Jul 21, 2026
11fcb21
refactor(routes): wire assessment and suggestions routes onto services
rsml Jul 21, 2026
cb08a1f
feat(services): extract export-epub service and epub-embedding domain…
rsml Jul 21, 2026
d752971
feat(services): extract import-book service and delete epub-importer
rsml Jul 21, 2026
aced2f3
feat(services): merge audiobook gates and narration into generate-aud…
rsml Jul 21, 2026
9975c00
feat(services): extract generate-cover, suggest-cover-prompt, and ser…
rsml Jul 21, 2026
b77e9bd
refactor(routes): wire audiobook engine routes through their ports di…
rsml Jul 21, 2026
f1796d4
refactor(domain): make profile context formatting pure
rsml Jul 21, 2026
a225a66
refactor(services): reconcile quiz generation onto the TextGeneration…
rsml Jul 21, 2026
01a61d4
feat(services): extract generate-next-chapter
rsml Jul 21, 2026
2f0e7d1
feat(services): extract the chapter-generation-stream hub
rsml Jul 21, 2026
c39559b
feat(services): extract generate-all-chapters
rsml Jul 21, 2026
d564149
feat(services): extract create-book
rsml Jul 21, 2026
a7f37ac
feat(services): extract revise-toc
rsml Jul 21, 2026
50799ae
feat(services): extract start-book
rsml Jul 21, 2026
612f121
refactor(services): reduce generation-manager to a compatibility shim
rsml Jul 21, 2026
dc7bb01
refactor(routes): rebuild generation routes as wiring over the new se…
rsml Jul 21, 2026
62fe3d3
chore(server): delete dead singleton shims with zero importers
rsml Jul 21, 2026
073384f
refactor(server): make chapter-range pure, collapse the legacy duplicate
rsml Jul 21, 2026
873a894
refactor(server): make profile-context pure, collapse the legacy dupl…
rsml Jul 21, 2026
efeb5d5
refactor(server): remove the mermaidRenderer decoration
rsml Jul 21, 2026
4dccf6e
refactor(server): replace the generation-manager registry with Shared…
rsml Jul 21, 2026
7adbb26
refactor(server): move crash recovery composition off book-store.js
rsml Jul 21, 2026
57bebfc
refactor(test): point route-harness's seedBook at the fs adapter dire…
rsml Jul 21, 2026
463194a
test(server): stop the frozen characterization suite importing book-s…
rsml Jul 21, 2026
14e3f57
refactor(server): delete book-store.ts, repoint its test at the real …
rsml Jul 21, 2026
445efef
chore(server): delete dead createTimeout, reword two stale comments
rsml Jul 21, 2026
45881b1
docs(plans): record what phase 2 actually shipped
rsml Jul 21, 2026
0a776dd
test(server): repoint kokoro coverage at the adapter, delete the shim
rsml Jul 21, 2026
92f1e1a
refactor(domain): name the final quiz question counts
rsml Jul 21, 2026
4de1199
docs(server): repoint comments at the modules that replaced the shims
rsml Jul 21, 2026
e8a5dd1
docs(plans): correct the sole adapter namer claim
rsml Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions docs/plans/refactor/phase-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ Scope: `server/` only. Assumes P1 delivered `server/ + shared/ + client/` with `

Consolidation deltas that modify this plan: S1 reduces to `shared/domain/provider.ts` consolidation only (P1 owns the schemas move); the schemas re-export shim does not exist so S12's shim deletion is moot; S1+S2+S3 land as an early `phase-2-foundations` PR merged to master so P3 can rebase; service unit tests are written red before each extraction (TDD delta 9).

## 0. Reconciled during execution

Facts found in the code that contradict what this plan was written against. The plan below is corrected in place; this list records what moved and why, so the diff between plan and reality is never silent.

1. **Test baseline.** Section 9 was written against a post-P0 count of 232. Phase 1 landed more tests, so the real baseline on `master` before this branch is 315. Stage S4 took it to 479, and the stage 2 items below took it to 481. The phase gate is measured against 481 plus the new adapter and service tests, not 232.
2. **`books.ts` size.** Read at 2,244 lines when this plan was written. The foundations PR moved constants, HTTP helpers, and the error handler out, so it is 2,176 lines and 47 route registrations at the point S6b splits it. Every line reference in sections 2 and 3 is therefore approximate and must be re-derived from the file rather than trusted.
3. **Sanctioned change 1 has no frozen assertion to edit.** Risk 4 assumed the eight MCP authoring routes had P0 characterization assertions locking their current 500. They do not. Those eight routes have zero test coverage of any kind, verified by grepping every `*.test.ts` under `server/` for their paths. So the 500 to 400 flip edits nothing. Instead this phase must ADD tests asserting the new 400, because an unsanctioned regression there would otherwise be invisible.
4. **DiagramRenderer failure contract.** The port shipped in S4 said a failed chart yields `''`. The two real implementations disagreed: kroki pushed `''`, Electron pushed an escaped mermaid code block holding the chart source. The architect standardized on the Electron behaviour, so a failed chart now yields `diagramSourceFallback(source)` and never `''`. The kroki adapter adopts it, which is sanctioned change 5, visible only in dev web mode.
5. **AudioAssembly cover embedding.** `ConcatToM4bRequest` gained an optional `coverPath`. Retrying the stitch without a cover when embedding fails stays adapter-internal resilience rather than a caller concern.
6. **Port count.** Thirteen ports shipped in S4, not the eleven plus two extras this plan estimated: TextGeneration, KeyVault, ImageGeneration, BookRepository, ArtifactStore, SpeechSynthesis, AudioAssembly, DiagramRenderer, EpubImport, EpubExport, BackgroundTasks, Clock, OsFileManager.
7. **`services/mermaid-renderer.ts` is confirmed dead.** Only its own test imports it. Every other `mermaid-renderer` hit in the tree is a log prefix string or a temp file name, not an import.
8. **Two ports were widened while their real adapters were built.** `AudioAssembly.concatToM4b` gained `bookTitle` alongside the architect's `coverPath`, because the real M4B stitch tags the file with the book's title as container metadata and as the FFMETADATA1 title line, and `AudiobookChapterEntry` carries only per-chapter titles. Without it the adapter could not reproduce today's tagging.
9. **`generation-manager.ts` and the standalone `generate-quiz.ts` were not duplicates.** The generation-manager copy appended `MARKDOWN_FORMATTING_RULES` to the quiz prompt and the standalone one did not. Reconciling them onto one implementation therefore needed an explicit `includeFormattingRules` flag rather than a straight merge, so each caller keeps the prompt it has always sent.
10. **The route split produced 46 route registrations, not 47.** Counted by grep across the original file, and the eight target modules account for all 46.
11. **Three `server/domain/` modules were not pure.** `profile-context.ts`, `chapter-range.ts`, and `skill-progress-report.ts` all reached the filesystem through the `book-store` singleton. Each was split into a pure core plus a port-taking wrapper in `server/services/`, which is what section 6 intended by "server-only pure helpers" but the mechanical split alone did not achieve.
12. **The singleton-to-factory risk was handled with deliberate temporary shims.** Rather than converting call sites and factories in one step across five parallel slices, each singleton became a thin shim over its new adapter in a single atomic commit, callers migrated onto ports slice by slice, and the shims were deleted only once nothing imported them. No half-converted state ever existed, so the production data directory was never at risk.

## 0b. Final shape

13 ports, 15 adapters, 52 services, 8 domain modules, 16 route modules, none over 200 lines. `createPorts(overrides)` builds all thirteen, `createSharedServices(ports)` builds the in-memory state two route modules must share, and `buildServer(overrides)` threads both into every route plugin as plugin options.

`server/composition-root.ts` is the only module that names a concrete adapter, with two documented exceptions. `server/services/recover-from-crash.ts` imports `cleanTmpArtifacts` from the filesystem book repository, because sweeping the stray `.tmp` files left by an interrupted write has no equivalent on the `BookRepository` port, which that function's own doc comment explains. Outside `server/`, `electron/main.ts` names the Electron diagram renderer, because Electron is its own composition point and hands the renderer to `startServer` as a port override.

## 1. Port catalog

Location: `server/ports/<port>.ts` — interface + fake + contract test per port. All signatures derived from actual call sites (line refs are current `server/`).
Expand Down Expand Up @@ -77,7 +100,7 @@ get(p: ProviderId): string|null; set(p, key): void; remove(p): void; has(p):
status(): Record<ProviderId, boolean>

// ports/diagram-renderer.ts
render(charts: string[]): Promise<string[]> // markup per chart; '' = failed
render(charts: string[]): Promise<string[]> // markup per chart; failed = diagramSourceFallback(source), never ''

// ports/epub-import.ts — MUST NOT persist (today epub-importer.ts:10 imports book-store)
preview(bytes: Buffer): Promise<EpubPreview>
Expand Down Expand Up @@ -206,16 +229,16 @@ Each port ships `ports/<name>.fake.ts` (in-memory, recording) and `ports/<name>.
1. **SSE regressions** (`reply.hijack`, `raw.writeHead`, `request.raw.on('close')`) are invisible to unit tests. Require ≥1 P0 characterization test per streaming route asserting event order; manual boot per slice.
2. **Timeout semantics move** into the adapter. Verify `AbortSignal.any` (Node 24 ✓, Electron 40 ✓); keep manual controller-combining as fallback.
3. **Electron packaging**: adapters change the rollup `external()` list in `vite.config.ts`; `epub-gen-memory`'s double-default and `kokoro-js`/`@huggingface/transformers` must stay external. Gate `electron:preview` after S5 and S12.
4. **Sanctioned behavior change**: 8 MCP CRUD routes (books.ts:2065, 2089, 2106, 2123, 2145, 2171, 2194, 2237) call `.parse()` unguarded and currently return **500** on bad input; `parseBody` makes them **400**. Architect accepted: document in the PR, update those P0 assertions — the only assertion edit allowed this phase.
4. **Sanctioned behavior change**: 8 MCP CRUD routes call `.parse()` unguarded and currently return **500** on bad input; `parseBody` makes them **400**. Architect accepted. At the current file size those unguarded parses sit at books.ts:1997, 2021, 2038, 2055, 2077, 2103, 2126, 2169. They have no test coverage at all, so nothing is edited and new tests assert the 400 instead (section 0 item 3).
5. **Singleton→factory**: `epub-importer`, `audiobook-generator`, `mcp-server` import `* as store` at module scope. All must convert in S5 or keep a shim; a half-converted state silently writes to the production data dir.
6. **Test-mock debt**: `vi.mock('../../lib/data-dir.js')` must be deleted when the factory lands, otherwise tests pass against the wrong directory.
7. `electron/main.ts:385` sets `fastify.mermaidRenderer`; switching to `setDiagramRenderer` requires an `electron/` edit — coordinate with the orchestrator on that file.
8. **Scope**: ~40 new files. Every slice must be independently mergeable and leave the app green.

## 9. Phase gate

- `pnpm test` green, count ≥ 232 + new contract/service tests (Phase 0 corrected the inflated 384 baseline — vitest was collecting nested worktree copies; the true post-P0 count is 232); `tsc --noEmit` clean; ESLint 0 warnings including new boundary rules.
- `pnpm test` green, count ≥ 481 + new adapter/service tests (see section 0 item 1 for how the baseline moved: 315 on master, 479 at S4, 481 after the stage 2 items); `tsc --noEmit` clean; ESLint 0 warnings including new boundary rules.
- Grep gates: `rg "from 'ai'" server/{routes,services}` = 0 · `rg "node:fs" server/routes` = 0 · `rg "5 \* 60 \* 1000" server` = 0 · `rg "instanceof ZodError" server` = 0 · `rg "await import\(" server/routes` = 0 · no route file > 200 lines · `server/routes/books.ts` gone.
- Contract test exists for all mandated ports; hermetic ones also run against the real adapter.
- `electron:dev` + `electron:preview` boot; manual E2E once: create book (SSE) → read → feedback+quiz → generate next → export EPUB → audiobook range request returns 206.
- P0 characterization assertions unchanged except the documented 400/500 item.
- P0 characterization assertions unchanged. The 400/500 item edits none of them, because those eight routes had no coverage to edit, so it ships with new tests instead (section 0 item 3).
97 changes: 15 additions & 82 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { getDataDir } from '@shared/node/data-dir.js'
import { startServer } from '../server/index.js'
import { createElectronDiagramRenderer } from '../server/adapters/electron-diagram-renderer.js'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const esmRequire = createRequire(import.meta.url)
Expand Down Expand Up @@ -323,8 +324,20 @@ let apiPort = 0

app.whenReady().then(async () => {
await migrateLegacyPlaintextKeys()
// Start the embedded API server on a random free port (localhost only — no firewall prompt)
const server = await startServer(0, '127.0.0.1')
// Start the embedded API server on a random free port (localhost only — no firewall prompt).
// The diagram renderer goes in as a port override rather than being
// assigned onto the built server afterwards, so the server is never
// briefly live on the kroki.io fallback it will not use, and Electron
// never has to cast away the instance's type to reach a decoration.
// Renders to PNG <img> tags — SVGs render poorly in most e-readers, and
// this one works offline, unlike the kroki.io default.
const server = await startServer(0, '127.0.0.1', {
diagramRenderer: createElectronDiagramRenderer({
BrowserWindow,
dataDir,
resolveMermaidPath: () => esmRequire.resolve('mermaid/dist/mermaid.min.js'),
}),
})
const addr = server.server.address()
apiPort = typeof addr === 'object' && addr ? addr.port : 0

Expand Down Expand Up @@ -376,86 +389,6 @@ app.whenReady().then(async () => {
} catch { /* swallow */ }
})

// Override mermaid renderer with Electron BrowserWindow-based renderer
// (faster and works offline, unlike the kroki.io API fallback).
// Renders to PNG <img> tags — SVGs render poorly in most e-readers.
const { sanitizeMermaidChart } = await import('@shared/sanitize-mermaid.js')
const { mermaidInitConfig } = await import('@shared/mermaid-theme.js')

;(server as unknown as { mermaidRenderer: unknown }).mermaidRenderer = async (charts: string[]) => {
if (charts.length === 0) return []

const win = new BrowserWindow({
show: false,
width: 1600,
height: 1200,
webPreferences: { offscreen: true },
})

try {
const mermaidPath = esmRequire.resolve('mermaid/dist/mermaid.min.js')
const mermaidJs = await readFile(mermaidPath, 'utf-8')

const tmpHtml = path.join(dataDir, 'mermaid-renderer.html')
// Safe: mermaidJs is from a trusted local npm package, not user input
await writeFile(tmpHtml, `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>body { margin: 0; background: white; }</style>
</head><body>
<div id="output"></div>
<script>${mermaidJs}<` + `/script>
<script>
mermaid.initialize(${JSON.stringify({ ...mermaidInitConfig, theme: 'default' })});
<` + `/script>
</body></html>`, 'utf-8')

await win.loadFile(tmpHtml)

const results: string[] = []
for (let i = 0; i < charts.length; i++) {
const sanitized = sanitizeMermaidChart(charts[i])
try {
// Render mermaid SVG, insert into DOM, then capture page as PNG
const dimensions: { width: number; height: number } = await Promise.race([
win.webContents.executeJavaScript(`
(async () => {
const { svg } = await mermaid.render('epub-chart-${i}', ${JSON.stringify(sanitized)});
const output = document.getElementById('output');
output.replaceChildren();
output.insertAdjacentHTML('afterbegin', svg);
const svgEl = output.querySelector('svg');
const rect = svgEl.getBoundingClientRect();
return { width: Math.ceil(rect.width) + 20, height: Math.ceil(rect.height) + 20 };
})()
`),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Mermaid render timeout')), 10_000)
),
])

// Resize to fit diagram and capture as PNG
win.setContentSize(Math.max(dimensions.width, 200), Math.max(dimensions.height, 100))
await new Promise(r => setTimeout(r, 100))
const image = await win.webContents.capturePage()
const pngBuffer = image.toPNG()
// Save to temp file — epub-gen-memory doesn't support data: URLs
const tmpPng = path.join(dataDir, `mermaid-chart-${i}.png`)
await writeFile(tmpPng, pngBuffer)
const { pathToFileURL } = await import('node:url')
results.push(`<img src="${pathToFileURL(tmpPng).href}" alt="diagram" style="max-width:100%"/>`)
} catch (err) {
console.warn('[mermaid-renderer] Chart ' + i + ' failed:', err)
results.push('<pre><code class="language-mermaid">' + sanitized.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</code></pre>')
}
}

await rm(tmpHtml).catch(() => {})
return results
} finally {
win.destroy()
}
}

// POST all saved API keys to the server's key store. loadApiKey handles
// identity-suffix paths, legacy fallback, and migration; failures are
// logged but never delete the .enc file.
Expand Down
123 changes: 123 additions & 0 deletions server/adapters/ai-sdk-text-generation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import type { LanguageModel } from 'ai'
import { AI_GENERATION_TIMEOUT_MS } from '../constants.js'
import { createFakeKeyVault } from '../ports/key-vault.fake.js'
import { composeAbortSignal, resolveModelClient } from './ai-sdk-text-generation.js'

// resolveModelClient's return type, LanguageModel, is a union that also
// allows a bare model-id string, for a registry-style global provider. The
// real implementation never returns that variant. It always calls one of
// the three provider factories and returns the resulting model object.
// This narrows that union back down so tests can read `.modelId` off it.
function modelIdOf(model: LanguageModel): string {
if (typeof model === 'string') {
throw new Error('expected a resolved model object, got a bare model id string')
}
return model.modelId
}

/**
* The TextGeneration contract (server/ports/text-generation.contract.ts) is
* fake-only and is never run against this adapter here. Doing so would
* spend real money against a live provider and reach the network. These
* tests instead cover the two parts of the adapter that are hermetically
* testable without a network call or an API key. Those are provider and
* model resolution, and the timeout signal composition. Constructing a
* model client below never phones home. Every AI SDK provider factory is
* lazy, only making a request once the returned model is actually used to
* generate or stream something, which none of these tests do.
*/

describe('resolveModelClient', () => {
it('throws for a provider the app does not know about', () => {
const keyVault = createFakeKeyVault({ anthropic: 'sk-test' })

expect(() => resolveModelClient(keyVault, 'not-a-real-provider', 'claude-sonnet-4-6'))
.toThrow('Invalid provider: not-a-real-provider')
})

it('throws for a model identifier with characters outside MODEL_REGEX', () => {
const keyVault = createFakeKeyVault({ anthropic: 'sk-test' })

expect(() => resolveModelClient(keyVault, 'anthropic', 'not a valid model!'))
.toThrow('Invalid model identifier: not a valid model!')
})

it('throws when the vault has no key for the requested provider', () => {
const keyVault = createFakeKeyVault()

expect(() => resolveModelClient(keyVault, 'anthropic', 'claude-sonnet-4-6'))
.toThrow('No API key configured for provider: anthropic')
})

it('resolves an anthropic model client once a key is present', () => {
const keyVault = createFakeKeyVault({ anthropic: 'sk-test-dummy' })

const model = resolveModelClient(keyVault, 'anthropic', 'claude-sonnet-4-6')

expect(model).toBeTruthy()
expect(modelIdOf(model)).toBe('claude-sonnet-4-6')
})

it('resolves an openai model client once a key is present', () => {
const keyVault = createFakeKeyVault({ openai: 'sk-test-dummy' })

const model = resolveModelClient(keyVault, 'openai', 'gpt-4o')

expect(model).toBeTruthy()
expect(modelIdOf(model)).toBe('gpt-4o')
})

it('resolves a google model client once a key is present', () => {
const keyVault = createFakeKeyVault({ google: 'test-dummy-key' })

const model = resolveModelClient(keyVault, 'google', 'gemini-2.0-flash')

expect(model).toBeTruthy()
expect(modelIdOf(model)).toBe('gemini-2.0-flash')
})
})

describe('composeAbortSignal', () => {
it('is not aborted when neither the caller signal nor the timeout has fired', () => {
const combined = composeAbortSignal(undefined, AI_GENERATION_TIMEOUT_MS)

expect(combined.aborted).toBe(false)
})

it('aborts the composed signal when the caller signal aborts', () => {
const controller = new AbortController()
const combined = composeAbortSignal(controller.signal, AI_GENERATION_TIMEOUT_MS)

expect(combined.aborted).toBe(false)
controller.abort()
expect(combined.aborted).toBe(true)
})

// AbortSignal.timeout()'s countdown runs on the runtime's own native
// timer, not the global setTimeout that vi.useFakeTimers() intercepts.
// This was verified empirically, advancing fake timers by the full
// timeout duration never fires a real AbortSignal.timeout(). So the
// "internal timeout also aborts the composed signal" behaviour below is
// exercised with a real, but tiny, timeout instead of a faked
// five-minute one. The `timeoutMs` parameter exists for exactly this.
// Production code never passes it, and always relies on the
// AI_GENERATION_TIMEOUT_MS default.

it('aborts the composed signal on its own once the internal timeout elapses', async () => {
const combined = composeAbortSignal(undefined, 10)

expect(combined.aborted).toBe(false)
await new Promise((resolve) => setTimeout(resolve, 50))
expect(combined.aborted).toBe(true)
})

it('aborts on its own timeout even when a caller signal is provided and never fires', async () => {
const controller = new AbortController()
const combined = composeAbortSignal(controller.signal, 10)

await new Promise((resolve) => setTimeout(resolve, 50))

expect(combined.aborted).toBe(true)
})
})
Loading
Loading