Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: CI

on:
push:
branches: [main, master]
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ out/
release/
dist/
*.log
*.tsbuildinfo

# Playwright e2e output
test-results/
playwright-report/
.playwright/

# Secrets — never commit
.env
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 tpikachu

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
39 changes: 39 additions & 0 deletions changelog/1.0.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 1.0.0 — 2026-06-25

The first stable release. This one is about **reliability and polish**: live failures
now surface instead of hanging, dead corners of the UI are gone, and a round of
accessibility and cross-platform fixes round out a credible 1.0.

## Fixed

- **Live answers no longer hang silently on failure.** If the connection drops, your
key expires, or you hit a quota mid-interview, the Cue Card now shows a clear error
and stops the "thinking…" spinner — instead of a card stuck loading forever. This
covers both retrieval (embeddings) and answer generation.
- **A dropped transcription connection is now reported.** If the speech-to-text socket
closes unexpectedly, BrainCue tells you to restart the interview — rather than going
silently deaf while the mic keeps running.
- **Cancelling the screen picker / denying the mic** no longer leaves a phantom "live"
session (carried over from 0.9), and coding-solve errors now show a clean message.

## Changed

- **Cleaner Cue Card.** Removed the empty "expanded mode" panel (talking points /
resume match) that never populated; the streamed answer, risk warnings, transcript,
and audio meter remain.
- **Higher-fidelity screen capture** for coding problems on HiDPI displays — small code
text now stays legible for the solver.
- **Friendlier errors** in Mock interview (inline messages instead of blocking pop-ups).
- Long interviews keep memory bounded (the transcript no longer grows without limit).

## Accessibility

- Cue Card icon buttons are now labeled for screen readers.
- Dialogs move focus into themselves and restore it on close, and the manual **Ask**
box no longer submits mid-composition (CJK/IME input).

## Under the hood

- Added a unit + end-to-end test suite (Vitest + Playwright/Electron) and a CI gate
(typecheck · tests · build) on every change.
- Removed an unimplemented internal `rag:search` channel and tidied the docs.
7 changes: 1 addition & 6 deletions docs/05-IPC-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ validates input with zod via the `handle()` helper. Errors are returned as

## Channel naming
`<domain>:<action>` — domains: `app`, `dialog`, `settings`, `profiles`,
`documents`, `jobs`, `notes`, `rag`, `session`, `mock`, `capture`, `overlay`,
`documents`, `jobs`, `notes`, `session`, `mock`, `capture`, `overlay`,
`privacy`, `data`, `window`.

## invoke / handle (request → response)
Expand Down Expand Up @@ -90,11 +90,6 @@ independently.
| `notes:create` | `{ profileId, content }` | `Note` |
| `notes:delete` | `{ id }` | `{ deleted: true }` |

### rag (mostly internal; exposed for debugging)
| Channel | Request | Response |
|---|---|---|
| `rag:search` | `{ profileId, query, k }` | `RetrievedChunk[]` |

### session
| Channel | Request | Response |
|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion docs/09-MVP-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ this repo delivers M0 and the scaffolding for M1–M2.
- Document import (native file picker + paste); local extraction (pdf/docx/txt/md).
- OpenAI structured parsing → store parsed JSON.
- Chunker + embeddings + `vectorStore` persistence (auto re-index on import/notes).
- `rag:search` returns top-k.
- Top-k retrieval (`services/rag/retriever.ts`) — an internal service call, not an IPC channel.
- UI: `ProfileEditorPage` (resume/JD upload + paste + parse, notes).

## M2 — Live session core ✅ (implemented)
Expand Down
69 changes: 69 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# End-to-end tests (Playwright + Electron)

These drive the **built** Electron app — real main process, real SQLite, real IPC —
to cover what the vitest unit suite structurally can't (the DB layer is built for
Electron's ABI and won't load under node).

## Setup

```bash
npm install # pulls @playwright/test + dotenv (added to devDependencies)
```

> No `npx playwright install` needed — these tests don't use Playwright's bundled
> browsers. They launch the project's own Electron and connect over CDP (see below).

For the **live tier**, put your key in `.env` (already gitignored):

```
OPENAI_API_KEY=sk-...
```

## Run

```bash
npm run test:e2e # builds first, then runs all specs
npm run test:e2e:only # skip the build (use the existing out/ bundle)
npx playwright test e2e/data-integrity.spec.ts # one file
```

