Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3412e4e
fix(typecheck): unblock pnpm -r typecheck after TS 6.x bump
tomymaritano Jun 8, 2026
03da9cb
fix(editor): stop runtime crashes in MarkdownEditor
tomymaritano Jun 8, 2026
07a76d6
chore(tooling): replace husky with lefthook, add knip, tighten lint-s…
tomymaritano Jun 8, 2026
e27f1ba
refactor(stores): use named selectors instead of destructuring full s…
tomymaritano Jun 8, 2026
c519fc3
chore(test): add coverage baseline + smoke tests for @readied/commands
tomymaritano Jun 8, 2026
5b1cc55
chore(mcp-server): migrate to registerTool API + FTS5 for read_note
tomymaritano Jun 8, 2026
0eb49a5
fix(backup): integrity-check restored db and roll back on failure
tomymaritano Jun 8, 2026
402e280
refactor(ipc): add typed IPC registry, migrate aiKeyHandlers as proof
tomymaritano Jun 8, 2026
dd4823d
refactor(ipc): migrate light handlers to defineIpcHandler
tomymaritano Jun 8, 2026
5607100
refactor(ipc): migrate heavy data handlers to defineIpcHandler
tomymaritano Jun 8, 2026
c51c4d3
fix(aiKeyStorage): stop deleting encrypted keys on transient decrypt …
tomymaritano Jun 8, 2026
d5f33ef
feat(licensing): add Ed25519 subscription envelope sign + verify
tomymaritano Jun 8, 2026
5ba96d4
feat(e2e): scaffold Playwright Electron suite + CI job
tomymaritano Jun 8, 2026
2bd1396
refactor(main): extract FileLicenseStorage and window state to services
tomymaritano Jun 8, 2026
fd9b809
chore(knip): delete verified-unused files (phase 1)
tomymaritano Jun 9, 2026
a3d7c1b
chore(knip): remove unused dependencies (phase 2)
tomymaritano Jun 9, 2026
0ec48b3
feat(license): wire Ed25519 signed-envelope verification at the stora…
tomymaritano Jun 9, 2026
cca7e04
refactor(storage-sqlite): extract noteMapping helpers from SQLiteNote…
tomymaritano Jun 9, 2026
1a0df95
refactor(editor): extract theme + highlight from MarkdownEditor
tomymaritano Jun 9, 2026
9034c71
chore(license): set real SUBSCRIPTION_PUBLIC_KEY
tomymaritano Jun 9, 2026
390503c
docs(api): document LICENSE_SIGNING_PRIVATE_KEY secret requirement
tomymaritano Jun 9, 2026
b668828
Merge remote-tracking branch 'origin/develop' into chore/set-subscrip…
tomymaritano Jun 9, 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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,50 @@ jobs:
working-directory: apps/desktop
run: pnpm typecheck

# ── Tier 2: E2E (Playwright + Electron, xvfb on Linux) ─────────────────
# Starts as continue-on-error: true while we stabilize the suite.
# Flip to required once it's reliably green on develop.
e2e:
needs: setup
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

- name: Restore node_modules
uses: actions/cache/restore@v4
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
key: modules-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

- name: Install Playwright system deps
working-directory: apps/desktop
run: npx playwright install-deps chromium

- name: Build desktop bundle
run: pnpm --filter @readied/desktop build

- name: Run Playwright E2E (xvfb)
working-directory: apps/desktop
run: xvfb-run --auto-servernum pnpm e2e
env:
CI: 'true'

- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/desktop/playwright-report/
retention-days: 7

# ── Tier 3: Security audit ─────────────────────────
security:
needs: setup
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ npm-debug.log*
# Cache
.eslintcache

# Vitest coverage reports
coverage/

# Playwright artifacts
test-results/
playwright-report/
playwright/.cache/

# Screenshots (root level only)
/CleanShot*.png

Expand Down
1 change: 0 additions & 1 deletion .husky/commit-msg

This file was deleted.

1 change: 0 additions & 1 deletion .husky/pre-commit

This file was deleted.

1 change: 0 additions & 1 deletion .husky/pre-push

This file was deleted.

49 changes: 49 additions & 0 deletions apps/desktop/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# E2E tests (Playwright + Electron)

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.

## Running locally

```bash
# From repo root
pnpm --filter @readied/desktop build # produces out/main/index.js
pnpm --filter @readied/desktop e2e # headless
pnpm --filter @readied/desktop e2e:headed # opens the window
```

