diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7ea06d6..a484f91 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,7 @@ "build": "electron-vite build", "start": "electron-vite preview", "typecheck": "tsc --noEmit", + "test": "vitest run", "e2e": "electron-vite build && playwright test", "e2e:only": "playwright test", "e2e:report": "playwright show-report e2e/.artifacts/report", diff --git a/apps/desktop/src/main/snapshot-store.test.ts b/apps/desktop/src/main/snapshot-store.test.ts new file mode 100644 index 0000000..2bd7c26 --- /dev/null +++ b/apps/desktop/src/main/snapshot-store.test.ts @@ -0,0 +1,86 @@ +/** + * Purity / rebuild guarantee for the *shipped* durable cache (#30, D4). The app's + * on-disk reconcile cache is this JSON snapshot-store. The invariant: delete it + * and lose nothing — the durable truth is in gitea, the file is only a boot/offline + * mirror. This test deletes the real file and asserts the store degrades to + * "no cache" (null), which is what forces the next `getSnapshot` to reconcile + * fresh from gitea rather than serve stale or missing data. + */ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +// electron can't be imported outside the Electron runtime; the store only needs +// app.getPath for its default path, which every test overrides with an injected path. +vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })) + +import { loadSnapshot, type PersistedSnapshot, saveSnapshot } from './snapshot-store.js' + +const SNAP: Omit = { + issues: [{ number: 1, title: 'An issue', state: 'open', labels: ['est/5d', 'p/1'] }], + milestones: [{ id: 7, title: 'P2' }], + deps: [{ issue: 1, dependsOn: 2 }], + timelines: { 1: [{ type: 'opened', at: '2026-07-01T00:00:00Z' }] }, +} + +describe('snapshot-store purity (#30)', () => { + let dir: string | null = null + const path = () => { + if (!dir) dir = mkdtempSync(join(tmpdir(), 'commitea-snap-')) + return join(dir, 'commitea-snapshot.json') + } + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = null + }) + + it('round-trips a saved snapshot', () => { + const p = path() + saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p) + const back = loadSnapshot(p) + expect(back).toEqual({ ...SNAP, savedAt: '2026-07-09T00:00:00Z' }) + }) + + it('delete the cache file → load returns null (degrades to no-cache, not a crash)', () => { + const p = path() + saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p) + expect(loadSnapshot(p)).not.toBeNull() + + rmSync(p) // delete the durable cache + expect(existsSync(p)).toBe(false) + + // The store must NOT throw and must report "no cache" so the next reconcile + // rebuilds from gitea. If this ever returned stale data or threw, D4 breaks. + expect(loadSnapshot(p)).toBeNull() + }) + + it('a corrupt / partial file is treated as no-cache, never a crash', () => { + const p = path() + writeFileSync(p, '{ this is not json', 'utf8') + expect(loadSnapshot(p)).toBeNull() + // valid JSON but wrong shape (no issues array) is also rejected + writeFileSync(p, JSON.stringify({ savedAt: 'x' }), 'utf8') + expect(loadSnapshot(p)).toBeNull() + }) + + it('re-saving after a delete restores the cache — the rebuild is lossless', () => { + const p = path() + saveSnapshot(SNAP, '2026-07-09T00:00:00Z', p) + const before = loadSnapshot(p) + + rmSync(p) + expect(loadSnapshot(p)).toBeNull() + + // a resync would call saveSnapshot again with the freshly reconciled data + saveSnapshot(SNAP, '2026-07-09T01:00:00Z', p) + const after = loadSnapshot(p) + + // same durable payload, only the savedAt marker differs + expect(after!.issues).toEqual(before!.issues) + expect(after!.milestones).toEqual(before!.milestones) + expect(after!.deps).toEqual(before!.deps) + expect(after!.timelines).toEqual(before!.timelines) + }) +}) diff --git a/apps/desktop/src/main/snapshot-store.ts b/apps/desktop/src/main/snapshot-store.ts index 2f79e39..2a2ed93 100644 --- a/apps/desktop/src/main/snapshot-store.ts +++ b/apps/desktop/src/main/snapshot-store.ts @@ -26,10 +26,15 @@ function snapshotPath(): string { return join(app.getPath('userData'), 'commitea-snapshot.json') } -/** Load the last persisted snapshot, or null if absent/corrupt. Never throws. */ -export function loadSnapshot(): PersistedSnapshot | null { +/** + * Load the last persisted snapshot, or null if absent/corrupt. Never throws — a + * deleted or unreadable cache degrades to "no cache" (the purity guarantee, D4: + * the next reconcile rebuilds it from gitea). `path` is injectable for tests; + * production always uses the userData file. + */ +export function loadSnapshot(path: string = snapshotPath()): PersistedSnapshot | null { try { - const parsed = JSON.parse(readFileSync(snapshotPath(), 'utf8')) as PersistedSnapshot + const parsed = JSON.parse(readFileSync(path, 'utf8')) as PersistedSnapshot if (parsed && Array.isArray(parsed.issues)) return parsed return null } catch { @@ -38,9 +43,9 @@ export function loadSnapshot(): PersistedSnapshot | null { } /** Persist a freshly reconciled snapshot. Best-effort — a write failure never breaks a reconcile. */ -export function saveSnapshot(snap: Omit, savedAt: string): void { +export function saveSnapshot(snap: Omit, savedAt: string, path: string = snapshotPath()): void { try { - writeFileSync(snapshotPath(), JSON.stringify({ ...snap, savedAt }), 'utf8') + writeFileSync(path, JSON.stringify({ ...snap, savedAt }), 'utf8') } catch { // disk full / permissions — the in-memory cache still works this session } diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 0000000..2879a57 --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' + +/** + * Unit tests for the Electron main process. Node environment only — renderer + * (React) is covered by the Playwright e2e suite, not here. `electron` is a + * native module that can't be imported outside the Electron runtime, so tests + * that touch it mock it (see snapshot-store.test.ts). + */ +export default defineConfig({ + test: { + environment: 'node', + include: ['src/main/**/*.test.ts'], + }, +}) diff --git a/packages/core/src/cache/cache-purity-v0.test.ts b/packages/core/src/cache/cache-purity-v0.test.ts new file mode 100644 index 0000000..5600b31 --- /dev/null +++ b/packages/core/src/cache/cache-purity-v0.test.ts @@ -0,0 +1,191 @@ +/** + * Purity / rebuild guarantee (#30, D4). The SQLite cache is a *rebuildable index* + * over the durable truth in gitea — never a source of truth. This test enforces + * that invariant the only way that matters: nuke the database file on disk and + * rebuild it from the same gitea snapshot, then assert nothing was lost. + * + * If a future change ever stores a fact that lives only in the cache (a + * user-authored note, a locally-computed field that isn't re-derived from the + * label set), the delete→resync round-trip would drop it and this test fails. + * That is the whole point: the cache must survive being deleted. + * + * Uses a real on-disk SQLite file via node:sqlite (present in the test runtime; + * main binds better-sqlite3 to the same CacheDriver seam), so "delete the SQLite + * file" is literal `rm`, not a metaphor. + */ +import { DatabaseSync } from 'node:sqlite' +import { mkdtempSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import type { GiteaIssue } from '../gitea/types.js' +import { extractLabelFacts } from '../labels/label-schema.js' +import { type CacheDriver, initCache, readIssue, upsertIssue } from './cache-v0.js' + +/** Adapt a file-backed node:sqlite handle to the CacheDriver seam, returning the path too. */ +function fileDriver(path: string): { driver: CacheDriver; close: () => void } { + const db = new DatabaseSync(path) + const driver: CacheDriver = { + exec: (sql) => db.exec(sql), + run: (sql, params = []) => { + db.prepare(sql).run(...(params as never[])) + }, + get: (sql, params = []) => db.prepare(sql).get(...(params as never[])) as Record | undefined, + all: (sql, params = []) => db.prepare(sql).all(...(params as never[])) as Record[], + } + return { driver, close: () => db.close() } +} + +function issue(over: Partial = {}): GiteaIssue { + const labels = over.labels ?? ['est/5d', 'p/1'] + return { + number: 1, + title: 'An issue', + body: 'body', + state: 'open', + labels, + facts: extractLabelFacts(labels), + milestone: null, + assignee: 'christian', + assignees: ['christian'], + createdAt: '2026-07-01T00:00:00Z', + updatedAt: '2026-07-02T00:00:00Z', + closedAt: null, + url: 'https://gitea/christian/commitea/issues/1', + ...over, + } +} + +/** A representative reconciled backlog: open + closed, milestones, reassignment, no-milestone, empty labels. */ +const GITEA_TRUTH: GiteaIssue[] = [ + issue({ + number: 42, + title: 'Monte Carlo engine', + labels: ['est/8d', 'p/1', 'deadline/hard'], + milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' }, + assignee: 'christian', + assignees: ['christian'], + }), + issue({ + number: 43, + title: 'Calibration honesty', + labels: ['est/3d', 'p/2'], + state: 'closed', + closedAt: '2026-07-05T00:00:00Z', + milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' }, + assignee: 'stephen', + assignees: ['stephen'], + }), + issue({ number: 44, title: 'No milestone, no labels', labels: [], milestone: null, assignee: null, assignees: [] }), +] + +/** Rebuild a cache from scratch out of the gitea snapshot — exactly what a resync does. */ +function rebuildFrom(driver: CacheDriver, truth: GiteaIssue[]): void { + initCache(driver) + for (const i of truth) upsertIssue(driver, i) +} + +/** The complete durable state we can read back — every issue, fully re-derived. */ +function readAll(driver: CacheDriver, truth: GiteaIssue[]): (GiteaIssue | null)[] { + return truth.map((i) => readIssue(driver, i.number)) +} + +describe('cache purity / rebuild (#30)', () => { + let dir: string | null = null + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = null + }) + + it('delete the SQLite file → resync → no durable truth is lost', () => { + dir = mkdtempSync(join(tmpdir(), 'commitea-cache-')) + const dbPath = join(dir, 'cache.sqlite') + + // 1. Build the cache from gitea and capture everything we can read back. + const first = fileDriver(dbPath) + rebuildFrom(first.driver, GITEA_TRUTH) + const before = readAll(first.driver, GITEA_TRUTH) + first.close() + expect(existsSync(dbPath)).toBe(true) + // sanity: the snapshot actually holds derived facts, not just rows + expect(before[0]!.facts).toEqual(extractLabelFacts(['est/8d', 'p/1', 'deadline/hard'])) + expect(before[0]!.facts.estimateDays).toBe(8) + + // 2. Delete the SQLite file. This is the durable cache, gone. + rmSync(dbPath) + // node:sqlite also drops a -journal/-wal sidecar in some modes; clear the dir of any residue. + expect(existsSync(dbPath)).toBe(false) + + // 3. Resync: a brand-new empty DB rebuilt from the *same* gitea snapshot. + const second = fileDriver(dbPath) + rebuildFrom(second.driver, GITEA_TRUTH) + const after = readAll(second.driver, GITEA_TRUTH) + second.close() + + // 4. Nothing was lost — the rebuilt cache is identical, field for field. + expect(after).toEqual(before) + }) + + it('a re-reconcile that changes gitea updates in place — the rebuild reflects truth, never stale rows', () => { + dir = mkdtempSync(join(tmpdir(), 'commitea-cache-')) + const dbPath = join(dir, 'cache.sqlite') + + const first = fileDriver(dbPath) + rebuildFrom(first.driver, GITEA_TRUTH) + first.close() + + // gitea moved on: #42 re-estimated + reassigned, #43 reopened. + const NEW_TRUTH: GiteaIssue[] = [ + issue({ ...GITEA_TRUTH[0], labels: ['est/2d', 'p/3'], assignee: 'stephen', assignees: ['stephen'] }), + issue({ ...GITEA_TRUTH[1], state: 'open', closedAt: null }), + GITEA_TRUTH[2], + ] + + // resync over the existing file (upsert-by-number), not a fresh DB + const second = fileDriver(dbPath) + initCache(second.driver) + for (const i of NEW_TRUTH) upsertIssue(second.driver, i) + const rowCount = second.driver.all('SELECT number FROM issues') + const back42 = readIssue(second.driver, 42)! + const back43 = readIssue(second.driver, 43)! + second.close() + + expect(rowCount).toHaveLength(3) // upsert in place — no duplicate rows accreted across reconciles + expect(back42.facts).toEqual(extractLabelFacts(['est/2d', 'p/3'])) + expect(back42.facts.estimateDays).toBe(2) + expect(back42.assignee).toBe('stephen') + expect(back43.state).toBe('open') + expect(back43.closedAt).toBeNull() + }) + + it('the cache stores no column that is not re-derivable from gitea (structural D4 guard)', () => { + dir = mkdtempSync(join(tmpdir(), 'commitea-cache-')) + const dbPath = join(dir, 'cache.sqlite') + const { driver, close } = fileDriver(dbPath) + initCache(driver) + + // Every issues-table column must map to a field carried on the gitea issue + // (or be a re-derivable mirror of one). If someone adds a user-authored + // column, it won't be in this allow-list and this guard fails — forcing a + // deliberate decision about durability instead of silently breaking D4. + const cols = driver.all('PRAGMA table_info(issues)').map((r) => r.name as string) + const FROM_GITEA = new Set([ + 'number', + 'title', + 'body', + 'state', + 'labels', // facts are re-derived from this on read, never stored + 'milestone_id', + 'assignee', + 'assignees', + 'created_at', + 'updated_at', + 'closed_at', + 'url', + ]) + close() + expect(cols.filter((c) => !FROM_GITEA.has(c))).toEqual([]) + }) +})