diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8481791a..39ccbd32e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -655,14 +655,28 @@ Launch with `mode: "plan"` to produce a PRD before writing code. Plan Mode restr ## Tests -Tests live next to the code they cover under `electron/`: +Tests live next to the code they cover, under both `electron/` (store/lib/MCP logic) and `src/` (store slices, pure libs, and React components): ```bash npm test # run all tests npm run test:watch # watch mode — great for TDD npm run test:coverage # coverage report +npm run test:unit # node project only (store/lib/electron logic) +npm run test:component # component project only (React + jsdom) ``` +vitest runs **two projects** (see `vitest.config.ts`): + +- **`node`** — the default fast suite for store slices, pure libs, and the + electron/MCP layer. Runs in the `node` environment with the native SQLite + shim. Matches `electron/**/*.test.ts`, `src/**/*.test.ts`, and + `src/**/*.test.tsx`, excluding component tests (`src/**/*.component.test.tsx`). +- **`component`** — React component tests rendered in **jsdom** via + `@testing-library/react`. Matches `src/**/*.component.test.tsx` and loads + `vitest.setup.components.ts` (jest-dom matchers + auto-cleanup). Use these for + presentational behavior (disabled states, ARIA attributes, click handlers). + Store-connected components can mock `@/store` with `vi.mock`. + | Test file | What it covers | |-----------|---------------| | `electron/db/queries.test.ts` | SQLite query helpers — CRUD, search, soft-delete | diff --git a/changelogs/v2.3.3.md b/changelogs/v2.3.3.md new file mode 100644 index 000000000..f95ec378b --- /dev/null +++ b/changelogs/v2.3.3.md @@ -0,0 +1,26 @@ +## What's new + +A quality release that hardens the test suite around the v2.3.2 features and adds a React component-testing harness, plus two Markdown bug fixes. + +### Bug fixes + +- **Clickable checkboxes inside tables**: Task-list checkboxes (`[ ]` / `[x]`) placed inside a Markdown table cell now render as real, clickable checkboxes and toggle the underlying source — previously they stayed as plain text. (GFM doesn't emit checkboxes inside table cells, so the preview now recovers them via custom `td`/`th` rendering, with toggling mapped back to source in document order across both list-item and table-cell checkboxes.) +- **Ordered-list numbering in the editor**: The note editor now auto-continues and renumbers ordered/bulleted lists on Enter, exits the list cleanly from an empty item, and renumbers correctly on Backspace. Previously breaking the numbering left the list out of order. + +### Changes + +- **Shared "What's New" gate**: Extracted the duplicated "unseen latest features" logic (previously copy-pasted in `NewFeatureModal` and the boot path in `page.tsx`) into a single pure `getUnseenLatestFeatures(registry, seenFeatures, forceOpen?)`. Both call sites now share one implementation, eliminating drift between the modal and the tour-deferral gate. No behavior change. + +### Tests + +- **Component test harness**: Added a second vitest project (`component`) running **jsdom + Testing Library** alongside the existing `node` project, plus `npm run test:unit` / `npm run test:component` scripts and a jest-dom setup file. Component tests live in `*.component.test.tsx`. +- **Onboarding component tests**: `StepCreateProject` — submit gating on empty/whitespace names, icon picker selection and `aria-pressed` state, accessible labels on icon-only buttons, and all controls (back, skip, icon picker) disabled while a create is in flight. +- **What's New modal component tests**: boot gating (unseen shows, seen hidden), "Done" marks the feature seen and closes, and — via the `forceOpen` (Settings) path — pagination dots, Next navigation, and "Skip All" marking every displayed feature seen. +- **Feature-gate unit tests**: full coverage of `getUnseenLatestFeatures` (forceOpen, empty registry, latest-version-only filtering, seen filtering, order-derived latest version). +- **Store & pure-logic unit tests**: + - `createProject` — icon persistence (the v2.3.2 onboarding fix), icon omitted when absent, the 5 optimistic columns with correct types/orders, and append-not-replace semantics. + - UI slice tour/seen-feature actions — `markFeatureAsSeen` (append/idempotent), `setTutorialActive` (with its step-index reset), `setTutorialStepIndex`. + - Board slice — `moveCard` (same/cross-column reindex, index clamping, untouched columns) and `getReadyCards` (blocker resolution, done/archived handling, orphan blockers, project scoping). + - `buildFolderTree` — folder-path normalization edge cases (undefined vs empty, leading/trailing/double slashes, deep nesting, alphabetical sort). + - `searchAll` — empty-query, title/content/description matching, snippet length, archived skipping, 50-result cap. + - Date helpers — `formatRelative` thresholds and `getDueDateStatus` (timezone-robust day comparison). \ No newline at end of file diff --git a/electron/ipc/registry.test.ts b/electron/ipc/registry.test.ts new file mode 100644 index 000000000..9df9a5ad9 --- /dev/null +++ b/electron/ipc/registry.test.ts @@ -0,0 +1,86 @@ +/** + * Unit tests for the registry's read/write channel classification. + * + * `isWriteChannel` decides whether a completed `db:*` channel auto-broadcasts + * `db:changed` (a full snapshot re-hydration in every window + mobile client). + * Misclassifying a read as a write causes a silent re-hydration storm, so this + * guards the convention: reads (by action verb or the small irregular-name set) + * must never broadcast; everything else `db:*` does. + */ + +import { describe, it, expect, vi } from "vitest"; + +// registry.ts imports `electron` at module load; stub the bits it touches so the +// pure classifier can be imported in the node test environment. +vi.mock("electron", () => ({ + ipcMain: { handle: vi.fn(), on: vi.fn() }, + BrowserWindow: { getAllWindows: () => [] }, +})); + +import { __isWriteChannel as isWriteChannel } from "./registry"; + +describe("isWriteChannel", () => { + it("treats non-db channels as non-writes (they never broadcast db:changed)", () => { + for (const c of ["app:setTheme", "git:status", "chat:stream", "updater:install"]) { + expect(isWriteChannel(c)).toBe(false); + } + }); + + it("classifies write actions as writes", () => { + const writes = [ + "db:note:create", + "db:note:update", + "db:note:delete", + "db:note:moveToFolder", + "db:card:addBlocker", + "db:card:removeBlocker", + "db:cards:archive-done", + "db:chat:addMessage", + "db:chat:upsertThread", + "db:chat:deleteThread", + "db:chat:clearThreadMessages", + "db:project:updateSettings", + "db:piSession:saveMessages", + "db:graph:recompute", + "db:embeddings:reindex", + "db:embeddings:recomputeProjections", + "db:flow:node:update", + "db:flow:node:delete", + "db:flow:edge:delete", + ]; + for (const c of writes) expect(isWriteChannel(c)).toBe(true); + }); + + it("classifies read actions as reads (no broadcast)", () => { + const reads = [ + "db:workspace:list", + "db:project:list", + "db:note:list", + "db:column:list", + "db:card:list", + "db:card:ready", + "db:flow:get", + "db:graph:get", + "db:graph:neighbors", + "db:tag:list", + "db:chat:threads", + "db:chat:messages", + "db:piSession:list", + "db:piSession:messages", + "db:embeddings:search", + ]; + for (const c of reads) expect(isWriteChannel(c)).toBe(false); + }); + + it("classifies irregularly-named read channels as reads", () => { + for (const c of ["db:snapshot", "db:hasData", "db:mcpQuery"]) { + expect(isWriteChannel(c)).toBe(false); + } + }); + + it("does not broadcast for db:flow:url:fetch — a pure URL-metadata read (regression)", () => { + // Previously misclassified as a write because it wasn't in the denylist, + // causing a db:changed re-hydration on every URL preview. + expect(isWriteChannel("db:flow:url:fetch")).toBe(false); + }); +}); diff --git a/electron/ipc/registry.ts b/electron/ipc/registry.ts index 214e64b0c..9048f6f62 100644 --- a/electron/ipc/registry.ts +++ b/electron/ipc/registry.ts @@ -29,33 +29,50 @@ const listeners = new Map(); let mobileBroadcastCallback: ((channel: string, payload: unknown) => void) | null = null; -function isWriteChannel(channel: string): boolean { - if (!channel.startsWith("db:")) return false; +/** + * Decide whether a completed `db:*` channel should auto-broadcast `db:changed` + * (which triggers a full snapshot re-hydration in every window + mobile client). + * + * Write channels broadcast; read channels must NOT. The previous implementation + * was a pure denylist of read channels, defaulting any *unlisted* `db:*` channel + * to "write" — so a forgotten read channel would silently fire `db:changed` on + * every call (a re-hydration storm that never errors, just wastes work). + * + * The codebase follows a strict `db::` naming convention, so we + * primarily classify by the trailing action verb: reads are the well-known + * read verbs below. A small denylist of irregularly-named read channels + * (e.g. `db:chat:threads`, `db:snapshot`) covers the cases that don't end in a + * read verb. Anything else is treated as a write. + */ +const READ_ACTIONS = new Set([ + "list", + "get", + "search", + "neighbors", + "ready", + "messages", + "threads", + "fetch", // db:flow:url:fetch — fetches URL metadata, no DB write +]); - const readChannels = [ - "db:snapshot", - "db:hasData", - "db:workspace:list", - "db:project:list", - "db:note:list", - "db:column:list", - "db:card:list", - "db:card:ready", - "db:flow:get", - "db:tag:list", - "db:chat:threads", - "db:chat:messages", - "db:piSession:list", - "db:piSession:messages", - "db:graph:get", - "db:graph:neighbors", - "db:embeddings:search", - "db:mcpQuery" - ]; +// Read channels whose names don't end in a recognised read verb. +const READ_CHANNELS = new Set([ + "db:snapshot", + "db:hasData", + "db:mcpQuery", +]); - return !readChannels.includes(channel); +function isWriteChannel(channel: string): boolean { + if (!channel.startsWith("db:")) return false; + if (READ_CHANNELS.has(channel)) return false; + const action = channel.slice(channel.lastIndexOf(":") + 1); + if (READ_ACTIONS.has(action)) return false; + return true; } +/** Exported for unit testing the read/write classification. */ +export const __isWriteChannel = isWriteChannel; + /** * Register a handler that maps to ipcMain.handle. */ diff --git a/electron/splash/bootsplash.ts b/electron/splash/bootsplash.ts index 748836a9c..0a8f6c085 100644 --- a/electron/splash/bootsplash.ts +++ b/electron/splash/bootsplash.ts @@ -79,8 +79,14 @@ function getAppVersion(): string { } } -function buildSplashHtml(iconDataUrl: string | null): string { +function buildSplashHtml(iconDataUrl: string | null, colors: Record): string { const version = getAppVersion(); + // Bake the resolved theme palette into the initial CSS so the splash paints + // with the correct (light or dark) colors on first frame — no dark flash + // before the post-load `splash:theme` IPC override arrives. + const initialVars = Object.keys(colors) + .map((key) => ` --splash-${key}: ${colors[key]};`) + .join("\n"); const logoHtml = iconDataUrl ? `Cairn` : `
C
`; @@ -93,6 +99,9 @@ function buildSplashHtml(iconDataUrl: string | null): string { Cairn