Skip to content

Commit 5829d6c

Browse files
tomymaritanoclaude
andauthored
release: bring audit stack (19 PRs) into develop (#285)
## Why this PR The audit stack (#266 through #284) was structured as 19 stacked PRs, each one's base pointing at its predecessor in the chain. Each merged into **its own parent branch**, not into \`develop\`. Net result: \`develop\` only contains #265, and the other 19 PRs are stranded on their branch tips. This PR ships the head of the stack (\`chore/set-subscription-public-key\`) into \`develop\` so the work actually lands. It's a single PR with **21 commits** — the linear chain of the stack — preserving each individual PR's conventional commit message so semantic-release can categorise them for the next release. ## What's in this PR (in merge order) | # | Commit | Title | |---|---|---| | 1 | \`3412e4e\` | fix(typecheck): unblock pnpm -r typecheck after TS 6.x bump (already merged as squash in develop — no-op overlap) | | 2 | \`03da9cb\` | fix(editor): stop runtime crashes in MarkdownEditor | | 3 | \`07a76d6\` | chore(tooling): replace husky with lefthook, add knip, tighten lint-staged | | 4 | \`e27f1ba\` | refactor(stores): use named selectors instead of destructuring full state | | 5 | \`c519fc3\` | chore(test): add coverage baseline + smoke tests for @readied/commands | | 6 | \`5b1cc55\` | chore(mcp-server): migrate to registerTool API + FTS5 for read_note | | 7 | \`0eb49a5\` | fix(backup): integrity-check restored db and roll back on failure | | 8 | \`402e280\` | refactor(ipc): add typed IPC registry, migrate aiKeyHandlers as proof | | 9 | \`dd4823d\` | refactor(ipc): migrate light handlers to defineIpcHandler | | 10 | \`5607100\` | refactor(ipc): migrate heavy data handlers to defineIpcHandler | | 11 | \`c51c4d3\` | fix(aiKeyStorage): stop deleting encrypted keys on transient decrypt errors | | 12 | \`d5f33ef\` | feat(licensing): add Ed25519 subscription envelope sign + verify | | 13 | \`5ba96d4\` | feat(e2e): scaffold Playwright Electron suite + CI job | | 14 | \`2bd1396\` | refactor(main): extract FileLicenseStorage and window state to services | | 15 | \`fd9b809\` | chore(knip): delete verified-unused files (phase 1) | | 16 | \`a3d7c1b\` | chore(knip): remove unused dependencies (phase 2) | | 17 | \`0ec48b3\` | feat(license): wire Ed25519 signed-envelope verification at the storage layer | | 18 | \`cca7e04\` | refactor(storage-sqlite): extract noteMapping helpers from SQLiteNoteRepository | | 19 | \`1a0df95\` | refactor(editor): extract theme + highlight from MarkdownEditor | | 20 | \`9034c71\` | chore(license): set real SUBSCRIPTION_PUBLIC_KEY | | 21 | \`390503c\` | docs(api): document LICENSE_SIGNING_PRIVATE_KEY secret requirement | Each is already individually reviewed and merged on GitHub (#266#284). They appear here as their original commits because the stack used rebase-based stacking, not merge commits. ## Pre-merge verification (local, this branch) - ✅ \`pnpm -r typecheck\` — green across 18 workspace projects - ✅ \`pnpm test\` — 17/17 packages - ✅ \`pnpm build\` — 6/6 packages ## After this PR merges Per the release flow in \`CLAUDE.md\`: 1. Open \`develop → main\` PR 2. Click \"Run workflow\" on Release action — \`semantic-release\` analyses these conventional commits and bumps the version 3. Tag push triggers Build workflow — mac/win/linux in parallel 4. All builds green → release un-drafts → electron-updater serves the update ## Notable behaviour changes for users - **Editor**: no more blank-window crash on notes with tables (#266) — root cause was \`Decoration.replace\` over multi-line ranges from a ViewPlugin instead of a StateField - **AI keys**: no more silent deletion of keys when keychain is temporarily locked after sleep/wake (#275) - **Backups**: corrupt restored DB is now refused and rolled back to a safety copy (#271) - **Subscriptions**: client now verifies Ed25519 server signatures (#281, #284); server-side signing rollout still needed for full effect - **Tooling**: lefthook replaces husky, knip available for dead-code audits (#267) - **Infrastructure**: full IPC surface now validated with Zod at the boundary (#272 + #273 + #274), Playwright scaffold in place (#277) ## Notable for reviewers - The placeholder \`SUBSCRIPTION_PUBLIC_KEY\` in #281 was replaced with a real key in #284. The matching private key is set in Cloudflare as \`LICENSE_SIGNING_PRIVATE_KEY\` (prod + staging). This keypair was generated in a Claude session and is dev/staging-grade — rotate before serving real paid customers. - 50% of audit findings were Knip false positives or already-fixed (documented per PR). Stack reflects real debt, not the audit verbatim. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added end-to-end testing for the desktop application with comprehensive smoke and notes testing. * Implemented IPC handler validation using Zod schemas for improved type safety. * Added subscription envelope signing and verification using Ed25519 cryptography. * **Bug Fixes** * Improved encryption error handling and recovery logic. * **Tests** * Established centralized test coverage configuration across all packages. * Expanded test suites for markdown commands and licensing functionality. * **Chores** * Transitioned from Husky to Lefthook for Git hooks management. * Refactored internal IPC architecture and service modules. * Updated build and deployment configurations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fb987c1 commit 5829d6c

79 files changed

Lines changed: 4515 additions & 3099 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,50 @@ jobs:
143143
working-directory: apps/desktop
144144
run: pnpm typecheck
145145

146+
# ── Tier 2: E2E (Playwright + Electron, xvfb on Linux) ─────────────────
147+
# Starts as continue-on-error: true while we stabilize the suite.
148+
# Flip to required once it's reliably green on develop.
149+
e2e:
150+
needs: setup
151+
runs-on: ubuntu-latest
152+
continue-on-error: true
153+
steps:
154+
- uses: actions/checkout@v4
155+
- uses: pnpm/action-setup@v5
156+
- uses: actions/setup-node@v4
157+
with:
158+
node-version: ${{ env.NODE_VERSION }}
159+
160+
- name: Restore node_modules
161+
uses: actions/cache/restore@v4
162+
with:
163+
path: |
164+
node_modules
165+
apps/*/node_modules
166+
packages/*/node_modules
167+
key: modules-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
168+
169+
- name: Install Playwright system deps
170+
working-directory: apps/desktop
171+
run: npx playwright install-deps chromium
172+
173+
- name: Build desktop bundle
174+
run: pnpm --filter @readied/desktop build
175+
176+
- name: Run Playwright E2E (xvfb)
177+
working-directory: apps/desktop
178+
run: xvfb-run --auto-servernum pnpm e2e
179+
env:
180+
CI: 'true'
181+
182+
- name: Upload Playwright report on failure
183+
if: failure()
184+
uses: actions/upload-artifact@v4
185+
with:
186+
name: playwright-report
187+
path: apps/desktop/playwright-report/
188+
retention-days: 7
189+
146190
# ── Tier 3: Security audit ─────────────────────────
147191
security:
148192
needs: setup

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ npm-debug.log*
3333
# Cache
3434
.eslintcache
3535

36+
# Vitest coverage reports
37+
coverage/
38+
39+
# Playwright artifacts
40+
test-results/
41+
playwright-report/
42+
playwright/.cache/
43+
3644
# Screenshots (root level only)
3745
/CleanShot*.png
3846

.husky/commit-msg

Lines changed: 0 additions & 1 deletion
This file was deleted.

.husky/pre-commit

Lines changed: 0 additions & 1 deletion
This file was deleted.

.husky/pre-push

Lines changed: 0 additions & 1 deletion
This file was deleted.

apps/desktop/e2e/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# E2E tests (Playwright + Electron)
2+
3+
End-to-end tests for the desktop app, driven through Playwright's `_electron` API. Tests launch the **built** Electron bundle in `out/`, so you must run `pnpm build` (or `pnpm dev` for headed iteration) before they pass.
4+
5+
## Running locally
6+
7+
```bash
8+
# From repo root
9+
pnpm --filter @readied/desktop build # produces out/main/index.js
10+
pnpm --filter @readied/desktop e2e # headless
11+
pnpm --filter @readied/desktop e2e:headed # opens the window
12+
```
13+
14+
First run also downloads Playwright's browser binaries:
15+
16+
```bash
17+
npx playwright install --with-deps
18+
```
19+
20+
(`--with-deps` only matters on Linux, where it installs system libs.)
21+
22+
## Isolation
23+
24+
`launchApp()` in `fixtures.ts` creates a fresh temp `userData` dir per test, so:
25+
26+
- The SQLite DB starts empty every time.
27+
- Settings, license cache, AI keys, etc. don't leak between tests.
28+
- The host's real Readied data is never touched.
29+
30+
Set `READIED_E2E_KEEP_USERDATA=1` to keep the temp dir on failure for post-mortem inspection.
31+
32+
## What we test
33+
34+
| Spec | What it covers |
35+
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
36+
| `smoke.spec.ts` | App launches, main window renders, IPC bridge present, no uncaught console errors during initial mount. This is the regression catch for #266 (editor mount crashes producing blank windows). |
37+
| `notes.spec.ts` | Notes IPC contract — create / list / get roundtrip, FTS5 search returns freshly-created notes. We deliberately drive the **preload bridge** (`window.readied.notes.*`) rather than the editor UI; selectors churn but the contract is stable. |
38+
39+
## What we deliberately don't test (yet)
40+
41+
- **Editor UI interactions** (typing, formatting, hotkeys). The CodeMirror surface is too prone to flake without per-spec selectors. Worth doing once the editor is split (see PR-G in the audit).
42+
- **AI panel streaming.** Needs a mock provider and is more useful as a vitest test against `@readied/ai-core`.
43+
- **Sync flows.** Need a fake server.
44+
45+
These will be follow-ups once the basics are stable in CI.
46+
47+
## CI
48+
49+
The `e2e` job in `.github/workflows/ci.yml` runs on Linux + xvfb. It starts as `continue-on-error: true` — the goal of this PR is to land the infrastructure, not to gate every PR on E2E green. Once the suite is verified end-to-end on a real CI run, flip the flag off in a follow-up.

apps/desktop/e2e/fixtures.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Shared E2E fixtures for Electron app tests.
3+
*
4+
* `launchApp()` launches a fresh Electron instance with an isolated
5+
* userData directory so tests don't interfere with each other or with
6+
* a developer's local Readied install. Each test should call this in
7+
* its own `beforeEach`.
8+
*/
9+
10+
import { mkdtemp, rm } from 'fs/promises';
11+
import { tmpdir } from 'os';
12+
import { join } from 'path';
13+
import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
14+
15+
interface LaunchedApp {
16+
app: ElectronApplication;
17+
window: Page;
18+
userDataDir: string;
19+
/** Call in afterEach. */
20+
cleanup: () => Promise<void>;
21+
}
22+
23+
/**
24+
* Launches the desktop app and waits for the first window to be ready.
25+
*
26+
* Uses a fresh temp `userData` so the test gets an empty database every
27+
* time. Set READIED_E2E_KEEP_USERDATA=1 to keep the dir on failure for
28+
* post-mortem.
29+
*/
30+
export async function launchApp(): Promise<LaunchedApp> {
31+
const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));
32+
33+
const app = await electron.launch({
34+
args: [
35+
'.',
36+
`--user-data-dir=${userDataDir}`,
37+
// Disable updates / external network checks during tests.
38+
'--disable-features=AutoUpdate',
39+
],
40+
env: {
41+
...process.env,
42+
NODE_ENV: 'test',
43+
READIED_E2E: '1',
44+
// Pin the data root explicitly so the app uses our temp dir for
45+
// its SQLite database too, not just for Electron's userData.
46+
READIED_DATA_DIR: userDataDir,
47+
},
48+
});
49+
50+
const window = await app.firstWindow();
51+
// Wait for the renderer to finish initial paint.
52+
await window.waitForLoadState('domcontentloaded');
53+
54+
return {
55+
app,
56+
window,
57+
userDataDir,
58+
cleanup: async () => {
59+
await app.close().catch(() => {});
60+
if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
61+
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
62+
}
63+
},
64+
};
65+
}

