Add Phase 0 test harness (vitest + ESLint) and fix build config
- Add vitest unit tests for Zustand app store state transitions (6 tests) - Add mock speech-engine WebSocket protocol integration test (spawns server.py --mock on a free port) - Add ESLint 10 flat config with typescript-eslint; add typecheck/lint/test scripts to package.json - Fix pre-existing tsc -b breakage via noEmit in tsconfig.node.json; add dedicated tsconfig.tests.json project for Node-side tests - Track src-tauri/Cargo.lock for reproducible Rust builds; enable protocol-asset Tauri feature - Ignore generated artifacts (*.tsbuildinfo, src-tauri/gen/, emitted vite.config.js/.d.ts); remove stale emitted vite config copies - Update AGENTS.md agent guide (demo scope lock, roadmap, verification commands) - Add HANDOFF.md documenting Phase 0 state and remaining steps for the next agent
This commit is contained in:
@@ -1,8 +1,30 @@
|
||||
# Dependencies (rebuilt via `npm ci` from package-lock.json)
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
src-tauri/target/
|
||||
|
||||
# TypeScript incremental build info (`tsc -b`)
|
||||
*.tsbuildinfo
|
||||
|
||||
# Tauri generated files (tauri-build emits schemas/capabilities here on every build)
|
||||
src-tauri/gen/
|
||||
|
||||
# Stale emitted copies of vite.config.ts — the .ts file is the source of truth.
|
||||
# Vite resolves .js before .ts, so an emitted copy would silently shadow it.
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
|
||||
# Python / speech engine (rebuilt via `python -m venv .venv && pip install -r requirements.txt`)
|
||||
speech-engine/.venv/
|
||||
speech-engine/__pycache__/
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Environment & secrets — never commit
|
||||
.env
|
||||
|
||||
# OS / editor noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
154
Wireframes/Desktop/MeetVault-Desktop-Demo/HANDOFF.md
Normal file
154
Wireframes/Desktop/MeetVault-Desktop-Demo/HANDOFF.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Phase 0 Handoff — MeetVault Desktop Demo
|
||||
|
||||
Session handoff for the **Phase 0 (test harness + runtime verification)** work defined in `AGENTS.md` §14.
|
||||
Read `PROJECT_SCOPE.md` and `AGENTS.md` first; this file is a continuation note, not a substitute.
|
||||
|
||||
**Delete or replace this file when Phase 0 is complete.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Current verification status (verified 2026-10-03)
|
||||
|
||||
| Check | Command | Status | Detail |
|
||||
|---|---|---|---|
|
||||
| Typecheck | `npm run typecheck` | **RED** | 3 errors, all in `tests/speechProtocol.mock.test.ts`: TS2322 line 111 (`ChildProcessByStdio<null, Readable, Readable>` not assignable to `ChildProcessWithoutNullStreams`), TS18048 lines 115/116 (`server` possibly undefined) |
|
||||
| Lint | `npm run lint` | **RED** | 3 errors: `react-hooks/exhaustive-deps` rule definition not found ×2 (`src/pages/LiveMeeting.tsx` lines 51, 70 — pre-existing disable comments; plugin installed but NOT wired into config), and `preserve-caught-error` at test line ~120 (rethrow without `{ cause }`) |
|
||||
| Tests | `npm test` | **RED** | Unit: `src/store/useAppStore.test.ts` **6/6 pass** (with stderr noise — see §4.3). Integration: `tests/speechProtocol.mock.test.ts` **2/5 pass**; failures: close code got 1006 expected 1000; "rejects unsupported sample rates" and "reports malformed config as an error event" both time out waiting for the error event |
|
||||
| Rust | `cargo check --manifest-path src-tauri/Cargo.toml` | **RED** | All deps compile (MSVC toolchain works). Fails only in tauri-build: `` `src-tauri/icons/icon.ico` not found; required for generating a Windows Resource file during tauri-build`` |
|
||||
| Python | `.venv\Scripts\python.exe -m py_compile speech-engine/server.py` | **GREEN** | No changes to server.py planned in Phase 0 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Completed work this session (done and verified)
|
||||
|
||||
1. **Removed `speech-engine/fresh_venv/`** — it was Python 3.14, incompatible with diart. The working venv is `speech-engine/.venv` (Python 3.12).
|
||||
2. **Fixed pre-existing build breakage**: added `"noEmit": true` to `tsconfig.node.json`. `tsc -b` now works; the composite+noEmit combo was verified.
|
||||
3. **Installed test/lint devDeps** (lockfile updated): vitest 3.2.7, eslint 10.12, @eslint/js ^10, typescript-eslint 8.71, @types/node ^26, **eslint-plugin-react-hooks ^7.1.1**.
|
||||
4. **Created the harness**:
|
||||
- `eslint.config.js` — ESLint 10 flat config: `@eslint/js` recommended + `typescript-eslint` recommended, scoped to `**/*.{ts,tsx}` via `.map()`; ignores dist/node_modules/target/.venv. (react-hooks plugin NOT yet wired in.)
|
||||
- `vitest.config.ts`, `tsconfig.tests.json` (ES2022, no DOM lib, `types: ["node"]`; includes `tests/**/*.ts` + `vitest.config.ts`) referenced from root `tsconfig.json`.
|
||||
- `src/test/setup.ts` — in-memory localStorage stub for zustand persist under Node. **Not working as intended; see §4.3.**
|
||||
- `src/store/useAppStore.test.ts` — 6 unit tests (createMeeting, appendSegment add/replace, renameSpeaker trim/fallback, finishMeeting commit, setSettings merge). All pass.
|
||||
- `tests/speechProtocol.mock.test.ts` — 5 integration tests; spawns `.venv\Scripts\python.exe speech-engine/server.py --mock --port <free-port>` (skips if venv missing), drives it with Node's native WebSocket + fetch.
|
||||
5. **package.json scripts added**: `typecheck` (`tsc -b`), `lint` (`eslint .`), `test` (`vitest run`), `test:watch` (`vitest`).
|
||||
6. **Toolchain unblocked**: installed VS C++ BuildTools workload; MSVC 14.51.36231 at `C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools`. cargo check compiles all dependencies successfully — only the icon step fails.
|
||||
7. **Verified `.venv` versions** (within requirements pins): fastapi 0.141.1, starlette **1.7.0**, uvicorn 0.54.0, pydantic 2.13.5.
|
||||
|
||||
---
|
||||
|
||||
## 3. Remaining work — do in this order
|
||||
|
||||
### Step 1 — Create the placeholder icon (unblocks cargo check)
|
||||
|
||||
The archive shipped without `src-tauri/icons/`; git confirms it was never tracked (`git ls-files src-tauri/icons/*` is empty). `tauri.conf.json` has no explicit icon array, so tauri-build requires the default `icons/icon.ico` on Windows.
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory "src-tauri\icons" # Node writeFileSync does NOT create parent dirs
|
||||
node C:\Users\supersanta\AppData\Local\Temp\opencode\make-ico.cjs "D:\workspace\MeetVault\Wireframes\Desktop\MeetVault-Desktop-Demo\src-tauri\icons\icon.ico"
|
||||
```
|
||||
|
||||
`make-ico.cjs` is a one-off generator in the temp dir (writes a 1150-byte solid-blue 16×16 32bpp ICO; takes output path as `argv[2]`). If it's gone, recreate it or hand-write any valid minimal ICO. **Commit the generated `icon.ico`** — the build requires it and it was never tracked.
|
||||
|
||||
Then re-run `cargo check --manifest-path src-tauri/Cargo.toml` → expect GREEN.
|
||||
|
||||
### Step 2 — Settle ground truth for WS teardown before touching test assertions
|
||||
|
||||
Facts verified by reading `speech-engine/server.py` (ws handler, lines ~318–415):
|
||||
|
||||
- **No explicit `websocket.close()` anywhere in the handler.** Both error paths do `send_json({"type":"error", ...})` then `return`; starlette tears down when the handler returns. Observed close code is **1006** (abnormal, no close frame) — consistent with starlette 1.7.0 behavior.
|
||||
- Bad sample rate → error message exactly: `"MeetVault speech engine currently requires 16 kHz mono PCM."` then return.
|
||||
- Malformed JSON hits the generic `except Exception` (~line 391): tries `send_json({"type":"error","message":str(exc)})`, swallows send failures, then `session.close()`.
|
||||
- Yet in tests those error events **never arrive** (client times out). Hypothesis: starlette drops/loses the pre-close send during teardown. Unconfirmed.
|
||||
|
||||
Do one of these to confirm before editing assertions:
|
||||
|
||||
1. Read `.venv\Lib\site-packages\starlette\websockets.py` (line 39+) for what happens on handler return, or
|
||||
2. Manual probe: start `server.py --mock --port <free>`, connect with a small Node WebSocket script, send (a) bad sample_rate config, (b) malformed JSON, (c) valid config + stop after finals; log every frame and the close code for each.
|
||||
|
||||
Then align the three failing assertions to actual behavior. If 1006/no-close-frame is confirmed, accept it in tests **with a comment** documenting that an explicit close/ready handshake is Phase 3 protocol debt (AGENTS.md §9 P1 "speech readiness/protocol"). Do NOT change `server.py` behavior in Phase 0 — tests only.
|
||||
|
||||
### Step 3 — Fix `tests/speechProtocol.mock.test.ts`
|
||||
|
||||
- Line ~6: import `ChildProcessByStdio` from `node:child_process` and `Readable` from `node:stream`.
|
||||
- Line ~104: `let server: ChildProcessByStdio<null, Readable, Readable> | undefined;` — fixes TS2322 (line 111) and both TS18048s (lines 115/116).
|
||||
- Line ~120: attach `{ cause: error }` to the rethrown `Error(...)` in `beforeAll` (ESLint 10 `preserve-caught-error`).
|
||||
- Close-code assertion per Step 2 findings.
|
||||
|
||||
### Step 4 — Wire eslint-plugin-react-hooks into `eslint.config.js`
|
||||
|
||||
Plugin is installed (`^7.1.1`) but absent from the config, so the pre-existing disable comments in `src/pages/LiveMeeting.tsx` (lines 51, 70) fail with "Definition for rule ... was not found". Check the v7 export shape first (`node_modules/eslint-plugin-react-hooks/package.json` + main entry — v7 is ESM), then add a flat-config entry registering the plugin with `rules-of-hooks: error` and `exhaustive-deps: warn` (or error). Keep the disable comments in LiveMeeting.tsx as-is; once the rule exists they become valid.
|
||||
|
||||
### Step 5 — Fix `src/test/setup.ts` localStorage stub
|
||||
|
||||
Current state: gated on `typeof globalThis.localStorage === 'undefined'`. Under vitest workers the gate passes (Node 26's getter returns undefined while emitting an ExperimentalWarning) yet zustand persist still logs `[zustand persist middleware] Unable to update item 'meetvault-demo-store-v1', the given storage is currently unavailable.` per state update. **Root cause undiagnosed.**
|
||||
|
||||
Planned fix: drop the gate — unconditionally `Object.defineProperty(globalThis, 'localStorage', { value: new MemoryStorage(), configurable: true, writable: true })`, then add a functional probe (setItem/getItem roundtrip) so a silent no-op is impossible. Verify success by running `npm test` and confirming BOTH the ExperimentalWarning lines AND all "storage is currently unavailable" messages are gone from output.
|
||||
|
||||
### Step 6 — Clean up stray artifacts
|
||||
|
||||
- **Delete `vite.config.js` and `vite.config.d.ts`.** Verified: `vite.config.js` is a tsc-emitted duplicate of `vite.config.ts` (identical config values, 4-space indent). Vite resolves `.js` before `.ts`, so the stale file can shadow the real one. The real config is `vite.config.ts`; keep it.
|
||||
- **`.gitignore`**: add `*.tsbuildinfo` (3 stray files at repo root: tsconfig.app/node/tests.tsbuildinfo) and `src-tauri/gen/` (tauri-build generated). Keep `src-tauri/Cargo.lock` tracked — this is an application, lockfile should be committed.
|
||||
|
||||
### Step 7 — Full verification (all four must be green)
|
||||
|
||||
```powershell
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm test
|
||||
cargo check --manifest-path src-tauri/Cargo.toml
|
||||
```
|
||||
|
||||
Plus the mock-demo smoke path still works: `python speech-engine/server.py --mock` + `npm run desktop` (or at minimum `npm run dev`). AGENTS.md §15: do not claim completion without stating which checks actually ran.
|
||||
|
||||
### Step 8 — Documentation
|
||||
|
||||
- **Create `docs/SMOKE_TEST.md`**: the manual Tauri acceptance walkthrough from AGENTS.md §7 as a checklist (mock mode first; mic permission, levels, transcript segments, speaker rename main↔overlay, pause/resume, finish → Meeting Details playback + transcript, exact placeholder text on backend buttons, restart persistence).
|
||||
- **Update `README.md`**: add the new commands (`typecheck`, `lint`, `test`) and normalize the Python version note to 3.12 (README still says 3.10/3.11 — documented drift per AGENTS.md §6.1; the working venv is 3.12).
|
||||
- **Update `AGENTS.md`** (at workspace root `D:\workspace\MeetVault\AGENTS.md`, tracked in git): §6.6 verification commands now include typecheck/lint/test; repo map gains `tests/`, `src/test/`, `eslint.config.js`, `vitest.config.ts`, `tsconfig.tests.json`; note the placeholder icon situation if relevant.
|
||||
- **Delete this HANDOFF.md** (or replace with a one-line "Phase 0 complete" note).
|
||||
|
||||
---
|
||||
|
||||
## 4. Verified ground truth & environment notes
|
||||
|
||||
### 4.1 Mock speech protocol (`speech-engine/server.py`)
|
||||
|
||||
- `/health` → `{ok: true, mode: "mock", sample_rate: 16000}` (real mode reports `"real-capable"`).
|
||||
- WS endpoint `ws://127.0.0.1:<port>/ws/live`; default port 8765; test picks a free port via net server on port 0.
|
||||
- Flow: accept → first text frame is JSON config (`model`, `compute`, `language`, `diarization`, `sample_rate`) → `ready` event. Mock ready payload: `{"type":"ready","engine":"mock-whisper","diarization":"mock-diart"}` (real mode: engine `"Whisper {model}"`).
|
||||
- Mock emits one **final** segment per 38400 samples (2.4 s of 16 kHz int16); speaker cycle `speaker_1 → speaker_2 → speaker_1 → speaker_3`. Test streams 5 s of silence in 3200-byte chunks and expects finals at ~[0.3–2.4] and ~[2.7–4.8].
|
||||
- Client sends `{"type":"stop"}` to end; handler has no explicit close (see Step 2).
|
||||
|
||||
### 4.2 Toolchain / environment
|
||||
|
||||
- Node **v26.10.0**: global `WebSocket` + `fetch` available natively (used by the integration test — no extra deps).
|
||||
- MSVC **14.51.36231** at `C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools` (VS BuildTools install from this session). cargo check works with it.
|
||||
- `.venv`: Python 3.12, fastapi 0.141.1, starlette 1.7.0, uvicorn 0.54.0, pydantic 2.13.5.
|
||||
- **PowerShell quirks** (this shell is Windows PowerShell 5.x): no `&&` separator — use separate commands or `;`; call operator `& "path"` for paths with spaces; run vitest via `node_modules\.bin\vitest.cmd`; esbuild binary lives at `node_modules\@esbuild\win32-x64\esbuild.exe`, NOT in `.bin`; piping stderr (`*>&1`) produces cosmetic NativeCommandError noise — the command still ran fine.
|
||||
- **ESLint 10**: flat config only; recommended set includes `preserve-caught-error` (rethrows must attach `{ cause }`).
|
||||
|
||||
### 4.3 Known test-output noise (to be fixed in Step 5)
|
||||
|
||||
Unit tests pass but emit, per worker: `(node:...) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided.` and one `[zustand persist middleware] Unable to update item 'meetvault-demo-store-v1', the given storage is currently unavailable.` line per state update. Both should disappear once the setup.ts stub actually takes effect.
|
||||
|
||||
### 4.4 Working-tree changes that predate or accompany this session (keep, don't revert)
|
||||
|
||||
- `src-tauri/Cargo.toml`: tauri feature list changed to `["protocol-asset"]` (from earlier work; needed for asset protocol).
|
||||
- Workspace-root `AGENTS.md` (`D:\workspace\MeetVault\AGENTS.md`): rewritten agent guide (82 → 944 lines); it is the current operating guide.
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraints (from AGENTS.md / PROJECT_SCOPE.md — do not violate)
|
||||
|
||||
- Keep mock-mode desktop operation working at every checkpoint; no half-finished architecture states.
|
||||
- Placeholder text must remain exactly: `Processing (The job). Please wait a moment`
|
||||
- `tsconfig.app.json` has `strict: true` — never weaken TypeScript strictness to silence an error.
|
||||
- No new **runtime** dependencies (test devDeps are fine and already installed).
|
||||
- Do not invent backend endpoints, credentials, auth behavior, or billing rules; keep `src/lib/backend.ts` a placeholder.
|
||||
- Do not edit generated/dependency dirs: `node_modules/`, `speech-engine/.venv/`, `dist/`, `src-tauri/target/`.
|
||||
- **No commits** — all changes stay uncommitted unless the user asks otherwise.
|
||||
|
||||
## 6. Git snapshot (as of handoff, branch `main`)
|
||||
|
||||
Modified: `AGENTS.md` (workspace root), `package.json`, `package-lock.json`, `src-tauri/Cargo.toml`, `tsconfig.json` (+tests project reference), `tsconfig.node.json` (+noEmit)
|
||||
Untracked: `eslint.config.js`, `vitest.config.ts`, `tsconfig.tests.json`, `src/test/`, `tests/`, `src/store/useAppStore.test.ts`, `HANDOFF.md` (this file), stray `vite.config.js` + `vite.config.d.ts` (delete in Step 6), `*.tsbuildinfo` ×3, `src-tauri/Cargo.lock` (keep tracked), `src-tauri/gen/` (gitignore)
|
||||
11
Wireframes/Desktop/MeetVault-Desktop-Demo/eslint.config.js
Normal file
11
Wireframes/Desktop/MeetVault-Desktop-Demo/eslint.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const tsFiles = ['**/*.{ts,tsx}'];
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/', 'node_modules/', 'src-tauri/target/', 'speech-engine/.venv/'] },
|
||||
{ files: tsFiles, ...js.configs.recommended },
|
||||
// typescript-eslint entries come last so their overrides of core rules win.
|
||||
...tseslint.configs.recommended.map((entry) => ({ files: tsFiles, ...entry })),
|
||||
];
|
||||
1759
Wireframes/Desktop/MeetVault-Desktop-Demo/package-lock.json
generated
1759
Wireframes/Desktop/MeetVault-Desktop-Demo/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,11 @@
|
||||
"tauri": "tauri",
|
||||
"desktop": "tauri dev",
|
||||
"speech": "python speech-engine/server.py",
|
||||
"speech:mock": "python speech-engine/server.py --mock"
|
||||
"speech:mock": "python speech-engine/server.py --mock",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
@@ -23,11 +27,17 @@
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5"
|
||||
"typescript-eslint": "^8.71.0",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
4548
Wireframes/Desktop/MeetVault-Desktop-Demo/src-tauri/Cargo.lock
generated
Normal file
4548
Wireframes/Desktop/MeetVault-Desktop-Demo/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TranscriptSegment } from '../types';
|
||||
import { useAppStore } from './useAppStore';
|
||||
|
||||
function makeSegment(overrides: Partial<TranscriptSegment> = {}): TranscriptSegment {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
speakerId: 'speaker_1',
|
||||
start: 0,
|
||||
end: 2.4,
|
||||
text: 'Hello world.',
|
||||
final: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getMeeting(id: string) {
|
||||
const meeting = useAppStore.getState().meetings.find((m) => m.id === id);
|
||||
if (!meeting) throw new Error(`meeting ${id} not found`);
|
||||
return meeting;
|
||||
}
|
||||
|
||||
describe('useAppStore', () => {
|
||||
it('createMeeting prepends a recording meeting and activates it', () => {
|
||||
const before = useAppStore.getState().meetings.length;
|
||||
const id = useAppStore.getState().createMeeting('Test Meeting');
|
||||
|
||||
const state = useAppStore.getState();
|
||||
expect(id).toBeTruthy();
|
||||
expect(state.activeMeetingId).toBe(id);
|
||||
expect(state.meetings.length).toBe(before + 1);
|
||||
expect(state.meetings[0].id).toBe(id);
|
||||
|
||||
const meeting = getMeeting(id);
|
||||
expect(meeting.title).toBe('Test Meeting');
|
||||
expect(meeting.status).toBe('recording');
|
||||
expect(meeting.durationSeconds).toBe(0);
|
||||
expect(meeting.transcript).toEqual([]);
|
||||
expect(meeting.speakerNames).toEqual({});
|
||||
expect(meeting.localOnly).toBe(true);
|
||||
expect(Number.isNaN(Date.parse(meeting.startedAt))).toBe(false);
|
||||
});
|
||||
|
||||
it('appendSegment adds segments to the right meeting only', () => {
|
||||
const id = useAppStore.getState().createMeeting();
|
||||
const otherId = useAppStore.getState().createMeeting();
|
||||
const segment = makeSegment({ text: 'First line.' });
|
||||
|
||||
useAppStore.getState().appendSegment(id, segment);
|
||||
|
||||
expect(getMeeting(id).transcript).toEqual([segment]);
|
||||
expect(getMeeting(otherId).transcript).toEqual([]);
|
||||
});
|
||||
|
||||
it('appendSegment replaces an existing segment with the same id', () => {
|
||||
const id = useAppStore.getState().createMeeting();
|
||||
const original = makeSegment({ text: 'partial text' });
|
||||
useAppStore.getState().appendSegment(id, original);
|
||||
|
||||
const updated = makeSegment({ ...original, text: 'final text', final: true });
|
||||
useAppStore.getState().appendSegment(id, updated);
|
||||
|
||||
expect(getMeeting(id).transcript).toEqual([updated]);
|
||||
});
|
||||
|
||||
it('renameSpeaker stores the trimmed name and falls back to the speaker id for blank names', () => {
|
||||
const id = useAppStore.getState().createMeeting();
|
||||
|
||||
useAppStore.getState().renameSpeaker(id, 'speaker_1', ' Alice ');
|
||||
expect(getMeeting(id).speakerNames.speaker_1).toBe('Alice');
|
||||
|
||||
useAppStore.getState().renameSpeaker(id, 'speaker_2', ' ');
|
||||
expect(getMeeting(id).speakerNames.speaker_2).toBe('speaker_2');
|
||||
});
|
||||
|
||||
it('finishMeeting commits endedAt, duration, processing status and media info', () => {
|
||||
const id = useAppStore.getState().createMeeting();
|
||||
const beforeFinish = Date.now();
|
||||
|
||||
useAppStore.getState().finishMeeting(id, 'meetings/test/recording.webm', 'video/webm');
|
||||
|
||||
const state = useAppStore.getState();
|
||||
const meeting = getMeeting(id);
|
||||
expect(meeting.status).toBe('processing');
|
||||
expect(meeting.endedAt).toBeTruthy();
|
||||
expect(Number.isNaN(Date.parse(meeting.endedAt!))).toBe(false);
|
||||
// A just-created meeting has ~0 elapsed time; the store clamps to at least 1 second.
|
||||
expect(meeting.durationSeconds).toBeGreaterThanOrEqual(1);
|
||||
expect(Date.parse(meeting.endedAt!) - beforeFinish).toBeLessThan(5_000);
|
||||
expect(meeting.mediaPath).toBe('meetings/test/recording.webm');
|
||||
expect(meeting.mediaMime).toBe('video/webm');
|
||||
expect(state.activeMeetingId).toBeUndefined();
|
||||
expect(state.processingMessage).toBe('Processing (The job). Please wait a moment');
|
||||
});
|
||||
|
||||
it('setSettings merges the patch without clobbering other keys', () => {
|
||||
const before = useAppStore.getState().settings;
|
||||
|
||||
useAppStore.getState().setSettings({ theme: 'dark', overlayLines: 6 });
|
||||
|
||||
const settings = useAppStore.getState().settings;
|
||||
expect(settings.theme).toBe('dark');
|
||||
expect(settings.overlayLines).toBe(6);
|
||||
for (const key of Object.keys(before) as Array<keyof typeof before>) {
|
||||
if (key !== 'theme' && key !== 'overlayLines') {
|
||||
expect(settings[key]).toBe(before[key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
38
Wireframes/Desktop/MeetVault-Desktop-Demo/src/test/setup.ts
Normal file
38
Wireframes/Desktop/MeetVault-Desktop-Demo/src/test/setup.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Vitest setup: provide a minimal in-memory localStorage so zustand/persist
|
||||
// works under the Node test environment (no DOM available).
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
get length(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.has(key) ? (this.store.get(key) as string) : null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.store.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.localStorage === 'undefined') {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Integration test for the mock speech-engine WebSocket protocol.
|
||||
// Spawns `speech-engine/server.py --mock` from the local .venv on a free port
|
||||
// and drives it with Node's native WebSocket client. Skipped when the venv is missing.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
interface SpeechEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SegmentPayload {
|
||||
id: string;
|
||||
speakerId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
final: boolean;
|
||||
}
|
||||
|
||||
function venvPython(): string | null {
|
||||
const candidates =
|
||||
process.platform === 'win32'
|
||||
? [path.join(repoRoot, 'speech-engine', '.venv', 'Scripts', 'python.exe')]
|
||||
: [path.join(repoRoot, 'speech-engine', '.venv', 'bin', 'python')];
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Could not determine a free port'));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Server not up yet.
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error(`Speech engine did not become healthy at ${url}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
function openSocket(url: string): Promise<WebSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(url);
|
||||
socket.onopen = () => resolve(socket);
|
||||
socket.onerror = (event) => reject(new Error(`WebSocket error: ${String(event.message ?? event.type)}`));
|
||||
});
|
||||
}
|
||||
|
||||
function nextEvent(socket: WebSocket, timeoutMs: number): Promise<SpeechEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timed out waiting for a speech engine event')), timeoutMs);
|
||||
socket.onmessage = (event) => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
resolve(JSON.parse(String(event.data)) as SpeechEvent);
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error('Non-JSON message from speech engine'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function waitForClose(socket: WebSocket, timeoutMs: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timed out waiting for the socket to close')), timeoutMs);
|
||||
socket.onclose = (event) => {
|
||||
clearTimeout(timer);
|
||||
resolve(event.code);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const python = venvPython();
|
||||
|
||||
describe.skipIf(python === null)(
|
||||
'mock speech protocol (server.py --mock)',
|
||||
() => {
|
||||
let server: ChildProcessWithoutNullStreams | undefined;
|
||||
let port = 0;
|
||||
const stderr: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!python) return;
|
||||
port = await findFreePort();
|
||||
server = spawn(python, [path.join(repoRoot, 'speech-engine', 'server.py'), '--mock', '--port', String(port)], {
|
||||
cwd: repoRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
server.stdout.on('data', () => undefined);
|
||||
server.stderr.on('data', (chunk) => stderr.push(String(chunk)));
|
||||
try {
|
||||
await waitForHealth(`http://127.0.0.1:${port}/health`, 30_000);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Speech engine did not start: ${error instanceof Error ? error.message : String(error)}\n--- stderr ---\n${stderr.join('')}`,
|
||||
);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
afterAll(() => {
|
||||
server?.kill();
|
||||
});
|
||||
|
||||
const validConfig = JSON.stringify({
|
||||
model: 'small',
|
||||
compute: 'auto',
|
||||
language: 'auto',
|
||||
diarization: true,
|
||||
sample_rate: 16000,
|
||||
});
|
||||
|
||||
it('reports mock mode on /health', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
expect(response.ok).toBe(true);
|
||||
const body = (await response.json()) as { ok: boolean; mode: string; sample_rate: number };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.mode).toBe('mock');
|
||||
expect(body.sample_rate).toBe(16000);
|
||||
});
|
||||
|
||||
it('completes the ready handshake for a valid config', async () => {
|
||||
const socket = await openSocket(`ws://127.0.0.1:${port}/ws/live`);
|
||||
socket.send(validConfig);
|
||||
|
||||
const ready = await nextEvent(socket, 10_000);
|
||||
expect(ready.type).toBe('ready');
|
||||
expect(String(ready.engine)).toContain('mock');
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it('emits final segments with cycling speakers for streamed PCM', async () => {
|
||||
const socket = await openSocket(`ws://127.0.0.1:${port}/ws/live`);
|
||||
socket.send(validConfig);
|
||||
expect((await nextEvent(socket, 10_000)).type).toBe('ready');
|
||||
|
||||
// Stream 5 seconds of silence (16 kHz mono int16) in 100 ms chunks; the mock
|
||||
// emits one final segment per 2.4 s of samples with cycling speaker labels.
|
||||
const totalBytes = 16000 * 5 * 2;
|
||||
const chunkSize = 3200;
|
||||
for (let offset = 0; offset < totalBytes; offset += chunkSize) {
|
||||
socket.send(new ArrayBuffer(chunkSize));
|
||||
}
|
||||
|
||||
const finals: SpeechEvent[] = [];
|
||||
while (finals.length < 2) {
|
||||
const event = await nextEvent(socket, 15_000);
|
||||
if (event.type === 'error') throw new Error(String(event.message));
|
||||
if (event.type === 'final') finals.push(event);
|
||||
}
|
||||
|
||||
expect(finals).toHaveLength(2);
|
||||
const first = finals[0].segment as SegmentPayload;
|
||||
const second = finals[1].segment as SegmentPayload;
|
||||
|
||||
for (const segment of [first, second]) {
|
||||
expect(segment.id).toBeTruthy();
|
||||
expect(segment.text.length).toBeGreaterThan(0);
|
||||
expect(segment.final).toBe(true);
|
||||
}
|
||||
expect(first.speakerId).toBe('speaker_1');
|
||||
expect(second.speakerId).toBe('speaker_2');
|
||||
expect(first.start).toBeCloseTo(0.3, 1);
|
||||
expect(first.end).toBeCloseTo(2.4, 1);
|
||||
expect(second.start).toBeCloseTo(2.7, 1);
|
||||
expect(second.end).toBeCloseTo(4.8, 1);
|
||||
expect(second.start).toBeGreaterThan(first.end);
|
||||
|
||||
socket.send(JSON.stringify({ type: 'stop' }));
|
||||
const code = await waitForClose(socket, 5_000);
|
||||
expect(code).toBe(1000);
|
||||
});
|
||||
|
||||
it('rejects unsupported sample rates', async () => {
|
||||
const socket = await openSocket(`ws://127.0.0.1:${port}/ws/live`);
|
||||
socket.send(
|
||||
JSON.stringify({ model: 'small', compute: 'auto', language: 'auto', diarization: true, sample_rate: 44100 }),
|
||||
);
|
||||
|
||||
const event = await nextEvent(socket, 10_000);
|
||||
expect(event.type).toBe('error');
|
||||
expect(String(event.message)).toContain('16 kHz');
|
||||
|
||||
await waitForClose(socket, 5_000);
|
||||
});
|
||||
|
||||
it('reports malformed config as an error event', async () => {
|
||||
const socket = await openSocket(`ws://127.0.0.1:${port}/ws/live`);
|
||||
socket.send('this is not json');
|
||||
|
||||
const event = await nextEvent(socket, 10_000);
|
||||
expect(event.type).toBe('error');
|
||||
expect(String(event.message)).not.toBe('');
|
||||
|
||||
await waitForClose(socket, 5_000);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,6 +2,7 @@
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.tests.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["tests/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
11
Wireframes/Desktop/MeetVault-Desktop-Demo/vitest.config.ts
Normal file
11
Wireframes/Desktop/MeetVault-Desktop-Demo/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'],
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user