First run also downloads Playwright's browser binaries:

```bash
npx playwright install --with-deps
```

(`--with-deps` only matters on Linux, where it installs system libs.)

## Isolation

`launchApp()` in `fixtures.ts` creates a fresh temp `userData` dir per test, so:

- The SQLite DB starts empty every time.
- Settings, license cache, AI keys, etc. don't leak between tests.
- The host's real Readied data is never touched.

Set `READIED_E2E_KEEP_USERDATA=1` to keep the temp dir on failure for post-mortem inspection.

## What we test

| Spec | What it covers |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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). |
| `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. |

## What we deliberately don't test (yet)

- **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).
- **AI panel streaming.** Needs a mock provider and is more useful as a vitest test against `@readied/ai-core`.
- **Sync flows.** Need a fake server.

These will be follow-ups once the basics are stable in CI.

## CI

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.
65 changes: 65 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Shared E2E fixtures for Electron app tests.
*
* `launchApp()` launches a fresh Electron instance with an isolated
* userData directory so tests don't interfere with each other or with
* a developer's local Readied install. Each test should call this in
* its own `beforeEach`.
*/

import { mkdtemp, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { _electron as electron, type ElectronApplication, type Page } from '@playwright/test';

interface LaunchedApp {
app: ElectronApplication;
window: Page;
userDataDir: string;
/** Call in afterEach. */
cleanup: () => Promise<void>;
}

/**
* Launches the desktop app and waits for the first window to be ready.
*
* Uses a fresh temp `userData` so the test gets an empty database every
* time. Set READIED_E2E_KEEP_USERDATA=1 to keep the dir on failure for
* post-mortem.
*/
export async function launchApp(): Promise<LaunchedApp> {
const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));

const app = await electron.launch({
args: [
'.',
`--user-data-dir=${userDataDir}`,
// Disable updates / external network checks during tests.
'--disable-features=AutoUpdate',
],
env: {
...process.env,
NODE_ENV: 'test',
READIED_E2E: '1',
// Pin the data root explicitly so the app uses our temp dir for
// its SQLite database too, not just for Electron's userData.
READIED_DATA_DIR: userDataDir,
},
});

const window = await app.firstWindow();
// Wait for the renderer to finish initial paint.
await window.waitForLoadState('domcontentloaded');

return {
app,
window,
userDataDir,
cleanup: async () => {
await app.close().catch(() => {});
if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
}
},
};
}
Comment on lines +30 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Resource leak if launch fails after temp directory creation.

If electron.launch() or app.firstWindow() throws, the temp directory created on line 31 is never cleaned up, since the cleanup function isn't returned. While CI runners clean up automatically, local development can accumulate orphaned temp directories if launch failures persist.

🛡️ Proposed fix to ensure cleanup on launch failure
 export async function launchApp(): Promise<LaunchedApp> {
   const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));
+  
+  try {
+    const app = await electron.launch({
+      args: [
+        '.',
+        `--user-data-dir=${userDataDir}`,
+        '--disable-features=AutoUpdate',
+      ],
+      env: {
+        ...process.env,
+        NODE_ENV: 'test',
+        READIED_E2E: '1',
+        READIED_DATA_DIR: userDataDir,
+      },
+    });
 
-  const app = await electron.launch({
-    args: [
-      '.',
-      `--user-data-dir=${userDataDir}`,
-      // Disable updates / external network checks during tests.
-      '--disable-features=AutoUpdate',
-    ],
-    env: {
-      ...process.env,
-      NODE_ENV: 'test',
-      READIED_E2E: '1',
-      // Pin the data root explicitly so the app uses our temp dir for
-      // its SQLite database too, not just for Electron's userData.
-      READIED_DATA_DIR: userDataDir,
-    },
-  });
-
-  const window = await app.firstWindow();
-  // Wait for the renderer to finish initial paint.
-  await window.waitForLoadState('domcontentloaded');
-
-  return {
-    app,
-    window,
-    userDataDir,
-    cleanup: async () => {
-      await app.close().catch(() => {});
-      if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
-        await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
-      }
-    },
-  };
+    const window = await app.firstWindow();
+    await window.waitForLoadState('domcontentloaded');
+
+    return {
+      app,
+      window,
+      userDataDir,
+      cleanup: async () => {
+        await app.close().catch(() => {});
+        if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
+          await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
+        }
+      },
+    };
+  } catch (err) {
+    await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
+    throw err;
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function launchApp(): Promise<LaunchedApp> {
const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));
const app = await electron.launch({
args: [
'.',
`--user-data-dir=${userDataDir}`,
// Disable updates / external network checks during tests.
'--disable-features=AutoUpdate',
],
env: {
...process.env,
NODE_ENV: 'test',
READIED_E2E: '1',
// Pin the data root explicitly so the app uses our temp dir for
// its SQLite database too, not just for Electron's userData.
READIED_DATA_DIR: userDataDir,
},
});
const window = await app.firstWindow();
// Wait for the renderer to finish initial paint.
await window.waitForLoadState('domcontentloaded');
return {
app,
window,
userDataDir,
cleanup: async () => {
await app.close().catch(() => {});
if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
}
},
};
}
export async function launchApp(): Promise<LaunchedApp> {
const userDataDir = await mkdtemp(join(tmpdir(), 'readied-e2e-'));
try {
const app = await electron.launch({
args: [
'.',
`--user-data-dir=${userDataDir}`,
// Disable updates / external network checks during tests.
'--disable-features=AutoUpdate',
],
env: {
...process.env,
NODE_ENV: 'test',
READIED_E2E: '1',
// Pin the data root explicitly so the app uses our temp dir for
// its SQLite database too, not just for Electron's userData.
READIED_DATA_DIR: userDataDir,
},
});
const window = await app.firstWindow();
// Wait for the renderer to finish initial paint.
await window.waitForLoadState('domcontentloaded');
return {
app,
window,
userDataDir,
cleanup: async () => {
await app.close().catch(() => {});
if (process.env.READIED_E2E_KEEP_USERDATA !== '1') {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
}
},
};
} catch (err) {
await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/e2e/fixtures.ts` around lines 30 - 65, launchApp currently
creates userDataDir via mkdtemp but if electron.launch() or app.firstWindow()
throws the temp dir is orphaned; wrap the launch/firstWindow sequence in a
try/catch and on any thrown error remove the created userDataDir (using
rm(userDataDir, { recursive: true, force: true })) unless
process.env.READIED_E2E_KEEP_USERDATA === '1', then rethrow the error; reference
the launchApp function, mkdtemp call that sets userDataDir, the electron.launch
and app.firstWindow calls, and preserve the existing cleanup closure for
successful returns.