apps/desktop/e2e/notes.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { test, expect } from '@playwright/test';
2+
import { launchApp } from './fixtures.js';
3+
4+
/**
5+
* Notes CRUD end-to-end.
6+
*
7+
* We exercise the IPC contract directly through the preload bridge
8+
* (`window.readied.notes`) rather than driving the editor UI. This is
9+
* intentional:
10+
* - The UI elements (selectors, labels, hotkeys) churn often. Asserting
11+
* against the IPC surface gives us regression coverage on the
12+
* *contract* that survives renderer refactors.
13+
* - Anything that breaks here also breaks the desktop's renderer code,
14+
* because the renderer uses the same bridge.
15+
*/
16+
test.describe('notes IPC contract', () => {
17+
test('create → list → read roundtrip', async () => {
18+
const { window, cleanup } = await launchApp();
19+
try {
20+
const noteId = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
21+
const content = '# E2E note\n\nbody from playwright';
22+
23+
const createResult = await window.evaluate(
24+
async ([id, body]) => {
25+
const api = (
26+
window as unknown as {
27+
readied: {
28+
notes: {
29+
create: (input: {
30+
id?: string;
31+
content: string;
32+
notebookId?: string;
33+
}) => Promise<unknown>;
34+
list: (
35+
opts?: Record<string, unknown>
36+
) => Promise<Array<{ id: string; title: string; content: string }>>;
37+
get: (id: string) => Promise<unknown>;
38+
};
39+
};
40+
}
41+
).readied;
42+
const created = await api.notes.create({ id, content: body });
43+
return { created };
44+
},
45+
[noteId, content] as const
46+
);
47+
48+
expect(createResult.created).toBeTruthy();
49+
50+
const list = await window.evaluate(
51+
async () =>
52+
(
53+
window as unknown as {
54+
readied: {
55+
notes: {
56+
list: () => Promise<Array<{ id: string; title: string; content: string }>>;
57+
};
58+
};
59+
}
60+
).readied.notes.list(),
61+
undefined
62+
);
63+
64+
const ourNote = list.find(n => n.id === noteId);
65+
expect(ourNote, `note ${noteId} missing from list`).toBeDefined();
66+
expect(ourNote!.content).toContain('body from playwright');
67+
} finally {
68+
await cleanup();
69+
}
70+
});
71+
72+
test('search returns the freshly-created note via FTS5', async () => {
73+
const { window, cleanup } = await launchApp();
74+
try {
75+
const marker = `marker_${Date.now()}_unique`;
76+
await window.evaluate(
77+
async ([body]) => {
78+
const api = (
79+
window as unknown as {
80+
readied: {
81+
notes: { create: (input: { content: string }) => Promise<unknown> };
82+
};
83+
}
84+
).readied;
85+
await api.notes.create({ content: `# Searchable\n\n${body}` });
86+
},
87+
[marker] as const
88+
);
89+
90+
const results = await window.evaluate(
91+
async ([q]) =>
92+
(
93+
window as unknown as {
94+
readied: {
95+
notes: {
96+
search: (
97+
query: string,
98+
limit?: number
99+
) => Promise<Array<{ id: string; content: string }>>;
100+
};
101+
};
102+
}
103+
).readied.notes.search(q, 10),
104+
[marker] as const
105+
);
106+
107+
expect(results.length).toBeGreaterThan(0);
108+
expect(results.some(r => r.content.includes(marker))).toBe(true);
109+
} finally {
110+
await cleanup();
111+
}
112+
});
113+
});