Two tiers:
- **Default (no key):** UI smoke + data-integrity (FK cascade, settings round-trip) via
the real DB. Runs in CI.
- **Live (`OPENAI_API_KEY` set):** `live-openai.spec.ts` hits real OpenAI (résumé parse
+ embeddings + RAG). It asserts on *structure*, not exact text. Skipped without a key.

## What's covered / not

- ✅ App launches; dashboard renders; navigation.
- ✅ Real main + SQLite via `window.api`: interview delete **FK cascade**, profile-delete
cascade, model preset + per-task override round-trip.
- ✅ (live) résumé parse → embed → RAG retrieval.
- ❌ **Live transcription / mic / screen capture / global shortcuts** — need real
hardware + a display; not automatable headlessly. Their pure logic is unit-tested;
the answer pipeline is exercised here via the no-audio sample/RAG path.

## How the harness works (and why)

Playwright's built-in `_electron.launch()` is **broken on Electron 30+** — it passes
`--remote-debugging-port=0` as a CLI flag that Electron rejects
([microsoft/playwright#39008](https://github.com/microsoft/playwright/issues/39008)).
So `e2e/fixtures.ts` instead:

1. spawns the built app (`out/main/index.js`) directly with `BRAINCUE_E2E=1`;
2. the app opens a fixed CDP port via `appendSwitch` (`src/main/index.ts`, gated on
the E2E flag) — which Electron *does* honor;
3. the fixture connects with `chromium.connectOverCDP` and grabs the dashboard window.

`e2e/global-setup.ts` copies `drizzle/` → `out/main/drizzle` so the built app finds its
migrations (electron-builder does this when packaging; a bare `out/` run doesn't).

## Notes / gotchas

- Tests launch `out/main/index.js`, so a **build must exist** (`test:e2e` builds for you).
- Each test runs against an **isolated data dir** (`E2E_USER_DATA`, honored by
`src/main/index.ts`) so your real profiles/sessions are never touched.
- Data-integrity specs use `window.api` directly rather than clicking through forms —
robust, and they target the exact main/DB paths.
- Privacy Mode (content protection) excludes windows from *screen capture*, not from
Playwright's CDP connection, so it doesn't interfere here.
38 changes: 38 additions & 0 deletions e2e/audit.capture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { test } from './fixtures';

// Opt-in diagnostic (E2E_CAPTURE=1). Drives the app to surface RUNTIME issues that
// static review can't: console/page errors per route, and first-run/empty states.
// Doesn't assert — it logs findings to the run output. No OpenAI key needed.
/* eslint-disable @typescript-eslint/no-explicit-any */
test('@audit runtime errors + empty states', async ({ dashboard }) => {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
dashboard.on('console', (m) => {
if (m.type() === 'error') consoleErrors.push(m.text());
});
dashboard.on('pageerror', (e) => pageErrors.push(e.message));

const routes = ['Profiles', 'Interview', 'Mock Interview', 'Reports', 'Settings'];
for (const name of routes) {
await dashboard.getByRole('link', { name: new RegExp(name, 'i') }).first().click();
await dashboard.waitForTimeout(600);
}

const snippet = async (linkRe: RegExp) => {
await dashboard.getByRole('link', { name: linkRe }).first().click();
await dashboard.waitForTimeout(500);
return (await dashboard.locator('main, #root').first().innerText()).replace(/\s+/g, ' ').slice(0, 280);
};
const reports = await snippet(/reports/i);
const interview = await snippet(/interview/i);
const profiles = await snippet(/profiles/i);

// Surface anything the renderer logs at warn level too (deprecations, React warnings).
console.log('\n===== AUDIT RESULTS =====');
console.log('CONSOLE_ERRORS(' + consoleErrors.length + '):', JSON.stringify(consoleErrors.slice(0, 25)));
console.log('PAGE_ERRORS(' + pageErrors.length + '):', JSON.stringify(pageErrors.slice(0, 25)));
console.log('REPORTS_EMPTY:', reports);
console.log('INTERVIEW_NOPROFILE:', interview);
console.log('PROFILES_EMPTY:', profiles);
console.log('===== END AUDIT =====\n');
});
84 changes: 84 additions & 0 deletions e2e/data-integrity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, expect } from './fixtures';

// Exercises the REAL main process + SQLite through the typed window.api facade —
// no brittle UI selectors. This is where the FK-cascade path lives (the one unit
// tests structurally can't cover, since better-sqlite3 won't load under vitest).
// No OpenAI key required: jobs.save just skips parsing when no key is present.

/* eslint-disable @typescript-eslint/no-explicit-any */
const newProfile = {
name: 'E2E Tester',
targetRole: 'SWE',
targetCompany: null,
interviewType: 'general',
answerStyle: 'default',
language: 'en',
resumeText: null,
jdText: null,
};

test.describe('data integrity (window.api → real DB)', () => {
test('deleting an interview cleans up without a FOREIGN KEY error', async ({ dashboard }) => {
const result = await dashboard.evaluate(async (profileInput) => {
const api = (window as any).api;
const profile = await api.profiles.create(profileInput);
const saved = await api.jobs.save({
profileId: profile.id,
title: 'E2E Role',
company: 'Acme',
jdUrl: null,
jdText: 'Build resilient systems.',
companyUrl: null,
notes: null,
});
const jobId = saved.job.id;
// The FK-cascade workaround: this must NOT throw "FOREIGN KEY constraint failed".
await api.jobs.delete(jobId);
const remaining = await api.jobs.list(profile.id);
await api.profiles.delete(profile.id); // cleanup
return { jobId, remainingIds: remaining.map((j: any) => j.id) };
}, newProfile);

expect(result.remainingIds).not.toContain(result.jobId);
});

test('deleting a profile cascades to its interviews', async ({ dashboard }) => {
const remaining = await dashboard.evaluate(async (profileInput) => {
const api = (window as any).api;
const profile = await api.profiles.create({ ...profileInput, name: 'E2E Cascade' });
await api.jobs.save({
profileId: profile.id,
title: 'J1',
company: null,
jdUrl: null,
jdText: 'x',
companyUrl: null,
notes: null,
});
await api.profiles.delete(profile.id); // must not throw
return (await api.jobs.list(profile.id)).length;
}, newProfile);

expect(remaining).toBe(0);
});

test('model preset + per-task override round-trip through settings', async ({ dashboard }) => {
const out = await dashboard.evaluate(async () => {
const api = (window as any).api;
await api.settings.set({ modelPreset: 'best', models: {} });
const a = await api.settings.get();
await api.settings.set({ models: { answer: 'gpt-4o' } });
const b = await api.settings.get();
// reset
await api.settings.set({ modelPreset: 'balanced', models: {} });
return {
preset: a.modelPreset,
bestAnswerDefault: a.modelDefaults.answer, // preset table flows into modelDefaults
override: b.models.answer,
};
});
expect(out.preset).toBe('best');
expect(out.bestAnswerDefault).toBe('gpt-4.1'); // Best uses the full model on the live path
expect(out.override).toBe('gpt-4o');
});
});
48 changes: 48 additions & 0 deletions e2e/error-handling.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test, expect } from './fixtures';

// Regression test for the v1.0 fixes B1 + B2: a failed live answer must SURFACE an
// error AND clear the streaming state (no card stuck spinning forever). We launch
// with NO API key (noApiKey strips OPENAI_API_KEY), so the first OpenAI call throws
// "No OpenAI API key configured" — deterministic, offline, no real auth call. Each
// test has its own isolated data dir.
/* eslint-disable @typescript-eslint/no-explicit-any */
test.use({ noApiKey: true });

test('a failed answer surfaces an error and clears the streaming state (B1/B2)', async ({
dashboard,
}) => {
test.setTimeout(60_000);
const result = await dashboard.evaluate(async () => {
const api = (window as any).api;
const profile = await api.profiles.create({
name: 'Err',
targetRole: 'SWE',
targetCompany: null,
interviewType: 'general',
answerStyle: 'default',
language: 'en',
resumeText: null,
jdText: null,
});
const session = await api.session.start(profile.id, 'general', 'default', null, 'key_points');

// Listen BEFORE asking. answerDone firing on a failed ask is the core B1 fix
// (the Cue Card card stops spinning); sessionError proves the failure isn't silent.
const sawError = new Promise<boolean>((res) => api.events.onSessionError(() => res(true)));
const sawDone = new Promise<boolean>((res) => api.events.onAnswerDone(() => res(true)));
const timeout = (ms: number) => new Promise<boolean>((res) => setTimeout(() => res(false), ms));

await api.session.ask(session.id, 'Tell me about a hard problem you solved.').catch(() => {});
const [errored, doneFired] = await Promise.all([
Promise.race([sawError, timeout(20_000)]),
Promise.race([sawDone, timeout(20_000)]),
]);

await api.session.stop(session.id).catch(() => {});
await api.profiles.delete(profile.id).catch(() => {});
return { errored, doneFired };
});

expect(result.errored).toBe(true); // failure is surfaced, not silent
expect(result.doneFired).toBe(true); // streaming state cleared — card doesn't wedge
});
Loading
Loading