diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5efb14b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3f86468..22f568c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,12 @@ out/ release/ dist/ *.log +*.tsbuildinfo + +# Playwright e2e output +test-results/ +playwright-report/ +.playwright/ # Secrets — never commit .env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e937463 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/changelog/1.0.0.md b/changelog/1.0.0.md new file mode 100644 index 0000000..1cc7f3c --- /dev/null +++ b/changelog/1.0.0.md @@ -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. diff --git a/docs/05-IPC-MAP.md b/docs/05-IPC-MAP.md index 794fc70..2472a43 100644 --- a/docs/05-IPC-MAP.md +++ b/docs/05-IPC-MAP.md @@ -14,7 +14,7 @@ validates input with zod via the `handle()` helper. Errors are returned as ## Channel naming `:` — 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) @@ -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 | |---|---|---| diff --git a/docs/09-MVP-PLAN.md b/docs/09-MVP-PLAN.md index ba575d9..7f2392f 100644 --- a/docs/09-MVP-PLAN.md +++ b/docs/09-MVP-PLAN.md @@ -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) diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..19bfd07 --- /dev/null +++ b/e2e/README.md @@ -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. diff --git a/e2e/audit.capture.spec.ts b/e2e/audit.capture.spec.ts new file mode 100644 index 0000000..840e161 --- /dev/null +++ b/e2e/audit.capture.spec.ts @@ -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'); +}); diff --git a/e2e/data-integrity.spec.ts b/e2e/data-integrity.spec.ts new file mode 100644 index 0000000..02dbc93 --- /dev/null +++ b/e2e/data-integrity.spec.ts @@ -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'); + }); +}); diff --git a/e2e/error-handling.spec.ts b/e2e/error-handling.spec.ts new file mode 100644 index 0000000..6c125ea --- /dev/null +++ b/e2e/error-handling.spec.ts @@ -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((res) => api.events.onSessionError(() => res(true))); + const sawDone = new Promise((res) => api.events.onAnswerDone(() => res(true))); + const timeout = (ms: number) => new Promise((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 +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..d3f7718 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,137 @@ +import { test as base, expect, chromium, type Browser, type Page } from '@playwright/test'; +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; + +// Playwright's _electron.launch() is broken on Electron 30+ (it passes +// --remote-debugging-port=0 as a CLI flag, which Electron rejects — +// microsoft/playwright#39008). So we spawn the built app ourselves with the E2E +// flag (the app then opens a fixed CDP port via appendSwitch, see src/main/index.ts) +// and connect with chromium.connectOverCDP. Tests drive the dashboard via window.api. + +// Playwright transpiles tests to CommonJS, so require() is available here; +// require('electron') resolves to the path of the electron binary. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const electronBin = require('electron') as unknown as string; +const APP_ENTRY = resolve(process.cwd(), 'out/main/index.js'); +const CDP_PORT = Number(process.env.E2E_CDP_PORT || 9222); + +/** True when a real OpenAI key is available (live tier). Tests needing OpenAI + * `test.skip(!hasKey, …)` so the suite still passes in CI without a key. */ +export const hasKey = !!process.env.OPENAI_API_KEY; + +async function waitForCDP(port: number, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastErr: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${port}/json/version`); + if (res.ok) return; + } catch (e) { + lastErr = e; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`Electron CDP endpoint never came up on :${port} — ${String(lastErr)}`); +} + +function killTree(pid: number): void { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }); + } else { + try { + process.kill(pid, 'SIGKILL'); + } catch { + /* already gone */ + } + } +} + +interface Fixtures { + /** The dashboard BrowserWindow (loaded WITHOUT a ?view= param). */ + dashboard: Page; + /** Launch option: when true, strip OPENAI_API_KEY so the app starts with NO key + * (the app resolves a dev env key first — see env.ts). Use to test no-key paths. */ + noApiKey: boolean; +} + +export const test = base.extend({ + noApiKey: [false, { option: true }], + dashboard: async ({ noApiKey }, use, testInfo) => { + const env: NodeJS.ProcessEnv = { + ...process.env, + BRAINCUE_E2E: '1', + E2E_CDP_PORT: String(CDP_PORT), + E2E_USER_DATA: resolve(testInfo.outputDir, 'userData'), // isolated DB per test + }; + // The Playwright runner sets ELECTRON_RUN_AS_NODE, which would make our spawned + // Electron run as plain Node (electron.app === undefined). Strip it so it boots + // as a real Electron app. + delete env.ELECTRON_RUN_AS_NODE; + if (noApiKey) delete env.OPENAI_API_KEY; + + const proc = spawn(electronBin, [APP_ENTRY], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + proc.stderr.on('data', (d) => (stderr += d.toString())); + + let browser: Browser | undefined; + try { + await waitForCDP(CDP_PORT); + browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`); + const ctx = browser.contexts()[0]; + + // Electron windows surface as CDP pages; the overlay/selection load with + // ?view=…, the dashboard doesn't. Poll — windows appear asynchronously. + const findDash = () => + ctx.pages().find((p) => { + const u = p.url(); + return u && !u.startsWith('about:') && !u.includes('view='); + }); + const deadline = Date.now() + 15_000; + let dash = findDash(); + while (!dash && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 150)); + dash = findDash(); + } + if (!dash) { + throw new Error('dashboard window not found; open pages: ' + ctx.pages().map((p) => p.url()).join(', ')); + } + + await dash.waitForLoadState('domcontentloaded'); + // Mark first-run flags, then reload so the guided tour (a full-screen overlay + // that intercepts clicks) doesn't auto-start — it fires on mount, before we + // could dismiss it, so we set the flag and re-boot the renderer clean. + await dash.evaluate(async () => { + await (window as unknown as { api?: { settings: { set: (p: unknown) => Promise } } }).api?.settings.set({ + tourDone: true, + dataConsentAck: true, + }); + }); + await dash.reload(); + await dash.waitForLoadState('domcontentloaded'); + + await use(dash); + } catch (e) { + throw new Error(`${(e as Error).message}\n--- electron stderr (tail) ---\n${stderr.slice(-2000)}`); + } finally { + await browser?.close().catch(() => {}); + // Await the process exit so port 9222 is free before the next test spawns — + // otherwise the next connectOverCDP can hit this dying instance ("target closed"). + if (proc.pid && proc.exitCode === null) { + const exited = new Promise((r) => proc.once('exit', () => r())); + killTree(proc.pid); + await Promise.race([exited, new Promise((r) => setTimeout(r, 5000))]); + } + } + }, +}); + +/** Inject the real API key (live tier) via the typed preload facade. No-op without a key. */ +export async function setApiKey(dashboard: Page): Promise { + const key = process.env.OPENAI_API_KEY; + if (!key) return; + await dashboard.evaluate(async (k) => { + await (window as unknown as { api: { settings: { setApiKey: (k: string) => Promise } } }).api.settings.setApiKey(k); + }, key); +} + +export { expect }; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..9176fd4 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,14 @@ +import { cpSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// The built app resolves Drizzle migrations relative to its bundle (out/main/drizzle). +// electron-builder copies them there when packaging; running out/main/index.js +// directly (as e2e does) doesn't — so the DB has no tables. Mirror the packaged +// layout before the suite runs. +export default function globalSetup(): void { + const src = resolve(process.cwd(), 'drizzle'); + const dest = resolve(process.cwd(), 'out/main/drizzle'); + if (!existsSync(src)) throw new Error(`drizzle/ migrations not found at ${src}`); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); +} diff --git a/e2e/live-openai.spec.ts b/e2e/live-openai.spec.ts new file mode 100644 index 0000000..5f3c554 --- /dev/null +++ b/e2e/live-openai.spec.ts @@ -0,0 +1,34 @@ +import { test, expect, hasKey, setApiKey } from './fixtures'; + +// LIVE tier — hits real OpenAI via the in-app pipeline (parse + embed + retrieve). +// Gated on OPENAI_API_KEY so CI without a key still passes. Asserts on STRUCTURE, +// not exact text, since real output is non-deterministic. Audio/transcription are +// NOT exercised (no mic in headless) — covered here via the no-audio sample/RAG path. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +test.describe('live OpenAI (real key)', () => { + test.skip(!hasKey, 'Set OPENAI_API_KEY in .env to run the live tier.'); + + test('load samples → résumé is parsed and chunks are embedded', async ({ dashboard }) => { + test.setTimeout(180_000); // real parse of a résumé + JDs + embeddings can take a while + await setApiKey(dashboard); + + const result = await dashboard.evaluate(async () => { + const api = (window as any).api; + const { profileId, jobs } = await api.data.loadSamples(); // real parse + index + const profile = await api.profiles.get(profileId); + const reindex = await api.documents.reindexProfile(profileId); // real embeddings + return { + jobs, + hasParsedResume: !!profile.parsedResume, + skills: profile.parsedResume?.skills?.length ?? 0, + embedded: reindex.embedded, + }; + }); + + expect(result.jobs).toBeGreaterThan(0); // sample interviews created + expect(result.hasParsedResume).toBe(true); // real OpenAI parse happened + expect(result.skills).toBeGreaterThan(0); + expect(result.embedded).toBeGreaterThan(0); // real OpenAI embeddings produced + }); +}); diff --git a/e2e/screenshots.capture.spec.ts b/e2e/screenshots.capture.spec.ts new file mode 100644 index 0000000..ad29d2c --- /dev/null +++ b/e2e/screenshots.capture.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, hasKey, setApiKey } from './fixtures'; +import { resolve } from 'node:path'; + +// Opt-in capture utility (run: E2E_CAPTURE=1 npx playwright test e2e/screenshots.capture.spec.ts). +// Drives the real app to produce README screenshots. CDP screenshots aren't blocked +// by Privacy Mode (that's an OS-capture exclusion), but we reveal anyway for clarity. +/* eslint-disable @typescript-eslint/no-explicit-any */ +const IMG = (name: string) => resolve(process.cwd(), 'docs/images', name); + +test('@capture generate README screenshots', async ({ dashboard }) => { + test.skip(!hasKey, 'needs OPENAI_API_KEY to populate sample data + a mock answer'); + test.setTimeout(240_000); + + await setApiKey(dashboard); + await dashboard.evaluate(async () => { + await (window as any).api.privacy.set(false); // reveal windows for the capture + }); + + // Seed a populated app: sample profile + Google/Amazon/Stripe interviews (parsed). + const { profileId } = await dashboard.evaluate(async () => (window as any).api.data.loadSamples()); + + const selectProfile = async () => { + const sel = dashboard.locator('select').first(); + await sel.waitFor(); + await sel.selectOption({ index: 1 }); // 0 = "Select a profile…" + }; + + // ── Interview ────────────────────────────────────────────────────────────── + await dashboard.getByRole('link', { name: /interview/i }).first().click(); + await selectProfile(); + await expect(dashboard.getByText(/Google|Amazon|Stripe/).first()).toBeVisible(); + await dashboard.waitForTimeout(400); + await dashboard.screenshot({ path: IMG('interview.png') }); + + // ── Settings (preset + Custom indicator) ───────────────────────────────────── + await dashboard.evaluate(async () => { + await (window as any).api.settings.set({ modelPreset: 'best', models: { answer: 'gpt-4o' } }); + }); + await dashboard.getByRole('link', { name: /settings/i }).first().click(); + await expect(dashboard.getByRole('heading', { name: 'OpenAI Models' })).toBeVisible(); + await dashboard.waitForTimeout(400); + await dashboard.screenshot({ path: IMG('settings.png') }); + await dashboard.evaluate(async () => { + await (window as any).api.settings.set({ modelPreset: 'balanced', models: {} }); + }); + + // ── Mock ───────────────────────────────────────────────────────────────────── + await dashboard.getByRole('link', { name: /mock/i }).first().click(); + await selectProfile(); + await dashboard.waitForTimeout(400); + await dashboard.screenshot({ path: IMG('mock.png') }); + + // ── Reports ─────────────────────────────────────────────────────────────────── + await dashboard.getByRole('link', { name: /reports/i }).first().click(); + await dashboard.waitForTimeout(400); + await dashboard.screenshot({ path: IMG('reports.png') }); + + // ── Cue Card (hero) — start a mock so a grounded answer streams in ───────────── + try { + await dashboard.evaluate(async () => { + await (window as any).api.overlay.setMode('expanded'); + }); + await dashboard.evaluate( + async (pid) => (window as any).api.mock.start(pid, 'alloy', null, 'behavioral'), + profileId, + ); + const overlay = dashboard.context().pages().find((p) => p.url().includes('view=overlay')); + if (overlay) { + await overlay.waitForTimeout(14_000); // let the question + answer stream in + await overlay.screenshot({ path: IMG('cue-card.png') }); + } + await dashboard.evaluate(async () => { + const api = (window as any).api; + const r = await api.session.list(); + if (r[0]) await api.mock.end(r[0].id); + }); + } catch { + /* hero is best-effort; the page screenshots above are the priority */ + } +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..a010bb4 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from './fixtures'; + +// Sanity: the app launches, the dashboard renders, and core navigation works. +// No OpenAI key required. +test.describe('smoke', () => { + test('dashboard window opens and renders the shell', async ({ dashboard }) => { + await expect(dashboard.locator('#root')).toBeVisible(); + // The brand wordmark is always present in the sidebar. + await expect(dashboard.getByText(/BrainCue/i).first()).toBeVisible(); + }); + + test('can navigate to Settings', async ({ dashboard }) => { + await dashboard.getByRole('link', { name: /settings/i }).first().click(); + await expect(dashboard.getByRole('heading', { name: 'OpenAI API Key' })).toBeVisible(); + await expect(dashboard.getByRole('heading', { name: 'OpenAI Models' })).toBeVisible(); + }); + + test('can navigate to Interview', async ({ dashboard }) => { + await dashboard.getByRole('link', { name: /interview/i }).first().click(); + await expect(dashboard.getByText(/Pick a profile/i)).toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 33d99f0..02d4be6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-interview-assistant", - "version": "0.5.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-interview-assistant", - "version": "0.5.0", + "version": "0.9.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -24,6 +24,7 @@ "devDependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@playwright/test": "^1.49.1", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.5", "@types/pdf-parse": "^1.1.4", @@ -32,6 +33,7 @@ "@types/ws": "^8.5.13", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "dotenv": "^16.4.7", "drizzle-kit": "^0.30.1", "electron": "^33.3.1", "electron-builder": "^25.1.8", @@ -2474,6 +2476,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -9724,6 +9742,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", diff --git a/package.json b/package.json index a361bca..d972dac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-interview-assistant", - "version": "0.9.0", + "version": "1.0.0", "description": "BrainCue Copilot — desktop AI interview copilot (Electron + React + OpenAI). Local-first data, BYO OpenAI key.", "author": "tpikachu", "license": "MIT", @@ -18,6 +18,8 @@ "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", "test": "vitest run", + "test:e2e": "npm run build && playwright test", + "test:e2e:only": "playwright test", "db:generate": "drizzle-kit generate", "icon": "node scripts/generate-icon.mjs", "clean:release": "node scripts/clean-release.mjs", @@ -44,6 +46,8 @@ "devDependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@playwright/test": "^1.49.1", + "dotenv": "^16.4.7", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.5", "@types/pdf-parse": "^1.1.4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c7c3dcc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; +import 'dotenv/config'; // loads .env → process.env (incl. OPENAI_API_KEY for the live tier) + +// E2E runs against the BUILT Electron app (out/main/index.js), launched per-test via +// Playwright's _electron (see e2e/fixtures.ts). `npm run test:e2e` builds first. +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/global-setup.ts', // mirror migrations into out/main/drizzle + // The README-screenshot capture is opt-in (it needs a key + writes to docs/images). + testIgnore: process.env.E2E_CAPTURE ? [] : ['**/*.capture.spec.ts'], + fullyParallel: false, // each test launches its own Electron instance + shares one local DB + workers: 1, + retries: 0, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [['list']], +}); diff --git a/src/main/index.ts b/src/main/index.ts index 881cecd..8589767 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,23 @@ if (process.env.AI_DISABLE_GPU === '1' || process.argv.includes('--disable-gpu') log.info('GPU hardware acceleration disabled (AI_DISABLE_GPU / --disable-gpu)'); } +// E2E: isolate the on-disk data dir so Playwright tests never touch the real user +// DB. Must run before the app is ready / before the DB opens (db path derives from +// userData). No effect in normal use. +if (process.env.E2E_USER_DATA) { + app.setPath('userData', process.env.E2E_USER_DATA); +} + +// E2E: the harness drives the app over the Chrome DevTools Protocol. Playwright's +// own _electron launcher passes `--remote-debugging-port=0` as a CLI flag, which +// Electron 30+ rejects ("bad option" — microsoft/playwright#39008), so the harness +// spawns us directly and we open a fixed CDP port here via appendSwitch (which +// Electron DOES honor). Gated on the E2E flag — never enabled in normal use. +if (process.env.BRAINCUE_E2E) { + app.commandLine.appendSwitch('remote-debugging-port', process.env.E2E_CDP_PORT || '9222'); + app.commandLine.appendSwitch('remote-allow-origins', '*'); +} + // A crashing GPU process can leave a blank/hidden window — log it so it's diagnosable. app.on('child-process-gone', (_e, details) => { if (details.type === 'GPU' || details.reason !== 'clean-exit') { diff --git a/src/main/services/capture/codingMode.test.ts b/src/main/services/capture/codingMode.test.ts new file mode 100644 index 0000000..85131ee --- /dev/null +++ b/src/main/services/capture/codingMode.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock everything codingMode touches except its own buffer logic. +vi.mock('electron', () => ({ clipboard: { readText: () => '' } })); +vi.mock('../../ipc/broadcast', () => ({ broadcast: vi.fn() })); +vi.mock('../../windows/overlayWindow', () => ({ showOverlay: vi.fn() })); +vi.mock('../openai/coding', () => ({ + // eslint-disable-next-line require-yield + solveFromOcr: vi.fn(async function* () {}), +})); +vi.mock('../openai/vision', () => ({ + solveFromImages: vi.fn(() => (async function* () {})()), +})); +// codingMode imports normalizeOpenAIError from the client, which transitively loads +// electron (app.isPackaged) — stub it so the import chain stays node-safe. +vi.mock('../openai/client', () => ({ normalizeOpenAIError: (e: unknown) => String(e) })); + +import { addCapture, clearCaptures, solveCaptures } from './codingMode'; +import { broadcast } from '../../ipc/broadcast'; +import { solveFromImages } from '../openai/vision'; +import { EVENTS } from '@shared/ipc'; + +const lastBufferImages = (): string[] => { + const calls = (broadcast as unknown as { mock: { calls: unknown[][] } }).mock.calls.filter( + (c) => c[0] === EVENTS.captureBuffer, + ); + return ((calls.at(-1)?.[1] as { images: string[] }) ?? { images: [] }).images; +}; + +beforeEach(() => { + clearCaptures(); + vi.clearAllMocks(); +}); + +describe('multi-image capture buffer', () => { + it('adds a capture and broadcasts the updated buffer to the overlay', () => { + addCapture('img-a'); + expect(broadcast).toHaveBeenCalledWith(EVENTS.captureBuffer, { images: ['img-a'] }, ['overlay']); + }); + + it('caps the buffer at 8, dropping the oldest', () => { + for (let i = 0; i < 9; i++) addCapture(`img-${i}`); + const imgs = lastBufferImages(); + expect(imgs).toHaveLength(8); + expect(imgs[0]).toBe('img-1'); // img-0 (oldest) was dropped + expect(imgs.at(-1)).toBe('img-8'); + }); + + it('clearCaptures empties the buffer and broadcasts []', () => { + addCapture('img-a'); + vi.clearAllMocks(); + clearCaptures(); + expect(lastBufferImages()).toEqual([]); + }); + + it('solveCaptures is a no-op on an empty buffer', async () => { + await solveCaptures(); + expect(solveFromImages).not.toHaveBeenCalled(); + }); + + it('solveCaptures sends ALL buffered images in one call, then clears', async () => { + addCapture('img-1'); + addCapture('img-2'); + await solveCaptures(); + expect(solveFromImages).toHaveBeenCalledTimes(1); + expect(solveFromImages).toHaveBeenCalledWith(['img-1', 'img-2']); + expect(lastBufferImages()).toEqual([]); // buffer cleared after solving + }); +}); diff --git a/src/main/services/capture/codingMode.ts b/src/main/services/capture/codingMode.ts index 4104096..a927f6a 100644 --- a/src/main/services/capture/codingMode.ts +++ b/src/main/services/capture/codingMode.ts @@ -3,6 +3,7 @@ import { EVENTS } from '@shared/ipc'; import { broadcast } from '../../ipc/broadcast'; import { solveFromOcr } from '../openai/coding'; import { solveFromImages } from '../openai/vision'; +import { normalizeOpenAIError } from '../openai/client'; import type { AnswerEvent } from '../openai/answer'; import { showOverlay } from '../../windows/overlayWindow'; @@ -56,7 +57,7 @@ async function streamToOverlay(gen: AsyncGenerator, label: string): } } } catch (e) { - broadcast(EVENTS.sessionError, { message: String(e) }, ['overlay', 'main']); + broadcast(EVENTS.sessionError, { message: normalizeOpenAIError(e) }, ['overlay', 'main']); } finally { broadcast(EVENTS.answerDone, { questionId }, ['overlay']); } diff --git a/src/main/services/capture/screenshot.ts b/src/main/services/capture/screenshot.ts index 3de307c..133491a 100644 --- a/src/main/services/capture/screenshot.ts +++ b/src/main/services/capture/screenshot.ts @@ -20,7 +20,11 @@ export async function captureScreen(display?: Display): Promise { const target = display ?? screen.getPrimaryDisplay(); const { width, height } = target.size; // CSS pixels const aspect = height / width; - const targetWidth = Math.min(width, MAX_WIDTH); + // Capture at DEVICE pixels (CSS × scaleFactor), bounded by MAX_WIDTH — otherwise a + // HiDPI display (e.g. 200% scale) is grabbed at logical resolution and small code + // text is downscaled below what the vision model can read. + const deviceWidth = Math.round(width * (target.scaleFactor || 1)); + const targetWidth = Math.min(deviceWidth, MAX_WIDTH); const targetHeight = Math.round(targetWidth * aspect); const sources = await desktopCapturer.getSources({ diff --git a/src/main/services/openai/answer.test.ts b/src/main/services/openai/answer.test.ts new file mode 100644 index 0000000..9804ed4 --- /dev/null +++ b/src/main/services/openai/answer.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { AnswerEvent } from './answer'; + +// Capture the request body passed to responses.stream, and feed back a fixed +// fake stream (two text deltas + usage). Mock the model resolver so models.ts → +// db → better-sqlite3 is never loaded. +const h = vi.hoisted(() => ({ lastBody: null as Record | null })); +vi.mock('./client', () => ({ + openai: () => ({ + responses: { + stream: (body: Record) => { + h.lastBody = body; + return { + async *[Symbol.asyncIterator]() { + yield { type: 'response.output_text.delta', delta: 'Hello' }; + yield { type: 'response.output_text.delta', delta: ' world' }; + yield { type: 'response.ignored.event' }; // non-delta events are skipped + }, + finalResponse: async () => ({ usage: { input_tokens: 12, output_tokens: 7 } }), + }; + }, + }, + }), +})); +vi.mock('./models', () => ({ model: () => 'gpt-4.1-mini' })); + +import { streamAnswer } from './answer'; + +const profile = { targetRole: 'SWE', targetCompany: 'Acme' } as Parameters[0]['profile']; + +function baseInput(over: Partial[0]> = {}) { + return { + question: 'Tell me about a hard bug.', + contextChunks: [{ id: 'c1', sourceType: 'resume' as const, content: 'Fixed a race condition', score: 0.8 }], + profile, + style: 'default' as const, + length: 'key_points' as const, + pronunciation: false, + interviewType: 'behavioral' as const, + ...over, + }; +} + +async function collect(gen: AsyncGenerator): Promise { + const out: AnswerEvent[] = []; + for await (const ev of gen) out.push(ev); + return out; +} + +const userPrompt = () => String((h.lastBody!.input as { role: string; content: string }[])[1].content); + +beforeEach(() => { + h.lastBody = null; +}); + +describe('streamAnswer — request body', () => { + it('caps key_points at 220 output tokens', async () => { + await collect(streamAnswer(baseInput({ length: 'key_points' }))); + expect(h.lastBody!.max_output_tokens).toBe(220); + expect(userPrompt()).toContain('KEY POINTS'); + expect(userPrompt()).toContain('~60 words'); + }); + + it('caps detailed at 800 output tokens', async () => { + await collect(streamAnswer(baseInput({ length: 'detailed' }))); + expect(h.lastBody!.max_output_tokens).toBe(800); + expect(userPrompt()).toContain('DETAILED'); + }); + + it('includes the pronunciation instruction only when enabled', async () => { + await collect(streamAnswer(baseInput({ pronunciation: true }))); + expect(userPrompt()).toMatch(/phonetic respelling/i); + await collect(streamAnswer(baseInput({ pronunciation: false }))); + expect(userPrompt()).not.toMatch(/phonetic respelling/i); + }); + + it('injects the chosen format and interview type', async () => { + await collect(streamAnswer(baseInput({ style: 'star', interviewType: 'coding' }))); + expect(userPrompt()).toContain('STAR'); + expect(userPrompt()).toContain('Interview type: coding'); + }); + + it('embeds retrieved context tagged by source', async () => { + await collect(streamAnswer(baseInput())); + expect(userPrompt()).toContain('(resume) Fixed a race condition'); + }); + + it('notes when there is NO matching context', async () => { + await collect(streamAnswer(baseInput({ contextChunks: [] }))); + expect(userPrompt()).toContain('no relevant profile context found'); + }); +}); + +describe('streamAnswer — streamed events', () => { + it('yields a delta per output_text.delta and skips other events', async () => { + const evs = await collect(streamAnswer(baseInput())); + const tokens = evs.filter((e) => e.type === 'delta').map((e) => (e as { token: string }).token); + expect(tokens).toEqual(['Hello', ' world']); + }); + + it('yields a usage event from finalResponse', async () => { + const evs = await collect(streamAnswer(baseInput())); + expect(evs).toContainEqual({ type: 'usage', prompt: 12, completion: 7 }); + }); + + it('sets a riskWarning in meta only when context is empty', async () => { + const withCtx = (await collect(streamAnswer(baseInput()))).find((e) => e.type === 'meta'); + expect((withCtx as { riskWarning: string | null }).riskWarning).toBeNull(); + const noCtx = (await collect(streamAnswer(baseInput({ contextChunks: [] })))).find( + (e) => e.type === 'meta', + ); + expect((noCtx as { riskWarning: string | null }).riskWarning).toBeTruthy(); + }); +}); diff --git a/src/main/services/openai/models.test.ts b/src/main/services/openai/models.test.ts new file mode 100644 index 0000000..5772f1f --- /dev/null +++ b/src/main/services/openai/models.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the DB-backed settings repo so importing models.ts doesn't pull in +// better-sqlite3 (which is built for Electron's ABI and can't load under node). +// A mutable `state` lets each test drive the "stored" preset/overrides. +const state = vi.hoisted(() => ({ + preset: null as string | null, + models: {} as Record, + efforts: {} as Record, +})); + +vi.mock('../../db/repositories/settings.repo', () => ({ + SETTINGS_KEYS: { modelPreset: 'model_preset', models: 'models', reasoningEfforts: 'reasoning_efforts' }, + settingsRepo: { + get: (k: string) => (k === 'model_preset' ? state.preset : null), + getJson: (k: string, fallback: unknown) => + k === 'models' ? state.models : k === 'reasoning_efforts' ? state.efforts : fallback, + }, +})); + +import { + PRESETS, + defaultModels, + model, + modelPreset, + presetModels, + reasoningEffort, + isReasoningModel, + reasoningParam, +} from './models'; + +beforeEach(() => { + state.preset = null; + state.models = {}; + state.efforts = {}; +}); + +describe('modelPreset()', () => { + it('defaults to balanced when unset', () => { + expect(modelPreset()).toBe('balanced'); + }); + it('returns a valid stored preset', () => { + state.preset = 'low_cost'; + expect(modelPreset()).toBe('low_cost'); + state.preset = 'best'; + expect(modelPreset()).toBe('best'); + }); + it('falls back to balanced for an unknown value', () => { + state.preset = 'turbo'; // not a real preset + expect(modelPreset()).toBe('balanced'); + }); +}); + +describe('presetModels() / PRESETS', () => { + it('every preset defines the same task keys', () => { + const keys = Object.keys(PRESETS.balanced).sort(); + expect(Object.keys(PRESETS.low_cost).sort()).toEqual(keys); + expect(Object.keys(PRESETS.best).sort()).toEqual(keys); + }); + it('keeps the live paths (answer/classify) on NON-reasoning models in every preset', () => { + for (const p of [PRESETS.balanced, PRESETS.low_cost, PRESETS.best]) { + expect(isReasoningModel(p.answer)).toBe(false); + expect(isReasoningModel(p.classify)).toBe(false); + } + }); + it('uses a reasoning model for the coding solver in every preset', () => { + for (const p of [PRESETS.balanced, PRESETS.low_cost, PRESETS.best]) { + expect(isReasoningModel(p.coding)).toBe(true); + } + }); + it('reflects the active preset', () => { + expect(presetModels()).toEqual(PRESETS.balanced); + state.preset = 'best'; + expect(presetModels()).toEqual(PRESETS.best); + }); + it('defaultModels is the balanced table', () => { + expect(defaultModels).toEqual(PRESETS.balanced); + }); +}); + +describe('model() resolution order', () => { + it('uses the active preset when there is no override', () => { + state.preset = 'best'; + expect(model('answer')).toBe(PRESETS.best.answer); + }); + it('a per-task override wins over the preset', () => { + state.preset = 'balanced'; + state.models = { answer: 'gpt-4o' }; + expect(model('answer')).toBe('gpt-4o'); + expect(model('classify')).toBe(PRESETS.balanced.classify); // untouched key still preset + }); + it('ignores an empty-string override (falls back to preset)', () => { + state.models = { coding: '' }; + expect(model('coding')).toBe(PRESETS.balanced.coding); + }); +}); + +describe('isReasoningModel()', () => { + it('flags GPT-5 and o-series', () => { + for (const id of ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'GPT-5-MINI', 'o4-mini', 'o3']) { + expect(isReasoningModel(id)).toBe(true); + } + }); + it('does not flag the gpt-4.1 / gpt-4o / embedding families', () => { + for (const id of ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'text-embedding-3-small']) { + expect(isReasoningModel(id)).toBe(false); + } + }); +}); + +describe('reasoningEffort()', () => { + it('coding defaults to low', () => { + expect(reasoningEffort('coding')).toBe('low'); + }); + it('non-coding tasks have no default effort', () => { + expect(reasoningEffort('answer')).toBeNull(); + }); + it('a stored override wins over the default', () => { + state.efforts = { coding: 'high' }; + expect(reasoningEffort('coding')).toBe('high'); + }); +}); + +describe('reasoningParam()', () => { + it('attaches effort for a reasoning coding model (default gpt-5-mini @ low)', () => { + expect(reasoningParam('coding')).toEqual({ reasoning: { effort: 'low' } }); + }); + it('is EMPTY when the coding model is overridden to a non-reasoning model', () => { + state.models = { coding: 'gpt-4.1' }; + expect(reasoningParam('coding')).toEqual({}); + }); + it('is empty for tasks with no configured effort even on a reasoning model', () => { + state.preset = 'best'; // answer = full gpt-4.1 (non-reasoning) — still empty + expect(reasoningParam('answer')).toEqual({}); + }); + it('respects an effort override on a reasoning model', () => { + state.efforts = { coding: 'high' }; + expect(reasoningParam('coding')).toEqual({ reasoning: { effort: 'high' } }); + }); +}); diff --git a/src/main/services/openai/parsing.test.ts b/src/main/services/openai/parsing.test.ts new file mode 100644 index 0000000..da3cc19 --- /dev/null +++ b/src/main/services/openai/parsing.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Capture the request body + drive the model's JSON output; mock the model resolver. +const h = vi.hoisted(() => ({ output: '{}', lastBody: null as Record | null })); +vi.mock('./client', () => ({ + openai: () => ({ + responses: { + create: async (body: Record) => { + h.lastBody = body; + return { output_text: h.output }; + }, + }, + }), +})); +vi.mock('./models', () => ({ model: () => 'gpt-4.1-mini' })); + +import { parseResume, parseJobDescription, parseCompany } from './parsing'; + +beforeEach(() => { + h.output = '{}'; + h.lastBody = null; +}); + +describe('parseResume', () => { + it('defaults every field to [] for an empty object', async () => { + h.output = '{}'; + const r = await parseResume('resume text'); + expect(r).toEqual({ + skills: [], + projects: [], + workHistory: [], + metrics: [], + education: [], + certifications: [], + techStack: [], + leadership: [], + }); + }); + + it('keeps present fields and defaults the rest', async () => { + h.output = JSON.stringify({ skills: ['ts', 'go'], metrics: ['+30% perf'] }); + const r = await parseResume('x'); + expect(r.skills).toEqual(['ts', 'go']); + expect(r.metrics).toEqual(['+30% perf']); + expect(r.projects).toEqual([]); // missing → default + }); + + it('caps the input text at 24k chars sent to the model', async () => { + await parseResume('a'.repeat(30_000)); + const userContent = String((h.lastBody!.input as { role: string; content: string }[])[1].content); + expect(userContent.length).toBe(24_000); + }); +}); + +describe('parseJobDescription', () => { + it('defaults all arrays for an empty object', async () => { + const r = await parseJobDescription('jd'); + expect(r).toEqual({ requirements: [], responsibilities: [], keywords: [], focusAreas: [] }); + }); + it('passes through provided arrays', async () => { + h.output = JSON.stringify({ requirements: ['5y exp'], keywords: ['react'] }); + const r = await parseJobDescription('jd'); + expect(r.requirements).toEqual(['5y exp']); + expect(r.keywords).toEqual(['react']); + expect(r.responsibilities).toEqual([]); + }); +}); + +describe('parseCompany', () => { + it('defaults overview to "" (string) and the rest to []', async () => { + const r = await parseCompany('site text'); + expect(r.overview).toBe(''); + expect(r).toMatchObject({ + products: [], + techStack: [], + values: [], + culture: [], + recentNews: [], + interviewAngles: [], + }); + }); + it('keeps a provided overview string', async () => { + h.output = JSON.stringify({ overview: 'We build payments infra.', products: ['API'] }); + const r = await parseCompany('x'); + expect(r.overview).toBe('We build payments infra.'); + expect(r.products).toEqual(['API']); + }); +}); diff --git a/src/main/services/openai/questions.test.ts b/src/main/services/openai/questions.test.ts new file mode 100644 index 0000000..a7243bb --- /dev/null +++ b/src/main/services/openai/questions.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Stub the OpenAI client (the classifier's model response) and the model resolver +// (so models.ts → db → better-sqlite3 is never loaded). +const h = vi.hoisted(() => ({ output: '{}' })); +vi.mock('./client', () => ({ + openai: () => ({ responses: { create: async () => ({ output_text: h.output }) } }), +})); +vi.mock('./models', () => ({ model: () => 'gpt-4.1-nano' })); + +import { classifyQuestion } from './questions'; + +beforeEach(() => { + h.output = '{}'; +}); + +describe('classifyQuestion', () => { + it('maps a well-formed classifier response', async () => { + h.output = JSON.stringify({ + isQuestion: true, + type: 'coding', + confidence: 0.92, + strategy: 'lead with the approach', + }); + const r = await classifyQuestion('reverse a linked list'); + expect(r).toEqual({ + isQuestion: true, + text: 'reverse a linked list', + type: 'coding', + confidence: 0.92, + strategy: 'lead with the approach', + }); + }); + + it('always echoes the input text, never the model output', async () => { + h.output = JSON.stringify({ isQuestion: true, text: 'SOMETHING ELSE' }); + const r = await classifyQuestion('the real utterance'); + expect(r.text).toBe('the real utterance'); + }); + + it('defaults every field when the model returns an empty object', async () => { + h.output = '{}'; + const r = await classifyQuestion('hello'); + expect(r).toEqual({ + isQuestion: false, // safe default: don't answer on uncertainty + text: 'hello', + type: 'behavioral', + confidence: 0, + strategy: '', + }); + }); + + it('treats a missing isQuestion as not-a-question (false), not truthy', async () => { + h.output = JSON.stringify({ type: 'product', confidence: 0.5 }); + const r = await classifyQuestion('mm-hmm'); + expect(r.isQuestion).toBe(false); + }); + + it('keeps confidence 0 when omitted (so the >=0.4 gate fails closed)', async () => { + h.output = JSON.stringify({ isQuestion: true }); + const r = await classifyQuestion('uh'); + expect(r.confidence).toBe(0); + }); + + // Documents a known gap: output_text is parsed without a guard. json_object format + // makes this unlikely, but malformed JSON currently rejects (caught upstream in + // processFinalTranscript). If this is ever hardened, update this test. + it('rejects on malformed JSON (current unguarded behavior)', async () => { + h.output = 'not json at all'; + await expect(classifyQuestion('x')).rejects.toThrow(); + }); +}); diff --git a/src/main/services/openai/realtime.ts b/src/main/services/openai/realtime.ts index 85a3c0b..8644404 100644 --- a/src/main/services/openai/realtime.ts +++ b/src/main/services/openai/realtime.ts @@ -24,6 +24,7 @@ export class RealtimeTranscriber { private ws: WebSocket | null = null; private ready = false; private closing = false; + private errored = false; // a specific error was already surfaced this connection constructor( private cb: RealtimeCallbacks, @@ -37,6 +38,7 @@ export class RealtimeTranscriber { return; } this.closing = false; + this.errored = false; // GA Realtime API: the beta shape was retired (the `OpenAI-Beta: realtime=v1` // header + `transcription_session.update` event), which is why the server now // rejects beta connections with `beta_api_shape_disabled`. @@ -81,11 +83,23 @@ export class RealtimeTranscriber { this.ws.on('message', (data) => this.handle(data.toString())); this.ws.on('error', (err) => { log.error('realtime: ws error', err.message); - if (!this.closing) this.cb.onError?.(`Realtime transcription error: ${err.message}`); + if (!this.closing) { + this.errored = true; + this.cb.onError?.(`Realtime transcription error: ${err.message}`); + } }); this.ws.on('close', (code, reason) => { this.ready = false; - if (!this.closing) log.warn(`realtime: ws closed (${code}) ${reason.toString()}`); + // An UNEXPECTED close would otherwise go unreported while the mic keeps streaming + // into a dead socket — the interview silently goes deaf. But 'ws' fires 'error' + // THEN 'close' for the same failure, so skip the generic message when a specific + // error was already surfaced (don't clobber "expired key" with "disconnected"). + if (!this.closing) { + log.warn(`realtime: ws closed (${code}) ${reason.toString()}`); + if (!this.errored) { + this.cb.onError?.('Transcription disconnected — stop and resume the interview to reconnect.'); + } + } }); } diff --git a/src/main/services/session/sessionManager.ts b/src/main/services/session/sessionManager.ts index 4873b1d..2a7086d 100644 --- a/src/main/services/session/sessionManager.ts +++ b/src/main/services/session/sessionManager.ts @@ -8,6 +8,7 @@ import { sessionsRepo } from '../../db/repositories/sessions.repo'; import { transcribeChunk } from '../openai/transcription'; import { classifyQuestion } from '../openai/questions'; import { streamAnswer } from '../openai/answer'; +import { normalizeOpenAIError } from '../openai/client'; import { retrieve } from '../rag/retriever'; import { RealtimeTranscriber } from '../openai/realtime'; import { getOverlayWindow, showOverlay } from '../../windows/overlayWindow'; @@ -443,10 +444,6 @@ export const sessionManager = { const profile = profilesRepo.get(session.profileId); if (!profile) throw new Error('Profile not found'); - const context = await retrieve(profile.id, questionText, 5, session.jobId); - // Transparency: tell the UI exactly what was sent to OpenAI for this question. - broadcast(EVENTS.contextSent, { questionId, question: questionText, chunks: context }); - let answer = ''; let tokens: { prompt: number; completion: number } | null = null; let meta: Record = {}; @@ -456,6 +453,11 @@ export const sessionManager = { live.answerAbort = abort; } try { + // Retrieval (an embeddings call) is INSIDE the try so a failure here is surfaced + // + un-wedges the card too — not just streamAnswer failures. + const context = await retrieve(profile.id, questionText, 5, session.jobId); + // Transparency: tell the UI exactly what was sent to OpenAI for this question. + broadcast(EVENTS.contextSent, { questionId, question: questionText, chunks: context }); for await (const ev of streamAnswer({ question: questionText, contextChunks: context, @@ -481,6 +483,11 @@ export const sessionManager = { } catch (e) { // Aborted by clear/regenerate — drop this partial answer silently. if (abort.signal.aborted) return { questionId }; + // A real failure (auth, quota, network drop, model-not-found): surface it and + // clear the Cue Card's streaming state, instead of leaving the card spinning + // forever with no error (the most common live failure — e.g. an expired key). + broadcast(EVENTS.sessionError, { message: normalizeOpenAIError(e) }); + broadcast(EVENTS.answerDone, { questionId }); throw e; } finally { if (live) { diff --git a/src/renderer/components/ui.tsx b/src/renderer/components/ui.tsx index 87f115a..208752c 100644 --- a/src/renderer/components/ui.tsx +++ b/src/renderer/components/ui.tsx @@ -1,5 +1,5 @@ import type React from 'react'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { ChevronLeftIcon, ChevronRightIcon, CloseIcon, SearchIcon } from './icons'; /** Centered modal dialog. Closes on overlay click or Escape. */ @@ -16,12 +16,26 @@ export function Modal({ width?: string; children: React.ReactNode; }) { + const dialogRef = useRef(null); + // Read onClose through a ref so the focus effect depends ONLY on `open`. Call sites + // pass a new inline onClose each render; if it were a dep, any parent re-render with + // the modal open (e.g. the Cue Card's per-frame audio-meter updates) would re-run + // this effect and yank focus out of the dialog's controls. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onCloseRef.current(); window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [open, onClose]); + // Move focus into the dialog (so keyboard/AT users land inside it) and restore it + // to whatever was focused before, on close. `prev` is captured once, when open→true. + const prev = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + return () => { + window.removeEventListener('keydown', onKey); + prev?.focus?.(); + }; + }, [open]); if (!open) return null; return ( @@ -30,7 +44,12 @@ export function Modal({ onMouseDown={onClose} >
e.stopPropagation()} >
diff --git a/src/renderer/dashboard/pages/MockPage.tsx b/src/renderer/dashboard/pages/MockPage.tsx index c5d02bc..a56106a 100644 --- a/src/renderer/dashboard/pages/MockPage.tsx +++ b/src/renderer/dashboard/pages/MockPage.tsx @@ -33,6 +33,7 @@ export default function MockPage() { const [progress, setProgress] = useState({ index: 0, total: 0 }); const [asked, setAsked] = useState([]); const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); const audioRef = useRef(null); const urlRef = useRef(null); // current clip's object URL (revoked on replace/unmount) const sessionIdRef = useRef(null); // latest id, for the unmount cleanup @@ -83,8 +84,9 @@ export default function MockPage() { const start = async () => { if (!profileId) return; + setError(null); if (!settings?.apiKeyPresent) { - alert('Add your OpenAI API key in Settings first.'); + setError('Add your OpenAI API key in Settings first.'); return; } setBusy('Starting rehearsal & generating the first question…'); @@ -96,7 +98,7 @@ export default function MockPage() { setAsked([r.question]); play(r.audioBase64); } catch (e) { - alert((e as Error).message); + setError((e as Error).message); } finally { setBusy(null); } @@ -104,6 +106,7 @@ export default function MockPage() { const next = async () => { if (!sessionId) return; + setError(null); setBusy('Thinking of the next question…'); try { const r = await api.mock.next(sessionId); @@ -116,7 +119,7 @@ export default function MockPage() { setAsked((a) => [...a, r.question!]); play(r.audioBase64); } catch (e) { - alert((e as Error).message); + setError((e as Error).message); } finally { setBusy(null); } @@ -137,6 +140,19 @@ export default function MockPage() { > {busy && } + {error && ( +
+ {error} + +
+ )} + {!sessionId && (
diff --git a/src/renderer/overlay/Overlay.tsx b/src/renderer/overlay/Overlay.tsx index 4f31903..e460fd2 100644 --- a/src/renderer/overlay/Overlay.tsx +++ b/src/renderer/overlay/Overlay.tsx @@ -12,6 +12,14 @@ import type { } from '@shared/types'; import { Markdown } from '../components/Markdown'; import { Modal } from '../components/ui'; +import { + type AnswerCard, + addCard, + makeCard, + patchLast, + removeCard, + toggleCollapsed, +} from './answerCards'; import { BoltIcon, ChevronRightIcon, @@ -35,23 +43,6 @@ interface Line { text: string; } -/** One generated answer (interview question or coding solve). With history on, past - * cards are kept (collapsed) instead of being replaced; each is individually removable. */ -interface AnswerCard { - id: number; - question: string; - answer: string; - meta: AnswerMetaEvent | null; - context: ContextSentEvent | null; - streaming: boolean; - collapsed: boolean; -} - -/** Apply a patch to the newest (current) card. */ -function patchLast(cards: AnswerCard[], patch: Partial): AnswerCard[] { - if (!cards.length) return cards; - return [...cards.slice(0, -1), { ...cards[cards.length - 1], ...patch }]; -} // Cap transcript lines kept in the DOM — a long interview can produce thousands. const MAX_LINES = 300; @@ -160,30 +151,18 @@ export default function Overlay() { const text = (p as { text: string }).text; cancelFlush(); // History on: collapse prior cards and add a fresh one. Off: replace. - setCards((cs) => { - const prior = historyEnabledRef.current - ? cs.map((c) => ({ ...c, collapsed: true, streaming: false })) - : []; - return [ - ...prior, - { - id: cardId.current++, - question: text, - answer: '', - meta: null, - context: null, - streaming: true, - collapsed: false, - }, - ]; - }); + setCards((cs) => addCard(cs, makeCard(cardId.current++, text), historyEnabledRef.current)); // Mirror the dashboard: surface the detected question in the transcript too. - setTranscript((t) => [...t, { id: lineId.current++, speaker: 'detected question', text }]); + setTranscript((t) => + [...t, { id: lineId.current++, speaker: 'detected question', text }].slice(-MAX_LINES * 2), + ); }), api.events.onTranscriptDelta((p) => { const d = p as { text: string; speaker: string; isFinal: boolean }; if (d.isFinal) { - setTranscript((t) => [...t, { id: lineId.current++, speaker: d.speaker, text: d.text }]); + setTranscript((t) => + [...t, { id: lineId.current++, speaker: d.speaker, text: d.text }].slice(-MAX_LINES * 2), + ); setInterim(''); } else { setInterim((s) => s + d.text); @@ -400,7 +379,7 @@ export default function Overlay() { const sendAsk = () => { const t = askText.trim(); if (!t) return; - void api.session.askActive(t); + void api.session.askActive(t).catch(() => {}); // errors surface via sessionError setAskText(''); }; @@ -889,7 +868,7 @@ export default function Overlay() {
)} - {/* Talking points + resume match (expanded mode) */} - {mode === 'expanded' && meta && ( -
- {meta.talkingPoints.length > 0 && ( -
    - {meta.talkingPoints.map((t, i) => ( -
  • {t}
  • - ))} -
- )} - {meta.resumeMatch && ( -

- Resume: - {meta.resumeMatch} -

- )} - {meta.followupQuestion && ( -

- Ask back: - {meta.followupQuestion} -

- )} -
- )} - {meta?.riskWarning && (

⚠ {meta.riskWarning} @@ -1164,7 +1118,12 @@ function Btn(props: { ? 'bg-neutral-700 text-white' : 'text-neutral-400 hover:bg-neutral-700/70 hover:text-neutral-200'; return ( - ); diff --git a/src/renderer/overlay/answerCards.test.ts b/src/renderer/overlay/answerCards.test.ts new file mode 100644 index 0000000..79071a7 --- /dev/null +++ b/src/renderer/overlay/answerCards.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { makeCard, patchLast, addCard, removeCard, toggleCollapsed, type AnswerCard } from './answerCards'; + +const card = (id: number, over: Partial = {}): AnswerCard => ({ + ...makeCard(id, `q${id}`), + ...over, +}); + +describe('makeCard', () => { + it('starts expanded + streaming with an empty answer', () => { + expect(makeCard(1, 'why hashmap?')).toEqual({ + id: 1, + question: 'why hashmap?', + answer: '', + meta: null, + context: null, + streaming: true, + collapsed: false, + }); + }); +}); + +describe('patchLast', () => { + it('patches only the newest card', () => { + const out = patchLast([card(1), card(2)], { answer: 'done', streaming: false }); + expect(out[0]).toEqual(card(1)); // untouched + expect(out[1]).toMatchObject({ id: 2, answer: 'done', streaming: false }); + }); + it('is a no-op on an empty list', () => { + expect(patchLast([], { answer: 'x' })).toEqual([]); + }); + it('does not mutate the input array', () => { + const input = [card(1)]; + patchLast(input, { answer: 'x' }); + expect(input[0].answer).toBe(''); + }); +}); + +describe('addCard — history OFF (replace)', () => { + it('drops prior cards, keeping only the new one', () => { + const out = addCard([card(1), card(2)], makeCard(3, 'q3'), false); + expect(out).toHaveLength(1); + expect(out[0].id).toBe(3); + }); +}); + +describe('addCard — history ON (keep + collapse)', () => { + it('collapses prior cards, stops their streaming, and appends the new one expanded', () => { + const prior = [card(1, { streaming: true, collapsed: false }), card(2, { streaming: true })]; + const out = addCard(prior, makeCard(3, 'q3'), true); + expect(out.map((c) => c.id)).toEqual([1, 2, 3]); + expect(out[0]).toMatchObject({ collapsed: true, streaming: false }); + expect(out[1]).toMatchObject({ collapsed: true, streaming: false }); + expect(out[2]).toMatchObject({ id: 3, collapsed: false, streaming: true }); + }); + it('preserves prior answers (history is not lost)', () => { + const out = addCard([card(1, { answer: 'kept' })], makeCard(2, 'q2'), true); + expect(out[0].answer).toBe('kept'); + }); +}); + +describe('removeCard', () => { + it('removes the matching id and leaves the rest', () => { + expect(removeCard([card(1), card(2), card(3)], 2).map((c) => c.id)).toEqual([1, 3]); + }); + it('is a no-op for an unknown id', () => { + expect(removeCard([card(1)], 99).map((c) => c.id)).toEqual([1]); + }); +}); + +describe('toggleCollapsed', () => { + it('flips only the targeted card', () => { + const out = toggleCollapsed([card(1, { collapsed: false }), card(2, { collapsed: false })], 1); + expect(out[0].collapsed).toBe(true); + expect(out[1].collapsed).toBe(false); + }); +}); diff --git a/src/renderer/overlay/answerCards.ts b/src/renderer/overlay/answerCards.ts new file mode 100644 index 0000000..8a2b438 --- /dev/null +++ b/src/renderer/overlay/answerCards.ts @@ -0,0 +1,41 @@ +import type { AnswerMetaEvent, ContextSentEvent } from '@shared/types'; + +/** One generated answer (interview question or coding solve). With history on, past + * cards are kept (collapsed) instead of being replaced; each is individually removable. */ +export interface AnswerCard { + id: number; + question: string; + answer: string; + meta: AnswerMetaEvent | null; + context: ContextSentEvent | null; + streaming: boolean; + collapsed: boolean; +} + +/** A fresh, expanded, streaming card for a newly detected question. */ +export function makeCard(id: number, question: string): AnswerCard { + return { id, question, answer: '', meta: null, context: null, streaming: true, collapsed: false }; +} + +/** Apply a patch to the newest (current) card. No-op on an empty list. */ +export function patchLast(cards: AnswerCard[], patch: Partial): AnswerCard[] { + if (!cards.length) return cards; + return [...cards.slice(0, -1), { ...cards[cards.length - 1], ...patch }]; +} + +/** Add a new question card. With history ON, prior cards collapse + stop streaming + * and are kept; with history OFF, they're replaced (only the new card remains). */ +export function addCard(cards: AnswerCard[], card: AnswerCard, historyEnabled: boolean): AnswerCard[] { + const prior = historyEnabled ? cards.map((c) => ({ ...c, collapsed: true, streaming: false })) : []; + return [...prior, card]; +} + +/** Remove a card by id (the per-card × button). */ +export function removeCard(cards: AnswerCard[], id: number): AnswerCard[] { + return cards.filter((c) => c.id !== id); +} + +/** Toggle one card's collapsed state (clicking its header). */ +export function toggleCollapsed(cards: AnswerCard[], id: number): AnswerCard[] { + return cards.map((c) => (c.id === id ? { ...c, collapsed: !c.collapsed } : c)); +} diff --git a/src/renderer/store/useLiveSession.ts b/src/renderer/store/useLiveSession.ts index cd6b043..70cc65d 100644 --- a/src/renderer/store/useLiveSession.ts +++ b/src/renderer/store/useLiveSession.ts @@ -52,6 +52,9 @@ interface LiveSessionState { ask: (question: string) => Promise; } +// Cap the in-memory transcript so a long session can't grow it without bound. +const MAX_TRANSCRIPT = 500; + // --- audio capture singletons (outside React) --- let ctx: AudioContext | null = null; let node: ScriptProcessorNode | null = null; @@ -84,7 +87,11 @@ export const useLiveSession = create((set, get) => { const d = p as { text: string; speaker: string; isFinal: boolean }; if (d.isFinal) { set((s) => ({ - transcript: [...s.transcript, { id: lineId++, speaker: d.speaker, text: d.text }], + // Cap the backing array — a multi-hour interview would otherwise accumulate + // thousands of line objects in memory (the UI only renders the last ~300). + transcript: [...s.transcript, { id: lineId++, speaker: d.speaker, text: d.text }].slice( + -MAX_TRANSCRIPT, + ), interim: '', })); } else { @@ -94,7 +101,10 @@ export const useLiveSession = create((set, get) => { api.events.onQuestionDetected((p) => { const d = p as { text: string }; set((s) => ({ - transcript: [...s.transcript, { id: lineId++, speaker: 'detected question', text: d.text }], + transcript: [ + ...s.transcript, + { id: lineId++, speaker: 'detected question', text: d.text }, + ].slice(-MAX_TRANSCRIPT), })); }); api.events.onSavePrompt((p) => set({ pendingSave: p })); @@ -220,10 +230,16 @@ export const useLiveSession = create((set, get) => { ask: async (question) => { const s = get().session; if (!s || !question) return; - await api.session.ask(s.id, question); + // Show the asked question immediately + keep it even if the answer fails (the + // failure surfaces via sessionError). Swallow the rejection so a failed ask + // doesn't become an unhandled promise rejection. set((st) => ({ - transcript: [...st.transcript, { id: lineId++, speaker: 'you (manual)', text: question }], + transcript: [ + ...st.transcript, + { id: lineId++, speaker: 'you (manual)', text: question }, + ].slice(-MAX_TRANSCRIPT), })); + await api.session.ask(s.id, question).catch(() => {}); }, }; }); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9d64b4f..fd1cf88 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -61,9 +61,6 @@ export const IPC = { create: 'notes:create', delete: 'notes:delete', }, - rag: { - search: 'rag:search', - }, session: { start: 'session:start', resume: 'session:resume', diff --git a/vitest.config.ts b/vitest.config.ts index af1fee9..5367ce8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,19 @@ import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; -// Unit tests cover the pure logic (no electron/db/network). See *.test.ts. +const r = (p: string) => resolve(process.cwd(), p); + +// Unit tests cover pure + lightly-mocked logic (electron/db/network are mocked per +// test — see *.test.ts). The path aliases mirror tsconfig so modules that import +// from `@shared`/`@main`/`@renderer` (value imports, e.g. EVENTS) resolve here too. export default defineConfig({ + resolve: { + alias: { + '@shared': r('src/shared'), + '@main': r('src/main'), + '@renderer': r('src/renderer'), + }, + }, test: { environment: 'node', include: ['src/**/*.test.ts'],