113 changes: 113 additions & 0 deletions apps/desktop/e2e/notes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { test, expect } from '@playwright/test';
import { launchApp } from './fixtures.js';

/**
* Notes CRUD end-to-end.
*
* We exercise the IPC contract directly through the preload bridge
* (`window.readied.notes`) rather than driving the editor UI. This is
* intentional:
* - The UI elements (selectors, labels, hotkeys) churn often. Asserting
* against the IPC surface gives us regression coverage on the
* *contract* that survives renderer refactors.
* - Anything that breaks here also breaks the desktop's renderer code,
* because the renderer uses the same bridge.
*/
test.describe('notes IPC contract', () => {
test('create → list → read roundtrip', async () => {
const { window, cleanup } = await launchApp();
try {
const noteId = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const content = '# E2E note\n\nbody from playwright';

const createResult = await window.evaluate(
async ([id, body]) => {
const api = (
window as unknown as {
readied: {
notes: {
create: (input: {
id?: string;
content: string;
notebookId?: string;
}) => Promise<unknown>;
list: (
opts?: Record<string, unknown>
) => Promise<Array<{ id: string; title: string; content: string }>>;
get: (id: string) => Promise<unknown>;
};
};
}
).readied;
const created = await api.notes.create({ id, content: body });
return { created };
},
[noteId, content] as const
);

expect(createResult.created).toBeTruthy();

const list = await window.evaluate(
async () =>
(
window as unknown as {
readied: {
notes: {
list: () => Promise<Array<{ id: string; title: string; content: string }>>;
};
};
}
).readied.notes.list(),
undefined
);

const ourNote = list.find(n => n.id === noteId);
expect(ourNote, `note ${noteId} missing from list`).toBeDefined();
expect(ourNote!.content).toContain('body from playwright');
} finally {
await cleanup();
}
});

test('search returns the freshly-created note via FTS5', async () => {
const { window, cleanup } = await launchApp();
try {
const marker = `marker_${Date.now()}_unique`;
await window.evaluate(
async ([body]) => {
const api = (
window as unknown as {
readied: {
notes: { create: (input: { content: string }) => Promise<unknown> };
};
}
).readied;
await api.notes.create({ content: `# Searchable\n\n${body}` });
},
[marker] as const
);

const results = await window.evaluate(
async ([q]) =>
(
window as unknown as {
readied: {
notes: {
search: (
query: string,
limit?: number
) => Promise<Array<{ id: string; content: string }>>;
};
};
}
).readied.notes.search(q, 10),
[marker] as const
);

expect(results.length).toBeGreaterThan(0);
expect(results.some(r => r.content.includes(marker))).toBe(true);
} finally {
await cleanup();
}
});
});
58 changes: 58 additions & 0 deletions apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test';
import { launchApp } from './fixtures.js';