apps/desktop/e2e/smoke.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test, expect } from '@playwright/test';
2+
import { launchApp } from './fixtures.js';
3+
4+
test.describe('app launch (smoke)', () => {
5+
test('launches and shows the main window', async () => {
6+
const { app, window, cleanup } = await launchApp();
7+
try {
8+
// Title is "Readied" in production. Allow any non-empty title in case
9+
// dev/test envs use a different one.
10+
const title = await window.title();
11+
expect(title.length).toBeGreaterThan(0);
12+
13+
// First window must render *something* — a <body> element with non-zero
14+
// size is a low bar that catches the regression class from PR #266
15+
// (editor mount crashes that produced a blank window).
16+
const bodyBox = await window.locator('body').boundingBox();
17+
expect(bodyBox).not.toBeNull();
18+
expect(bodyBox!.width).toBeGreaterThan(0);
19+
expect(bodyBox!.height).toBeGreaterThan(0);
20+
21+
// Sanity: the app exposed its IPC bridge.
22+
const hasBridge = await window.evaluate(
23+
() => typeof (window as unknown as { readied?: unknown }).readied !== 'undefined'
24+
);
25+
expect(hasBridge).toBe(true);
26+
27+
expect(app.windows().length).toBeGreaterThanOrEqual(1);
28+
} finally {
29+
await cleanup();
30+
}
31+
});
32+
33+
test('console does not log uncaught errors during initial render', async () => {
34+
const { window, cleanup } = await launchApp();
35+
const consoleErrors: string[] = [];
36+
window.on('console', msg => {
37+
if (msg.type() === 'error') consoleErrors.push(msg.text());
38+
});
39+
window.on('pageerror', err => consoleErrors.push(`pageerror: ${err.message}`));
40+
41+
try {
42+
// Give the renderer 3s to throw any early errors during mount.
43+
await window.waitForTimeout(3000);
44+
45+
// Known non-fatal noise that the app emits in test/dev environments.
46+
// Strip these out before asserting "no errors".
47+
const ignored = [
48+
/\[Sentry\]/, // "No DSN configured" — expected without VITE_SENTRY_DSN
49+
/Failed to load resource: net::ERR_/, // network during dev sometimes
50+
];
51+
const real = consoleErrors.filter(line => !ignored.some(re => re.test(line)));
52+
53+
expect(real, real.join('\n')).toEqual([]);
54+
} finally {
55+
await cleanup();
56+
}
57+
});
58+
});

apps/desktop/e2e/tsconfig.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "../../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"rootDir": ".",
7+
"noEmit": true
8+
},
9+
"include": ["**/*.ts"]
10+
}

0 commit comments

Comments
 (0)