@hasna/recordings 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/scheduled_tasks.lock +1 -0
- package/LICENSE +21 -0
- package/dist/cli/index.js +1446 -0
- package/dist/mcp/index.js +4882 -0
- package/package.json +46 -0
- package/src/__tests__/agents.test.ts +136 -0
- package/src/__tests__/config.test.ts +252 -0
- package/src/__tests__/database.test.ts +167 -0
- package/src/__tests__/enhancer.test.ts +574 -0
- package/src/__tests__/preload.ts +4 -0
- package/src/__tests__/projects.test.ts +109 -0
- package/src/__tests__/recorder.test.ts +278 -0
- package/src/__tests__/recordings.test.ts +353 -0
- package/src/__tests__/transcriber.test.ts +322 -0
- package/src/__tests__/types.test.ts +75 -0
- package/src/cli/index.ts +1078 -0
- package/src/db/agents.ts +81 -0
- package/src/db/database.ts +126 -0
- package/src/db/projects.ts +71 -0
- package/src/db/recordings.ts +219 -0
- package/src/index.ts +81 -0
- package/src/lib/config.ts +166 -0
- package/src/lib/enhancer.ts +167 -0
- package/src/lib/recorder.ts +198 -0
- package/src/lib/transcriber.ts +105 -0
- package/src/mcp/index.ts +405 -0
- package/src/native/RecordingsHelper.swift +352 -0
- package/src/types/index.ts +138 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
5
|
+
import { DEFAULT_CONFIG } from "../lib/config.js";
|
|
6
|
+
import type { RecordingsConfig } from "../types/index.js";
|
|
7
|
+
import { RecordingError } from "../types/index.js";
|
|
8
|
+
import { EventEmitter } from "events";
|
|
9
|
+
|
|
10
|
+
let tempDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tempDir = join(tmpdir(), `open-recordings-test-rec-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
14
|
+
mkdirSync(tempDir, { recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (existsSync(tempDir)) {
|
|
19
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("checkRecordingDeps", () => {
|
|
24
|
+
test("returns an object with available, tool, and message fields", async () => {
|
|
25
|
+
const { checkRecordingDeps } = await import("../lib/recorder.js");
|
|
26
|
+
const result = await checkRecordingDeps();
|
|
27
|
+
expect(result).toHaveProperty("available");
|
|
28
|
+
expect(result).toHaveProperty("tool");
|
|
29
|
+
expect(result).toHaveProperty("message");
|
|
30
|
+
expect(typeof result.available).toBe("boolean");
|
|
31
|
+
expect(typeof result.tool).toBe("string");
|
|
32
|
+
expect(typeof result.message).toBe("string");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("detects at least one recording tool on this system or returns none", async () => {
|
|
36
|
+
const { checkRecordingDeps } = await import("../lib/recorder.js");
|
|
37
|
+
const result = await checkRecordingDeps();
|
|
38
|
+
if (result.available) {
|
|
39
|
+
expect(["sox", "rec", "ffmpeg"]).toContain(result.tool);
|
|
40
|
+
expect(result.message).toContain("is available");
|
|
41
|
+
} else {
|
|
42
|
+
expect(result.tool).toBe("none");
|
|
43
|
+
expect(result.message).toContain("No recording tool found");
|
|
44
|
+
expect(result.message).toContain("Install sox");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("isRecording", () => {
|
|
50
|
+
test("returns false when not recording", async () => {
|
|
51
|
+
const { isRecording } = await import("../lib/recorder.js");
|
|
52
|
+
expect(isRecording()).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("getCurrentFile", () => {
|
|
57
|
+
test("returns null when not recording", async () => {
|
|
58
|
+
const { getCurrentFile } = await import("../lib/recorder.js");
|
|
59
|
+
expect(getCurrentFile()).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("stopRecording", () => {
|
|
64
|
+
test("returns null when not recording", async () => {
|
|
65
|
+
const { stopRecording } = await import("../lib/recorder.js");
|
|
66
|
+
const result = stopRecording();
|
|
67
|
+
expect(result).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("startRecording with mocked spawn", () => {
|
|
72
|
+
test("spawns rec process and returns filepath", async () => {
|
|
73
|
+
// Create a mock child process
|
|
74
|
+
const mockProcess = new EventEmitter() as any;
|
|
75
|
+
mockProcess.kill = mock(() => {});
|
|
76
|
+
mockProcess.stdin = null;
|
|
77
|
+
mockProcess.stdout = null;
|
|
78
|
+
mockProcess.stderr = null;
|
|
79
|
+
|
|
80
|
+
mock.module("child_process", () => ({
|
|
81
|
+
spawn: mock(() => mockProcess),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
// Re-import to pick up mock
|
|
85
|
+
const recorder = await import("../lib/recorder.js");
|
|
86
|
+
|
|
87
|
+
// Reset any existing recording state by stopping
|
|
88
|
+
recorder.stopRecording();
|
|
89
|
+
|
|
90
|
+
const config: RecordingsConfig = {
|
|
91
|
+
...DEFAULT_CONFIG,
|
|
92
|
+
audio_dir: tempDir,
|
|
93
|
+
audio_format: "wav",
|
|
94
|
+
sample_rate: 16000,
|
|
95
|
+
max_recording_seconds: 300,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const filepath = recorder.startRecording(config);
|
|
99
|
+
expect(filepath).toContain(tempDir);
|
|
100
|
+
expect(filepath).toContain("recording-");
|
|
101
|
+
expect(filepath).toContain(".wav");
|
|
102
|
+
expect(recorder.isRecording()).toBe(true);
|
|
103
|
+
expect(recorder.getCurrentFile()).toBe(filepath);
|
|
104
|
+
|
|
105
|
+
// Stop
|
|
106
|
+
const stopped = recorder.stopRecording();
|
|
107
|
+
expect(stopped).toBe(filepath);
|
|
108
|
+
expect(recorder.isRecording()).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("throws RecordingError when already recording", async () => {
|
|
112
|
+
const mockProcess = new EventEmitter() as any;
|
|
113
|
+
mockProcess.kill = mock(() => {});
|
|
114
|
+
mockProcess.stdin = null;
|
|
115
|
+
mockProcess.stdout = null;
|
|
116
|
+
mockProcess.stderr = null;
|
|
117
|
+
|
|
118
|
+
mock.module("child_process", () => ({
|
|
119
|
+
spawn: mock(() => mockProcess),
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const recorder = await import("../lib/recorder.js");
|
|
123
|
+
recorder.stopRecording(); // Clean state
|
|
124
|
+
|
|
125
|
+
const config: RecordingsConfig = {
|
|
126
|
+
...DEFAULT_CONFIG,
|
|
127
|
+
audio_dir: tempDir,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
recorder.startRecording(config);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
recorder.startRecording(config);
|
|
134
|
+
expect(true).toBe(false);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
expect(err).toBeInstanceOf(RecordingError);
|
|
137
|
+
expect((err as Error).message).toContain("Already recording");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
recorder.stopRecording();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("generates filename with mp3 format", async () => {
|
|
144
|
+
const mockProcess = new EventEmitter() as any;
|
|
145
|
+
mockProcess.kill = mock(() => {});
|
|
146
|
+
mockProcess.stdin = null;
|
|
147
|
+
mockProcess.stdout = null;
|
|
148
|
+
mockProcess.stderr = null;
|
|
149
|
+
|
|
150
|
+
mock.module("child_process", () => ({
|
|
151
|
+
spawn: mock(() => mockProcess),
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
const recorder = await import("../lib/recorder.js");
|
|
155
|
+
recorder.stopRecording();
|
|
156
|
+
|
|
157
|
+
const config: RecordingsConfig = {
|
|
158
|
+
...DEFAULT_CONFIG,
|
|
159
|
+
audio_dir: tempDir,
|
|
160
|
+
audio_format: "mp3",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const filepath = recorder.startRecording(config);
|
|
164
|
+
expect(filepath).toContain(".mp3");
|
|
165
|
+
|
|
166
|
+
recorder.stopRecording();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("handles process exit event", async () => {
|
|
170
|
+
const mockProcess = new EventEmitter() as any;
|
|
171
|
+
mockProcess.kill = mock(() => {});
|
|
172
|
+
mockProcess.stdin = null;
|
|
173
|
+
mockProcess.stdout = null;
|
|
174
|
+
mockProcess.stderr = null;
|
|
175
|
+
|
|
176
|
+
mock.module("child_process", () => ({
|
|
177
|
+
spawn: mock(() => mockProcess),
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
const recorder = await import("../lib/recorder.js");
|
|
181
|
+
recorder.stopRecording();
|
|
182
|
+
|
|
183
|
+
const config: RecordingsConfig = {
|
|
184
|
+
...DEFAULT_CONFIG,
|
|
185
|
+
audio_dir: tempDir,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
recorder.startRecording(config);
|
|
189
|
+
expect(recorder.isRecording()).toBe(true);
|
|
190
|
+
|
|
191
|
+
// Simulate process exit
|
|
192
|
+
mockProcess.emit("exit", 0);
|
|
193
|
+
|
|
194
|
+
// After exit, _recordProcess should be null but _currentFile may still be set
|
|
195
|
+
// until stopRecording is called
|
|
196
|
+
recorder.stopRecording();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("handles process error event by clearing state", async () => {
|
|
200
|
+
const mockProcess = new EventEmitter() as any;
|
|
201
|
+
mockProcess.kill = mock(() => {});
|
|
202
|
+
mockProcess.stdin = null;
|
|
203
|
+
mockProcess.stdout = null;
|
|
204
|
+
mockProcess.stderr = null;
|
|
205
|
+
|
|
206
|
+
mock.module("child_process", () => ({
|
|
207
|
+
spawn: mock(() => mockProcess),
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
const recorder = await import("../lib/recorder.js");
|
|
211
|
+
recorder.stopRecording();
|
|
212
|
+
|
|
213
|
+
const config: RecordingsConfig = {
|
|
214
|
+
...DEFAULT_CONFIG,
|
|
215
|
+
audio_dir: tempDir,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
recorder.startRecording(config);
|
|
219
|
+
expect(recorder.isRecording()).toBe(true);
|
|
220
|
+
|
|
221
|
+
// Simulate process error - the error handler throws, but we catch it
|
|
222
|
+
try {
|
|
223
|
+
mockProcess.emit("error", new Error("spawn ENOENT"));
|
|
224
|
+
} catch {
|
|
225
|
+
// The error handler throws a RecordingError
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// After error, state should be cleared
|
|
229
|
+
// Note: the error handler sets _recordProcess = null and _currentFile = null
|
|
230
|
+
// but since it throws, the state cleanup depends on the throw being caught
|
|
231
|
+
recorder.stopRecording(); // Cleanup
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("stopRecording sends SIGINT to process", async () => {
|
|
235
|
+
const mockProcess = new EventEmitter() as any;
|
|
236
|
+
mockProcess.kill = mock(() => {});
|
|
237
|
+
mockProcess.stdin = null;
|
|
238
|
+
mockProcess.stdout = null;
|
|
239
|
+
mockProcess.stderr = null;
|
|
240
|
+
|
|
241
|
+
mock.module("child_process", () => ({
|
|
242
|
+
spawn: mock(() => mockProcess),
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
const recorder = await import("../lib/recorder.js");
|
|
246
|
+
recorder.stopRecording();
|
|
247
|
+
|
|
248
|
+
const config: RecordingsConfig = {
|
|
249
|
+
...DEFAULT_CONFIG,
|
|
250
|
+
audio_dir: tempDir,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
recorder.startRecording(config);
|
|
254
|
+
recorder.stopRecording();
|
|
255
|
+
|
|
256
|
+
expect(mockProcess.kill).toHaveBeenCalledWith("SIGINT");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("recordDuration", () => {
|
|
261
|
+
test("throws when rec is not available", async () => {
|
|
262
|
+
const { recordDuration } = await import("../lib/recorder.js");
|
|
263
|
+
|
|
264
|
+
const config: RecordingsConfig = {
|
|
265
|
+
...DEFAULT_CONFIG,
|
|
266
|
+
audio_dir: tempDir,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await recordDuration(1, config);
|
|
271
|
+
// If rec is installed, this might succeed
|
|
272
|
+
} catch (err) {
|
|
273
|
+
// Expected to fail since rec isn't typically installed in CI
|
|
274
|
+
// Could be ENOENT from Bun.spawn or RecordingError
|
|
275
|
+
expect(err).toBeDefined();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
5
|
+
import {
|
|
6
|
+
getDatabase,
|
|
7
|
+
closeDatabase,
|
|
8
|
+
resetDatabase,
|
|
9
|
+
} from "../db/database.js";
|
|
10
|
+
import {
|
|
11
|
+
createRecording,
|
|
12
|
+
getRecording,
|
|
13
|
+
listRecordings,
|
|
14
|
+
deleteRecording,
|
|
15
|
+
searchRecordings,
|
|
16
|
+
getRecordingStats,
|
|
17
|
+
} from "../db/recordings.js";
|
|
18
|
+
import type { Recording } from "../types/index.js";
|
|
19
|
+
import { type Database } from "bun:sqlite";
|
|
20
|
+
|
|
21
|
+
let tempDir: string;
|
|
22
|
+
let db: Database;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
resetDatabase();
|
|
26
|
+
tempDir = join(tmpdir(), `open-recordings-test-rec-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
27
|
+
mkdirSync(tempDir, { recursive: true });
|
|
28
|
+
const dbPath = join(tempDir, "test.db");
|
|
29
|
+
db = getDatabase(dbPath);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
closeDatabase();
|
|
34
|
+
resetDatabase();
|
|
35
|
+
if (existsSync(tempDir)) {
|
|
36
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("createRecording", () => {
|
|
41
|
+
test("creates a recording with minimal input", () => {
|
|
42
|
+
const rec = createRecording({ raw_text: "hello world" }, db);
|
|
43
|
+
expect(rec).toBeDefined();
|
|
44
|
+
expect(rec.id).toBeDefined();
|
|
45
|
+
expect(rec.raw_text).toBe("hello world");
|
|
46
|
+
expect(rec.processing_mode).toBe("raw");
|
|
47
|
+
expect(rec.model_used).toBe("gpt-4o-mini-transcribe");
|
|
48
|
+
expect(rec.tags).toEqual([]);
|
|
49
|
+
expect(rec.metadata).toEqual({});
|
|
50
|
+
expect(rec.audio_path).toBeNull();
|
|
51
|
+
expect(rec.processed_text).toBeNull();
|
|
52
|
+
expect(rec.enhancement_model).toBeNull();
|
|
53
|
+
expect(rec.language).toBeNull();
|
|
54
|
+
expect(rec.agent_id).toBeNull();
|
|
55
|
+
expect(rec.project_id).toBeNull();
|
|
56
|
+
expect(rec.session_id).toBeNull();
|
|
57
|
+
expect(rec.duration_ms).toBe(0);
|
|
58
|
+
expect(rec.created_at).toBeDefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("creates a recording with all fields", () => {
|
|
62
|
+
const rec = createRecording(
|
|
63
|
+
{
|
|
64
|
+
audio_path: "/tmp/test.wav",
|
|
65
|
+
raw_text: "raw dictation",
|
|
66
|
+
processed_text: "polished text",
|
|
67
|
+
processing_mode: "enhanced",
|
|
68
|
+
model_used: "whisper-1",
|
|
69
|
+
enhancement_model: "gpt-4o",
|
|
70
|
+
duration_ms: 5000,
|
|
71
|
+
language: "en",
|
|
72
|
+
tags: ["meeting", "important"],
|
|
73
|
+
session_id: "sess-1",
|
|
74
|
+
metadata: { source: "cli" },
|
|
75
|
+
},
|
|
76
|
+
db
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(rec.audio_path).toBe("/tmp/test.wav");
|
|
80
|
+
expect(rec.raw_text).toBe("raw dictation");
|
|
81
|
+
expect(rec.processed_text).toBe("polished text");
|
|
82
|
+
expect(rec.processing_mode).toBe("enhanced");
|
|
83
|
+
expect(rec.model_used).toBe("whisper-1");
|
|
84
|
+
expect(rec.enhancement_model).toBe("gpt-4o");
|
|
85
|
+
expect(rec.duration_ms).toBe(5000);
|
|
86
|
+
expect(rec.language).toBe("en");
|
|
87
|
+
expect(rec.tags).toEqual(["meeting", "important"]);
|
|
88
|
+
expect(rec.session_id).toBe("sess-1");
|
|
89
|
+
expect(rec.metadata).toEqual({ source: "cli" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("inserts tags into recording_tags table", () => {
|
|
93
|
+
const rec = createRecording(
|
|
94
|
+
{ raw_text: "test", tags: ["alpha", "beta"] },
|
|
95
|
+
db
|
|
96
|
+
);
|
|
97
|
+
const tags = db
|
|
98
|
+
.query("SELECT tag FROM recording_tags WHERE recording_id = ? ORDER BY tag")
|
|
99
|
+
.all(rec.id) as { tag: string }[];
|
|
100
|
+
expect(tags.map((t) => t.tag)).toEqual(["alpha", "beta"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("handles empty tags array", () => {
|
|
104
|
+
const rec = createRecording({ raw_text: "test", tags: [] }, db);
|
|
105
|
+
const tags = db
|
|
106
|
+
.query("SELECT tag FROM recording_tags WHERE recording_id = ?")
|
|
107
|
+
.all(rec.id) as { tag: string }[];
|
|
108
|
+
expect(tags).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("getRecording", () => {
|
|
113
|
+
test("retrieves a recording by full ID", () => {
|
|
114
|
+
const created = createRecording({ raw_text: "find me" }, db);
|
|
115
|
+
const found = getRecording(created.id, db);
|
|
116
|
+
expect(found).toBeDefined();
|
|
117
|
+
expect(found!.id).toBe(created.id);
|
|
118
|
+
expect(found!.raw_text).toBe("find me");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("retrieves a recording by partial ID prefix", () => {
|
|
122
|
+
const created = createRecording({ raw_text: "partial find" }, db);
|
|
123
|
+
const prefix = created.id.substring(0, 8);
|
|
124
|
+
const found = getRecording(prefix, db);
|
|
125
|
+
expect(found).toBeDefined();
|
|
126
|
+
expect(found!.id).toBe(created.id);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("returns null for non-existent ID", () => {
|
|
130
|
+
const found = getRecording("nonexistent-id", db);
|
|
131
|
+
expect(found).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("listRecordings", () => {
|
|
136
|
+
test("returns all recordings ordered by created_at DESC", () => {
|
|
137
|
+
createRecording({ raw_text: "first" }, db);
|
|
138
|
+
createRecording({ raw_text: "second" }, db);
|
|
139
|
+
createRecording({ raw_text: "third" }, db);
|
|
140
|
+
|
|
141
|
+
const list = listRecordings(undefined, db);
|
|
142
|
+
expect(list).toHaveLength(3);
|
|
143
|
+
// Most recent first
|
|
144
|
+
expect(list[0]!.raw_text).toBe("third");
|
|
145
|
+
expect(list[2]!.raw_text).toBe("first");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("returns empty array when no recordings", () => {
|
|
149
|
+
const list = listRecordings(undefined, db);
|
|
150
|
+
expect(list).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("filters by processing_mode", () => {
|
|
154
|
+
createRecording({ raw_text: "raw one" }, db);
|
|
155
|
+
createRecording({ raw_text: "enhanced one", processing_mode: "enhanced" }, db);
|
|
156
|
+
|
|
157
|
+
const raw = listRecordings({ processing_mode: "raw" }, db);
|
|
158
|
+
expect(raw).toHaveLength(1);
|
|
159
|
+
expect(raw[0]!.raw_text).toBe("raw one");
|
|
160
|
+
|
|
161
|
+
const enhanced = listRecordings({ processing_mode: "enhanced" }, db);
|
|
162
|
+
expect(enhanced).toHaveLength(1);
|
|
163
|
+
expect(enhanced[0]!.raw_text).toBe("enhanced one");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("filters by session_id", () => {
|
|
167
|
+
createRecording({ raw_text: "sess1", session_id: "s1" }, db);
|
|
168
|
+
createRecording({ raw_text: "sess2", session_id: "s2" }, db);
|
|
169
|
+
|
|
170
|
+
const results = listRecordings({ session_id: "s1" }, db);
|
|
171
|
+
expect(results).toHaveLength(1);
|
|
172
|
+
expect(results[0]!.raw_text).toBe("sess1");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("filters by tags", () => {
|
|
176
|
+
createRecording({ raw_text: "tagged", tags: ["important", "meeting"] }, db);
|
|
177
|
+
createRecording({ raw_text: "other", tags: ["casual"] }, db);
|
|
178
|
+
|
|
179
|
+
const results = listRecordings({ tags: ["important"] }, db);
|
|
180
|
+
expect(results).toHaveLength(1);
|
|
181
|
+
expect(results[0]!.raw_text).toBe("tagged");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("filters by multiple tags (AND logic)", () => {
|
|
185
|
+
createRecording({ raw_text: "both", tags: ["a", "b"] }, db);
|
|
186
|
+
createRecording({ raw_text: "only-a", tags: ["a"] }, db);
|
|
187
|
+
|
|
188
|
+
const results = listRecordings({ tags: ["a", "b"] }, db);
|
|
189
|
+
expect(results).toHaveLength(1);
|
|
190
|
+
expect(results[0]!.raw_text).toBe("both");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("filters by search text", () => {
|
|
194
|
+
createRecording({ raw_text: "the quick brown fox" }, db);
|
|
195
|
+
createRecording({ raw_text: "lazy dog" }, db);
|
|
196
|
+
|
|
197
|
+
const results = listRecordings({ search: "brown fox" }, db);
|
|
198
|
+
expect(results).toHaveLength(1);
|
|
199
|
+
expect(results[0]!.raw_text).toBe("the quick brown fox");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("search matches processed_text", () => {
|
|
203
|
+
createRecording({ raw_text: "raw", processed_text: "polished golden text" }, db);
|
|
204
|
+
createRecording({ raw_text: "other" }, db);
|
|
205
|
+
|
|
206
|
+
const results = listRecordings({ search: "golden" }, db);
|
|
207
|
+
expect(results).toHaveLength(1);
|
|
208
|
+
expect(results[0]!.processed_text).toBe("polished golden text");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("filters by since date", () => {
|
|
212
|
+
const rec = createRecording({ raw_text: "recent" }, db);
|
|
213
|
+
const results = listRecordings({ since: "2000-01-01" }, db);
|
|
214
|
+
expect(results).toHaveLength(1);
|
|
215
|
+
|
|
216
|
+
const noResults = listRecordings({ since: "2099-01-01" }, db);
|
|
217
|
+
expect(noResults).toHaveLength(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("filters by until date", () => {
|
|
221
|
+
createRecording({ raw_text: "old" }, db);
|
|
222
|
+
const results = listRecordings({ until: "2099-01-01" }, db);
|
|
223
|
+
expect(results).toHaveLength(1);
|
|
224
|
+
|
|
225
|
+
const noResults = listRecordings({ until: "2000-01-01" }, db);
|
|
226
|
+
expect(noResults).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("respects limit", () => {
|
|
230
|
+
for (let i = 0; i < 10; i++) {
|
|
231
|
+
createRecording({ raw_text: `rec-${i}` }, db);
|
|
232
|
+
}
|
|
233
|
+
const results = listRecordings({ limit: 3 }, db);
|
|
234
|
+
expect(results).toHaveLength(3);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("respects offset", () => {
|
|
238
|
+
for (let i = 0; i < 5; i++) {
|
|
239
|
+
createRecording({ raw_text: `rec-${i}` }, db);
|
|
240
|
+
}
|
|
241
|
+
const results = listRecordings({ limit: 2, offset: 2 }, db);
|
|
242
|
+
expect(results).toHaveLength(2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("filters by agent_id", () => {
|
|
246
|
+
// Create an agent first
|
|
247
|
+
db.query("INSERT INTO agents (id, name, created_at, last_seen_at) VALUES (?, ?, ?, ?)").run(
|
|
248
|
+
"agent-1", "maximus", new Date().toISOString(), new Date().toISOString()
|
|
249
|
+
);
|
|
250
|
+
createRecording({ raw_text: "by agent", agent_id: "agent-1" }, db);
|
|
251
|
+
createRecording({ raw_text: "no agent" }, db);
|
|
252
|
+
|
|
253
|
+
const results = listRecordings({ agent_id: "agent-1" }, db);
|
|
254
|
+
expect(results).toHaveLength(1);
|
|
255
|
+
expect(results[0]!.raw_text).toBe("by agent");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("filters by project_id", () => {
|
|
259
|
+
db.query("INSERT INTO projects (id, name, path, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(
|
|
260
|
+
"proj-1", "my-project", "/tmp/proj", new Date().toISOString(), new Date().toISOString()
|
|
261
|
+
);
|
|
262
|
+
createRecording({ raw_text: "in project", project_id: "proj-1" }, db);
|
|
263
|
+
createRecording({ raw_text: "no project" }, db);
|
|
264
|
+
|
|
265
|
+
const results = listRecordings({ project_id: "proj-1" }, db);
|
|
266
|
+
expect(results).toHaveLength(1);
|
|
267
|
+
expect(results[0]!.raw_text).toBe("in project");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("default limit is 50", () => {
|
|
271
|
+
for (let i = 0; i < 60; i++) {
|
|
272
|
+
createRecording({ raw_text: `rec-${i}` }, db);
|
|
273
|
+
}
|
|
274
|
+
const results = listRecordings(undefined, db);
|
|
275
|
+
expect(results).toHaveLength(50);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("deleteRecording", () => {
|
|
280
|
+
test("deletes an existing recording and returns true", () => {
|
|
281
|
+
const rec = createRecording({ raw_text: "delete me" }, db);
|
|
282
|
+
const result = deleteRecording(rec.id, db);
|
|
283
|
+
expect(result).toBe(true);
|
|
284
|
+
expect(getRecording(rec.id, db)).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("returns false for non-existent recording", () => {
|
|
288
|
+
const result = deleteRecording("nonexistent", db);
|
|
289
|
+
expect(result).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("cascades tag deletion", () => {
|
|
293
|
+
const rec = createRecording({ raw_text: "tagged", tags: ["a", "b"] }, db);
|
|
294
|
+
deleteRecording(rec.id, db);
|
|
295
|
+
const tags = db
|
|
296
|
+
.query("SELECT * FROM recording_tags WHERE recording_id = ?")
|
|
297
|
+
.all(rec.id) as unknown[];
|
|
298
|
+
expect(tags).toHaveLength(0);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("searchRecordings", () => {
|
|
303
|
+
test("delegates to listRecordings with search filter", () => {
|
|
304
|
+
createRecording({ raw_text: "searchable content here" }, db);
|
|
305
|
+
createRecording({ raw_text: "nothing special" }, db);
|
|
306
|
+
|
|
307
|
+
const results = searchRecordings("searchable", undefined, db);
|
|
308
|
+
expect(results).toHaveLength(1);
|
|
309
|
+
expect(results[0]!.raw_text).toBe("searchable content here");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("combines search with other filters", () => {
|
|
313
|
+
createRecording({ raw_text: "meeting notes alpha", session_id: "s1" }, db);
|
|
314
|
+
createRecording({ raw_text: "meeting notes beta", session_id: "s2" }, db);
|
|
315
|
+
|
|
316
|
+
const results = searchRecordings("meeting", { session_id: "s1" }, db);
|
|
317
|
+
expect(results).toHaveLength(1);
|
|
318
|
+
expect(results[0]!.raw_text).toBe("meeting notes alpha");
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("getRecordingStats", () => {
|
|
323
|
+
test("returns zero stats when empty", () => {
|
|
324
|
+
const stats = getRecordingStats(db);
|
|
325
|
+
expect(stats.total).toBe(0);
|
|
326
|
+
expect(stats.raw).toBe(0);
|
|
327
|
+
expect(stats.enhanced).toBe(0);
|
|
328
|
+
expect(stats.total_duration_ms).toBe(0);
|
|
329
|
+
expect(stats.by_model).toEqual({});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("counts totals correctly", () => {
|
|
333
|
+
createRecording({ raw_text: "one", duration_ms: 1000 }, db);
|
|
334
|
+
createRecording({ raw_text: "two", duration_ms: 2000, processing_mode: "enhanced" }, db);
|
|
335
|
+
createRecording({ raw_text: "three", duration_ms: 3000 }, db);
|
|
336
|
+
|
|
337
|
+
const stats = getRecordingStats(db);
|
|
338
|
+
expect(stats.total).toBe(3);
|
|
339
|
+
expect(stats.raw).toBe(2);
|
|
340
|
+
expect(stats.enhanced).toBe(1);
|
|
341
|
+
expect(stats.total_duration_ms).toBe(6000);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("groups by model correctly", () => {
|
|
345
|
+
createRecording({ raw_text: "a", model_used: "whisper-1" }, db);
|
|
346
|
+
createRecording({ raw_text: "b", model_used: "whisper-1" }, db);
|
|
347
|
+
createRecording({ raw_text: "c", model_used: "gpt-4o-mini-transcribe" }, db);
|
|
348
|
+
|
|
349
|
+
const stats = getRecordingStats(db);
|
|
350
|
+
expect(stats.by_model["whisper-1"]).toBe(2);
|
|
351
|
+
expect(stats.by_model["gpt-4o-mini-transcribe"]).toBe(1);
|
|
352
|
+
});
|
|
353
|
+
});
|