test.describe('app launch (smoke)', () => {
test('launches and shows the main window', async () => {
const { app, window, cleanup } = await launchApp();
try {
// Title is "Readied" in production. Allow any non-empty title in case
// dev/test envs use a different one.
const title = await window.title();
expect(title.length).toBeGreaterThan(0);

// First window must render *something* — a <body> element with non-zero
// size is a low bar that catches the regression class from PR #266
// (editor mount crashes that produced a blank window).
const bodyBox = await window.locator('body').boundingBox();
expect(bodyBox).not.toBeNull();
expect(bodyBox!.width).toBeGreaterThan(0);
expect(bodyBox!.height).toBeGreaterThan(0);

// Sanity: the app exposed its IPC bridge.
const hasBridge = await window.evaluate(
() => typeof (window as unknown as { readied?: unknown }).readied !== 'undefined'
);
expect(hasBridge).toBe(true);

expect(app.windows().length).toBeGreaterThanOrEqual(1);
} finally {
await cleanup();
}
});

test('console does not log uncaught errors during initial render', async () => {
const { window, cleanup } = await launchApp();
const consoleErrors: string[] = [];
window.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
window.on('pageerror', err => consoleErrors.push(`pageerror: ${err.message}`));

try {
// Give the renderer 3s to throw any early errors during mount.
await window.waitForTimeout(3000);

// Known non-fatal noise that the app emits in test/dev environments.
// Strip these out before asserting "no errors".
const ignored = [
/\[Sentry\]/, // "No DSN configured" — expected without VITE_SENTRY_DSN
/Failed to load resource: net::ERR_/, // network during dev sometimes
];
const real = consoleErrors.filter(line => !ignored.some(re => re.test(line)));
Comment on lines +47 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Error filter patterns are too broad and may hide real failures.

The regex patterns /\[Sentry\]/ and /Failed to load resource: net::ERR_/ will match any console message containing those substrings, not just the specific known noise mentioned in the comment. For example, a real Sentry error like [Sentry] Fatal: API key rejected would be silently filtered, undermining the smoke test's ability to catch genuine errors.

🔍 Proposed fix with more specific patterns
       const ignored = [
-        /\[Sentry\]/, // "No DSN configured" — expected without VITE_SENTRY_DSN
-        /Failed to load resource: net::ERR_/, // network during dev sometimes
+        /\[Sentry\].*No DSN configured/, // Expected without VITE_SENTRY_DSN
+        /Failed to load resource:.*net::ERR_FILE_NOT_FOUND/, // Dev HMR artifacts
       ];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/e2e/smoke.spec.ts` around lines 47 - 51, The current broad
filters in the ignored array (used by consoleErrors -> real) hide any message
containing "[Sentry]" or "Failed to load resource: net::ERR_", which can silence
real errors; narrow these to exact known noise by replacing those regexes with
anchored patterns or exact string matches that match the precise noise you
expect (e.g., an anchored regex for "^[\\[Sentry\\]]\\s*No DSN configured\\b" or
a full exact string for "Failed to load resource: net::ERR_NAME_NOT_RESOLVED" or
similar), update the ignored array and keep the filter using consoleErrors and
real unchanged so only the specific expected benign messages are filtered out.


expect(real, real.join('\n')).toEqual([]);
} finally {
await cleanup();
}
});
});
10 changes: 10 additions & 0 deletions apps/desktop/e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"rootDir": ".",
"noEmit": true
},
"include": ["**/*.ts"]
}
Loading
Loading