@filmwhisper/mcp-server 0.1.0
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/LICENSE +21 -0
- package/dist/__tests__/data-integrity.test.d.ts +2 -0
- package/dist/__tests__/data-integrity.test.d.ts.map +1 -0
- package/dist/__tests__/data-integrity.test.js +171 -0
- package/dist/__tests__/data-integrity.test.js.map +1 -0
- package/dist/__tests__/engine/ipc-contract.test.d.ts +2 -0
- package/dist/__tests__/engine/ipc-contract.test.d.ts.map +1 -0
- package/dist/__tests__/engine/ipc-contract.test.js +122 -0
- package/dist/__tests__/engine/ipc-contract.test.js.map +1 -0
- package/dist/__tests__/fw-project-init.test.d.ts +2 -0
- package/dist/__tests__/fw-project-init.test.d.ts.map +1 -0
- package/dist/__tests__/fw-project-init.test.js +96 -0
- package/dist/__tests__/fw-project-init.test.js.map +1 -0
- package/dist/__tests__/helpers/mock-engine.d.ts +2 -0
- package/dist/__tests__/helpers/mock-engine.d.ts.map +1 -0
- package/dist/__tests__/helpers/mock-engine.js +81 -0
- package/dist/__tests__/helpers/mock-engine.js.map +1 -0
- package/dist/__tests__/r1-quality.test.d.ts +2 -0
- package/dist/__tests__/r1-quality.test.d.ts.map +1 -0
- package/dist/__tests__/r1-quality.test.js +539 -0
- package/dist/__tests__/r1-quality.test.js.map +1 -0
- package/dist/__tests__/search-fts5.test.d.ts +2 -0
- package/dist/__tests__/search-fts5.test.d.ts.map +1 -0
- package/dist/__tests__/search-fts5.test.js +135 -0
- package/dist/__tests__/search-fts5.test.js.map +1 -0
- package/dist/__tests__/tools/assemble.test.d.ts +2 -0
- package/dist/__tests__/tools/assemble.test.d.ts.map +1 -0
- package/dist/__tests__/tools/assemble.test.js +203 -0
- package/dist/__tests__/tools/assemble.test.js.map +1 -0
- package/dist/__tests__/tools/export.test.d.ts +2 -0
- package/dist/__tests__/tools/export.test.d.ts.map +1 -0
- package/dist/__tests__/tools/export.test.js +126 -0
- package/dist/__tests__/tools/export.test.js.map +1 -0
- package/dist/__tests__/tools/integration.test.d.ts +2 -0
- package/dist/__tests__/tools/integration.test.d.ts.map +1 -0
- package/dist/__tests__/tools/integration.test.js +707 -0
- package/dist/__tests__/tools/integration.test.js.map +1 -0
- package/dist/__tests__/tools/relink.test.d.ts +2 -0
- package/dist/__tests__/tools/relink.test.d.ts.map +1 -0
- package/dist/__tests__/tools/relink.test.js +107 -0
- package/dist/__tests__/tools/relink.test.js.map +1 -0
- package/dist/__tests__/tools/render-preview.test.d.ts +2 -0
- package/dist/__tests__/tools/render-preview.test.d.ts.map +1 -0
- package/dist/__tests__/tools/render-preview.test.js +99 -0
- package/dist/__tests__/tools/render-preview.test.js.map +1 -0
- package/dist/engine/engine-client.d.ts +29 -0
- package/dist/engine/engine-client.d.ts.map +1 -0
- package/dist/engine/engine-client.js +211 -0
- package/dist/engine/engine-client.js.map +1 -0
- package/dist/engine/engine-manager.d.ts +31 -0
- package/dist/engine/engine-manager.d.ts.map +1 -0
- package/dist/engine/engine-manager.js +144 -0
- package/dist/engine/engine-manager.js.map +1 -0
- package/dist/engine/types.d.ts +6 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/types.js +2 -0
- package/dist/engine/types.js.map +1 -0
- package/dist/helpers/errors.d.ts +13 -0
- package/dist/helpers/errors.d.ts.map +1 -0
- package/dist/helpers/errors.js +14 -0
- package/dist/helpers/errors.js.map +1 -0
- package/dist/helpers/media-extensions.d.ts +8 -0
- package/dist/helpers/media-extensions.d.ts.map +1 -0
- package/dist/helpers/media-extensions.js +39 -0
- package/dist/helpers/media-extensions.js.map +1 -0
- package/dist/helpers/undo.d.ts +55 -0
- package/dist/helpers/undo.d.ts.map +1 -0
- package/dist/helpers/undo.js +142 -0
- package/dist/helpers/undo.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +6 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +62 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resources.d.ts +7 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +59 -0
- package/dist/resources.js.map +1 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +33 -0
- package/dist/server.js.map +1 -0
- package/dist/storage/project-store.d.ts +92 -0
- package/dist/storage/project-store.d.ts.map +1 -0
- package/dist/storage/project-store.js +399 -0
- package/dist/storage/project-store.js.map +1 -0
- package/dist/storage/sqlite-store.d.ts +207 -0
- package/dist/storage/sqlite-store.d.ts.map +1 -0
- package/dist/storage/sqlite-store.js +983 -0
- package/dist/storage/sqlite-store.js.map +1 -0
- package/dist/tools/assemble.d.ts +17 -0
- package/dist/tools/assemble.d.ts.map +1 -0
- package/dist/tools/assemble.js +237 -0
- package/dist/tools/assemble.js.map +1 -0
- package/dist/tools/edit.d.ts +7 -0
- package/dist/tools/edit.d.ts.map +1 -0
- package/dist/tools/edit.js +159 -0
- package/dist/tools/edit.js.map +1 -0
- package/dist/tools/export.d.ts +7 -0
- package/dist/tools/export.d.ts.map +1 -0
- package/dist/tools/export.js +108 -0
- package/dist/tools/export.js.map +1 -0
- package/dist/tools/inspect.d.ts +7 -0
- package/dist/tools/inspect.d.ts.map +1 -0
- package/dist/tools/inspect.js +238 -0
- package/dist/tools/inspect.js.map +1 -0
- package/dist/tools/process.d.ts +7 -0
- package/dist/tools/process.d.ts.map +1 -0
- package/dist/tools/process.js +211 -0
- package/dist/tools/process.js.map +1 -0
- package/dist/tools/project.d.ts +7 -0
- package/dist/tools/project.d.ts.map +1 -0
- package/dist/tools/project.js +178 -0
- package/dist/tools/project.js.map +1 -0
- package/dist/tools/search.d.ts +7 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +75 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/utility.d.ts +7 -0
- package/dist/tools/utility.d.ts.map +1 -0
- package/dist/tools/utility.js +108 -0
- package/dist/tools/utility.js.map +1 -0
- package/package.json +40 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: Full E2E workflow across all 18 MCP tools.
|
|
3
|
+
* Uses InMemoryTransport — no subprocess needed.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { createServer } from "../../server.js";
|
|
12
|
+
import { ProjectStore, atomicWrite } from "../../storage/project-store.js";
|
|
13
|
+
import { SqliteStore } from "../../storage/sqlite-store.js";
|
|
14
|
+
function parseResult(result) {
|
|
15
|
+
const content = result.content;
|
|
16
|
+
return JSON.parse(content[0].text);
|
|
17
|
+
}
|
|
18
|
+
async function createTestEnv() {
|
|
19
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fw-integ-"));
|
|
20
|
+
// Create a fake media file for scanning
|
|
21
|
+
const mediaFile = path.join(tmpDir, "clip01.mp4");
|
|
22
|
+
await fs.writeFile(mediaFile, "fake-video-content-for-hashing");
|
|
23
|
+
const mediaFile2 = path.join(tmpDir, "clip02.mov");
|
|
24
|
+
await fs.writeFile(mediaFile2, "second-fake-video-content");
|
|
25
|
+
const deps = {
|
|
26
|
+
projectStore: new ProjectStore(),
|
|
27
|
+
projects: new Map(),
|
|
28
|
+
};
|
|
29
|
+
const server = createServer(deps);
|
|
30
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
31
|
+
await server.connect(serverTransport);
|
|
32
|
+
const client = new Client({ name: "test-client", version: "1.0.0" });
|
|
33
|
+
await client.connect(clientTransport);
|
|
34
|
+
return { tmpDir, client, server, deps };
|
|
35
|
+
}
|
|
36
|
+
describe("All 25 tools listed", () => {
|
|
37
|
+
let tmpDir;
|
|
38
|
+
let client;
|
|
39
|
+
let server;
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
({ tmpDir, client, server } = await createTestEnv());
|
|
42
|
+
});
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await client.close();
|
|
45
|
+
await server.close();
|
|
46
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
it("should list all 29 tools including fw_auto_assemble and export tools", async () => {
|
|
49
|
+
const { tools } = await client.listTools();
|
|
50
|
+
const names = tools.map((t) => t.name).sort();
|
|
51
|
+
const expected = [
|
|
52
|
+
"fw_asset_remove",
|
|
53
|
+
"fw_auto_assemble",
|
|
54
|
+
"fw_export",
|
|
55
|
+
"fw_get_asset",
|
|
56
|
+
"fw_get_frames",
|
|
57
|
+
"fw_get_timeline",
|
|
58
|
+
"fw_get_transcript",
|
|
59
|
+
"fw_get_waveform",
|
|
60
|
+
"fw_job_cancel",
|
|
61
|
+
"fw_job_status",
|
|
62
|
+
"fw_list_assets",
|
|
63
|
+
"fw_process",
|
|
64
|
+
"fw_project_delete",
|
|
65
|
+
"fw_project_init",
|
|
66
|
+
"fw_project_relink",
|
|
67
|
+
"fw_project_rescan",
|
|
68
|
+
"fw_project_status",
|
|
69
|
+
"fw_redo",
|
|
70
|
+
"fw_render_preview",
|
|
71
|
+
"fw_review_set",
|
|
72
|
+
"fw_search",
|
|
73
|
+
"fw_set_analysis",
|
|
74
|
+
"fw_timeline_add",
|
|
75
|
+
"fw_timeline_batch",
|
|
76
|
+
"fw_timeline_clear",
|
|
77
|
+
"fw_timeline_remove",
|
|
78
|
+
"fw_timeline_reorder",
|
|
79
|
+
"fw_timeline_trim",
|
|
80
|
+
"fw_undo",
|
|
81
|
+
];
|
|
82
|
+
expect(names).toEqual(expected);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("E2E workflow: init → inspect → edit → undo → redo → delete", () => {
|
|
86
|
+
let tmpDir;
|
|
87
|
+
let client;
|
|
88
|
+
let server;
|
|
89
|
+
let deps;
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
({ tmpDir, client, server, deps } = await createTestEnv());
|
|
92
|
+
});
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
// Close any remaining sqlite stores
|
|
95
|
+
for (const ctx of deps.projects.values()) {
|
|
96
|
+
try {
|
|
97
|
+
ctx.sqliteStore.close();
|
|
98
|
+
}
|
|
99
|
+
catch { /* cleanup */ }
|
|
100
|
+
}
|
|
101
|
+
await client.close();
|
|
102
|
+
await server.close();
|
|
103
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
it("full workflow with all tool categories", async () => {
|
|
106
|
+
// === 1. PROJECT INIT ===
|
|
107
|
+
const initResult = await client.callTool({
|
|
108
|
+
name: "fw_project_init",
|
|
109
|
+
arguments: { name: "Test Project", source_path: tmpDir },
|
|
110
|
+
});
|
|
111
|
+
expect(initResult.isError).toBeFalsy();
|
|
112
|
+
const initData = parseResult(initResult);
|
|
113
|
+
expect(initData.project_id).toBeTruthy();
|
|
114
|
+
expect(initData.assets_discovered).toBe(2);
|
|
115
|
+
const projectId = initData.project_id;
|
|
116
|
+
// === 2. PROJECT STATUS ===
|
|
117
|
+
const statusResult = await client.callTool({
|
|
118
|
+
name: "fw_project_status",
|
|
119
|
+
arguments: { project_id: projectId },
|
|
120
|
+
});
|
|
121
|
+
expect(statusResult.isError).toBeFalsy();
|
|
122
|
+
const status = parseResult(statusResult);
|
|
123
|
+
expect(status.assets.total).toBe(2);
|
|
124
|
+
expect(status.engine_status).toBe("stopped");
|
|
125
|
+
// === 3. LIST ASSETS ===
|
|
126
|
+
const listResult = await client.callTool({
|
|
127
|
+
name: "fw_list_assets",
|
|
128
|
+
arguments: { project_id: projectId },
|
|
129
|
+
});
|
|
130
|
+
expect(listResult.isError).toBeFalsy();
|
|
131
|
+
const listData = parseResult(listResult);
|
|
132
|
+
expect(listData.assets).toHaveLength(2);
|
|
133
|
+
const assetHash = listData.assets[0].asset_hash;
|
|
134
|
+
// === 4. GET ASSET ===
|
|
135
|
+
const getAssetResult = await client.callTool({
|
|
136
|
+
name: "fw_get_asset",
|
|
137
|
+
arguments: { project_id: projectId, asset_id: assetHash },
|
|
138
|
+
});
|
|
139
|
+
expect(getAssetResult.isError).toBeFalsy();
|
|
140
|
+
const assetData = parseResult(getAssetResult);
|
|
141
|
+
expect(assetData.asset_hash).toBe(assetHash);
|
|
142
|
+
expect(assetData.transcode_status).toBe("pending");
|
|
143
|
+
// === 5. GET TRANSCRIPT (no transcript yet) ===
|
|
144
|
+
const transcriptResult = await client.callTool({
|
|
145
|
+
name: "fw_get_transcript",
|
|
146
|
+
arguments: { project_id: projectId, asset_id: assetHash },
|
|
147
|
+
});
|
|
148
|
+
expect(transcriptResult.isError).toBe(true);
|
|
149
|
+
// === 6. TIMELINE ADD ===
|
|
150
|
+
const addResult = await client.callTool({
|
|
151
|
+
name: "fw_timeline_add",
|
|
152
|
+
arguments: {
|
|
153
|
+
project_id: projectId,
|
|
154
|
+
asset_hash: assetHash,
|
|
155
|
+
in_point_ms: 0,
|
|
156
|
+
out_point_ms: 5000,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
expect(addResult.isError).toBeFalsy();
|
|
160
|
+
const addData = parseResult(addResult);
|
|
161
|
+
expect(addData.clip.asset_hash).toBe(assetHash);
|
|
162
|
+
expect(addData.revision).toBe(1);
|
|
163
|
+
const clipId = addData.clip.id;
|
|
164
|
+
// Add a second clip
|
|
165
|
+
const assetHash2 = listData.assets[1].asset_hash;
|
|
166
|
+
const add2Result = await client.callTool({
|
|
167
|
+
name: "fw_timeline_add",
|
|
168
|
+
arguments: {
|
|
169
|
+
project_id: projectId,
|
|
170
|
+
asset_hash: assetHash2,
|
|
171
|
+
in_point_ms: 1000,
|
|
172
|
+
out_point_ms: 3000,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
expect(add2Result.isError).toBeFalsy();
|
|
176
|
+
const add2Data = parseResult(add2Result);
|
|
177
|
+
const clipId2 = add2Data.clip.id;
|
|
178
|
+
expect(add2Data.revision).toBe(2);
|
|
179
|
+
// === 7. GET TIMELINE ===
|
|
180
|
+
const timelineResult = await client.callTool({
|
|
181
|
+
name: "fw_get_timeline",
|
|
182
|
+
arguments: { project_id: projectId },
|
|
183
|
+
});
|
|
184
|
+
expect(timelineResult.isError).toBeFalsy();
|
|
185
|
+
const timeline = parseResult(timelineResult);
|
|
186
|
+
expect(timeline.clips).toHaveLength(2);
|
|
187
|
+
expect(timeline.total_duration_ms).toBe(7000);
|
|
188
|
+
expect(timeline.revision).toBe(2);
|
|
189
|
+
// === 8. TIMELINE TRIM ===
|
|
190
|
+
const trimResult = await client.callTool({
|
|
191
|
+
name: "fw_timeline_trim",
|
|
192
|
+
arguments: {
|
|
193
|
+
project_id: projectId,
|
|
194
|
+
clip_id: clipId,
|
|
195
|
+
in_point_ms: 500,
|
|
196
|
+
out_point_ms: 4000,
|
|
197
|
+
expected_revision: 2,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
expect(trimResult.isError).toBeFalsy();
|
|
201
|
+
const trimData = parseResult(trimResult);
|
|
202
|
+
expect(trimData.clip.in_point_ms).toBe(500);
|
|
203
|
+
expect(trimData.clip.out_point_ms).toBe(4000);
|
|
204
|
+
expect(trimData.revision).toBe(3);
|
|
205
|
+
// === 9. TIMELINE REORDER ===
|
|
206
|
+
const reorderResult = await client.callTool({
|
|
207
|
+
name: "fw_timeline_reorder",
|
|
208
|
+
arguments: {
|
|
209
|
+
project_id: projectId,
|
|
210
|
+
clip_id: clipId2,
|
|
211
|
+
new_position: 0,
|
|
212
|
+
expected_revision: 3,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
expect(reorderResult.isError).toBeFalsy();
|
|
216
|
+
const reorderData = parseResult(reorderResult);
|
|
217
|
+
expect(reorderData.clips[0].id).toBe(clipId2);
|
|
218
|
+
expect(reorderData.revision).toBe(4);
|
|
219
|
+
// === 10. REVIEW SET ===
|
|
220
|
+
const reviewResult = await client.callTool({
|
|
221
|
+
name: "fw_review_set",
|
|
222
|
+
arguments: {
|
|
223
|
+
project_id: projectId,
|
|
224
|
+
asset_hash: assetHash,
|
|
225
|
+
segment_index: 0,
|
|
226
|
+
status: "keep",
|
|
227
|
+
reviewer: "ai",
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
expect(reviewResult.isError).toBeFalsy();
|
|
231
|
+
const reviewData = parseResult(reviewResult);
|
|
232
|
+
expect(reviewData.review.status).toBe("keep");
|
|
233
|
+
// === 11. UNDO ===
|
|
234
|
+
const undoResult = await client.callTool({
|
|
235
|
+
name: "fw_undo",
|
|
236
|
+
arguments: { project_id: projectId },
|
|
237
|
+
});
|
|
238
|
+
expect(undoResult.isError).toBeFalsy();
|
|
239
|
+
const undoData = parseResult(undoResult);
|
|
240
|
+
expect(undoData.undone_operations.length).toBeGreaterThan(0);
|
|
241
|
+
// === 12. REDO ===
|
|
242
|
+
const redoResult = await client.callTool({
|
|
243
|
+
name: "fw_redo",
|
|
244
|
+
arguments: { project_id: projectId },
|
|
245
|
+
});
|
|
246
|
+
expect(redoResult.isError).toBeFalsy();
|
|
247
|
+
const redoData = parseResult(redoResult);
|
|
248
|
+
expect(redoData.redone_operations.length).toBeGreaterThan(0);
|
|
249
|
+
// === 13. TIMELINE BATCH ===
|
|
250
|
+
const currentTimeline = parseResult(await client.callTool({
|
|
251
|
+
name: "fw_get_timeline",
|
|
252
|
+
arguments: { project_id: projectId },
|
|
253
|
+
}));
|
|
254
|
+
const batchResult = await client.callTool({
|
|
255
|
+
name: "fw_timeline_batch",
|
|
256
|
+
arguments: {
|
|
257
|
+
project_id: projectId,
|
|
258
|
+
operations: [
|
|
259
|
+
{ type: "add", asset_hash: assetHash, in_point_ms: 6000, out_point_ms: 8000 },
|
|
260
|
+
{ type: "trim", clip_id: clipId, in_point_ms: 0, out_point_ms: 3000 },
|
|
261
|
+
],
|
|
262
|
+
expected_revision: currentTimeline.revision,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
expect(batchResult.isError).toBeFalsy();
|
|
266
|
+
const batchData = parseResult(batchResult);
|
|
267
|
+
expect(batchData.results).toHaveLength(2);
|
|
268
|
+
expect(batchData.results.every((r) => r.success)).toBe(true);
|
|
269
|
+
// === 14. TIMELINE REMOVE ===
|
|
270
|
+
const tl = parseResult(await client.callTool({
|
|
271
|
+
name: "fw_get_timeline",
|
|
272
|
+
arguments: { project_id: projectId },
|
|
273
|
+
}));
|
|
274
|
+
const lastClip = tl.clips[tl.clips.length - 1];
|
|
275
|
+
const removeResult = await client.callTool({
|
|
276
|
+
name: "fw_timeline_remove",
|
|
277
|
+
arguments: {
|
|
278
|
+
project_id: projectId,
|
|
279
|
+
clip_id: lastClip.id,
|
|
280
|
+
expected_revision: tl.revision,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
expect(removeResult.isError).toBeFalsy();
|
|
284
|
+
expect(parseResult(removeResult).removed).toBe(true);
|
|
285
|
+
// === 15. TIMELINE CLEAR ===
|
|
286
|
+
const clearResult = await client.callTool({
|
|
287
|
+
name: "fw_timeline_clear",
|
|
288
|
+
arguments: { project_id: projectId },
|
|
289
|
+
});
|
|
290
|
+
expect(clearResult.isError).toBeFalsy();
|
|
291
|
+
expect(parseResult(clearResult).clips_removed).toBeGreaterThanOrEqual(1);
|
|
292
|
+
// === 16. PROJECT RESCAN ===
|
|
293
|
+
// Add a new file to source
|
|
294
|
+
await fs.writeFile(path.join(tmpDir, "new-clip.mp4"), "new-content");
|
|
295
|
+
const rescanResult = await client.callTool({
|
|
296
|
+
name: "fw_project_rescan",
|
|
297
|
+
arguments: { project_id: projectId },
|
|
298
|
+
});
|
|
299
|
+
expect(rescanResult.isError).toBeFalsy();
|
|
300
|
+
const rescan = parseResult(rescanResult);
|
|
301
|
+
expect(rescan.assets_new).toBe(1);
|
|
302
|
+
// === 17. ASSET REMOVE ===
|
|
303
|
+
const allAssets = parseResult(await client.callTool({
|
|
304
|
+
name: "fw_list_assets",
|
|
305
|
+
arguments: { project_id: projectId },
|
|
306
|
+
}));
|
|
307
|
+
const removeAssetResult = await client.callTool({
|
|
308
|
+
name: "fw_asset_remove",
|
|
309
|
+
arguments: {
|
|
310
|
+
project_id: projectId,
|
|
311
|
+
asset_ids: [allAssets.assets[0].asset_hash],
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
expect(removeAssetResult.isError).toBeFalsy();
|
|
315
|
+
expect(parseResult(removeAssetResult).removed_count).toBe(1);
|
|
316
|
+
// === 18. PROJECT DELETE ===
|
|
317
|
+
const deleteResult = await client.callTool({
|
|
318
|
+
name: "fw_project_delete",
|
|
319
|
+
arguments: { project_id: projectId, confirm: true },
|
|
320
|
+
});
|
|
321
|
+
expect(deleteResult.isError).toBeFalsy();
|
|
322
|
+
expect(parseResult(deleteResult).deleted_assets_count).toBeGreaterThanOrEqual(0);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
describe("Error handling", () => {
|
|
326
|
+
let tmpDir;
|
|
327
|
+
let client;
|
|
328
|
+
let server;
|
|
329
|
+
beforeEach(async () => {
|
|
330
|
+
({ tmpDir, client, server } = await createTestEnv());
|
|
331
|
+
});
|
|
332
|
+
afterEach(async () => {
|
|
333
|
+
await client.close();
|
|
334
|
+
await server.close();
|
|
335
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
336
|
+
});
|
|
337
|
+
it("returns project_not_found for unknown project", async () => {
|
|
338
|
+
const result = await client.callTool({
|
|
339
|
+
name: "fw_list_assets",
|
|
340
|
+
arguments: { project_id: "proj_nonexistent" },
|
|
341
|
+
});
|
|
342
|
+
expect(result.isError).toBe(true);
|
|
343
|
+
const data = parseResult(result);
|
|
344
|
+
expect(data.error.code).toBe("project_not_found");
|
|
345
|
+
});
|
|
346
|
+
it("returns asset_not_found for unknown asset", async () => {
|
|
347
|
+
// First create a project
|
|
348
|
+
const init = await client.callTool({
|
|
349
|
+
name: "fw_project_init",
|
|
350
|
+
arguments: { name: "Test", source_path: tmpDir },
|
|
351
|
+
});
|
|
352
|
+
const projectId = parseResult(init).project_id;
|
|
353
|
+
const result = await client.callTool({
|
|
354
|
+
name: "fw_get_asset",
|
|
355
|
+
arguments: { project_id: projectId, asset_id: "nonexistent_hash" },
|
|
356
|
+
});
|
|
357
|
+
expect(result.isError).toBe(true);
|
|
358
|
+
const data = parseResult(result);
|
|
359
|
+
expect(data.error.code).toBe("asset_not_found");
|
|
360
|
+
});
|
|
361
|
+
it("returns revision_conflict on stale revision", async () => {
|
|
362
|
+
const init = await client.callTool({
|
|
363
|
+
name: "fw_project_init",
|
|
364
|
+
arguments: { name: "Test", source_path: tmpDir },
|
|
365
|
+
});
|
|
366
|
+
const projectId = parseResult(init).project_id;
|
|
367
|
+
const assets = parseResult(await client.callTool({
|
|
368
|
+
name: "fw_list_assets",
|
|
369
|
+
arguments: { project_id: projectId },
|
|
370
|
+
}));
|
|
371
|
+
const assetHash = assets.assets[0].asset_hash;
|
|
372
|
+
// Add a clip (revision becomes 1)
|
|
373
|
+
await client.callTool({
|
|
374
|
+
name: "fw_timeline_add",
|
|
375
|
+
arguments: {
|
|
376
|
+
project_id: projectId,
|
|
377
|
+
asset_hash: assetHash,
|
|
378
|
+
in_point_ms: 0,
|
|
379
|
+
out_point_ms: 5000,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
// Try to add with stale revision (expected 0, actual is 1)
|
|
383
|
+
const result = await client.callTool({
|
|
384
|
+
name: "fw_timeline_add",
|
|
385
|
+
arguments: {
|
|
386
|
+
project_id: projectId,
|
|
387
|
+
asset_hash: assetHash,
|
|
388
|
+
in_point_ms: 1000,
|
|
389
|
+
out_point_ms: 2000,
|
|
390
|
+
expected_revision: 0,
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
expect(result.isError).toBe(true);
|
|
394
|
+
const data = parseResult(result);
|
|
395
|
+
expect(data.error.code).toBe("revision_conflict");
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
describe("fw_search — L0 in-memory search", () => {
|
|
399
|
+
let tmpDir;
|
|
400
|
+
let client;
|
|
401
|
+
let server;
|
|
402
|
+
let deps;
|
|
403
|
+
beforeEach(async () => {
|
|
404
|
+
({ tmpDir, client, server, deps } = await createTestEnv());
|
|
405
|
+
});
|
|
406
|
+
afterEach(async () => {
|
|
407
|
+
for (const ctx of deps.projects.values()) {
|
|
408
|
+
try {
|
|
409
|
+
ctx.sqliteStore.close();
|
|
410
|
+
}
|
|
411
|
+
catch { /* cleanup */ }
|
|
412
|
+
}
|
|
413
|
+
await client.close();
|
|
414
|
+
await server.close();
|
|
415
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
416
|
+
});
|
|
417
|
+
async function initProjectWithSegments() {
|
|
418
|
+
const initResult = await client.callTool({
|
|
419
|
+
name: "fw_project_init",
|
|
420
|
+
arguments: { name: "Search Test", source_path: tmpDir },
|
|
421
|
+
});
|
|
422
|
+
const initData = parseResult(initResult);
|
|
423
|
+
const projectId = initData.project_id;
|
|
424
|
+
const ctx = deps.projects.get(projectId);
|
|
425
|
+
// Load assets to get their hashes
|
|
426
|
+
const listResult = await client.callTool({
|
|
427
|
+
name: "fw_list_assets",
|
|
428
|
+
arguments: { project_id: projectId },
|
|
429
|
+
});
|
|
430
|
+
const { assets } = parseResult(listResult);
|
|
431
|
+
// Inject segments into first asset's .fw.json
|
|
432
|
+
const assetHash = assets[0].asset_hash;
|
|
433
|
+
const assetFilePath = path.join(ctx.projectPath, ".fw", "assets", `${assetHash}.fw.json`);
|
|
434
|
+
const rawAsset = JSON.parse(await fs.readFile(assetFilePath, "utf-8"));
|
|
435
|
+
rawAsset.segments = [
|
|
436
|
+
{
|
|
437
|
+
segment_index: 0,
|
|
438
|
+
start_ms: 0,
|
|
439
|
+
end_ms: 5000,
|
|
440
|
+
editorial_role: "interview",
|
|
441
|
+
energy: 0.7,
|
|
442
|
+
story_value: 0.8,
|
|
443
|
+
summary: "The quick brown fox jumps over the lazy dog",
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
segment_index: 1,
|
|
447
|
+
start_ms: 5000,
|
|
448
|
+
end_ms: 10000,
|
|
449
|
+
editorial_role: "b-roll",
|
|
450
|
+
energy: 0.3,
|
|
451
|
+
story_value: 0.4,
|
|
452
|
+
summary: "Scenic mountain landscape with flowing river",
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
await atomicWrite(assetFilePath, JSON.stringify(rawAsset, null, 2));
|
|
456
|
+
return { projectId, assetHash };
|
|
457
|
+
}
|
|
458
|
+
it("fw_search is listed in available tools", async () => {
|
|
459
|
+
const { tools } = await client.listTools();
|
|
460
|
+
const names = tools.map((t) => t.name);
|
|
461
|
+
expect(names).toContain("fw_search");
|
|
462
|
+
});
|
|
463
|
+
it("returns matching segments from project assets", async () => {
|
|
464
|
+
const { projectId, assetHash } = await initProjectWithSegments();
|
|
465
|
+
const result = await client.callTool({
|
|
466
|
+
name: "fw_search",
|
|
467
|
+
arguments: { project_id: projectId, query: "fox" },
|
|
468
|
+
});
|
|
469
|
+
expect(result.isError).toBeFalsy();
|
|
470
|
+
const data = parseResult(result);
|
|
471
|
+
expect(data.backend_used).toBe("memory");
|
|
472
|
+
expect(data.results).toHaveLength(1);
|
|
473
|
+
expect(data.results[0].asset_hash).toBe(assetHash);
|
|
474
|
+
expect(data.results[0].segment_index).toBe(0);
|
|
475
|
+
expect(data.results[0].snippet).toContain("fox");
|
|
476
|
+
});
|
|
477
|
+
it("returns empty array when no segments match", async () => {
|
|
478
|
+
const { projectId } = await initProjectWithSegments();
|
|
479
|
+
const result = await client.callTool({
|
|
480
|
+
name: "fw_search",
|
|
481
|
+
arguments: { project_id: projectId, query: "unicorn" },
|
|
482
|
+
});
|
|
483
|
+
expect(result.isError).toBeFalsy();
|
|
484
|
+
const data = parseResult(result);
|
|
485
|
+
expect(data.results).toHaveLength(0);
|
|
486
|
+
expect(data.backend_used).toBe("memory");
|
|
487
|
+
});
|
|
488
|
+
it("respects the limit parameter", async () => {
|
|
489
|
+
const { projectId } = await initProjectWithSegments();
|
|
490
|
+
// Inject a third segment also matching "mountain" into the second asset
|
|
491
|
+
const listResult2 = await client.callTool({
|
|
492
|
+
name: "fw_list_assets",
|
|
493
|
+
arguments: { project_id: projectId },
|
|
494
|
+
});
|
|
495
|
+
const assets2 = parseResult(listResult2).assets;
|
|
496
|
+
const assetHash2 = assets2[1].asset_hash;
|
|
497
|
+
const ctx = deps.projects.get(projectId);
|
|
498
|
+
const assetFilePath2 = path.join(ctx.projectPath, ".fw", "assets", `${assetHash2}.fw.json`);
|
|
499
|
+
const rawAsset2 = JSON.parse(await fs.readFile(assetFilePath2, "utf-8"));
|
|
500
|
+
rawAsset2.segments = [
|
|
501
|
+
{ segment_index: 0, start_ms: 0, end_ms: 5000, editorial_role: "b-roll", energy: 0.4, story_value: 0.5, summary: "Scenic mountain peak at sunrise" },
|
|
502
|
+
];
|
|
503
|
+
await atomicWrite(assetFilePath2, JSON.stringify(rawAsset2, null, 2));
|
|
504
|
+
// "mountain" matches segment 1 of asset 1 and segment 0 of asset 2 — 2 total, limit=1 → next_cursor
|
|
505
|
+
const result = await client.callTool({
|
|
506
|
+
name: "fw_search",
|
|
507
|
+
arguments: { project_id: projectId, query: "mountain", limit: 1 },
|
|
508
|
+
});
|
|
509
|
+
expect(result.isError).toBeFalsy();
|
|
510
|
+
const data = parseResult(result);
|
|
511
|
+
expect(data.results).toHaveLength(1);
|
|
512
|
+
expect(data.next_cursor).toBeDefined();
|
|
513
|
+
});
|
|
514
|
+
it("fw_process, fw_job_status, and fw_job_cancel are listed in available tools", async () => {
|
|
515
|
+
const { tools } = await client.listTools();
|
|
516
|
+
const names = tools.map((t) => t.name);
|
|
517
|
+
expect(names).toContain("fw_process");
|
|
518
|
+
expect(names).toContain("fw_job_status");
|
|
519
|
+
expect(names).toContain("fw_job_cancel");
|
|
520
|
+
});
|
|
521
|
+
it("falls through to L0 when threshold exceeded but no FTS5 data", async () => {
|
|
522
|
+
const originalThreshold = process.env.FW_SEARCH_THRESHOLD;
|
|
523
|
+
process.env.FW_SEARCH_THRESHOLD = "1"; // threshold=1, we have 2 assets
|
|
524
|
+
try {
|
|
525
|
+
const initResult = await client.callTool({
|
|
526
|
+
name: "fw_project_init",
|
|
527
|
+
arguments: { name: "Threshold Test", source_path: tmpDir },
|
|
528
|
+
});
|
|
529
|
+
const { project_id: projectId } = parseResult(initResult);
|
|
530
|
+
const result = await client.callTool({
|
|
531
|
+
name: "fw_search",
|
|
532
|
+
arguments: { project_id: projectId, query: "anything" },
|
|
533
|
+
});
|
|
534
|
+
// No FTS5 data populated, so falls through to L0 in-memory scan
|
|
535
|
+
expect(result.isError).toBeUndefined();
|
|
536
|
+
const data = parseResult(result);
|
|
537
|
+
expect(data.backend_used).toBe("memory");
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
if (originalThreshold === undefined) {
|
|
541
|
+
delete process.env.FW_SEARCH_THRESHOLD;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
process.env.FW_SEARCH_THRESHOLD = originalThreshold;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
describe("Process tools — fw_process, fw_job_status, fw_job_cancel", () => {
|
|
550
|
+
let tmpDir;
|
|
551
|
+
let client;
|
|
552
|
+
let server;
|
|
553
|
+
let deps;
|
|
554
|
+
beforeEach(async () => {
|
|
555
|
+
({ tmpDir, client, server, deps } = await createTestEnv());
|
|
556
|
+
});
|
|
557
|
+
afterEach(async () => {
|
|
558
|
+
for (const ctx of deps.projects.values()) {
|
|
559
|
+
try {
|
|
560
|
+
ctx.sqliteStore.close();
|
|
561
|
+
}
|
|
562
|
+
catch { /* cleanup */ }
|
|
563
|
+
}
|
|
564
|
+
await client.close();
|
|
565
|
+
await server.close();
|
|
566
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
567
|
+
});
|
|
568
|
+
it("fw_process returns error when engineManager is not available", async () => {
|
|
569
|
+
const initResult = await client.callTool({
|
|
570
|
+
name: "fw_project_init",
|
|
571
|
+
arguments: { name: "Test", source_path: tmpDir },
|
|
572
|
+
});
|
|
573
|
+
const { project_id: projectId } = parseResult(initResult);
|
|
574
|
+
// deps.engineManager is undefined in test env
|
|
575
|
+
const result = await client.callTool({
|
|
576
|
+
name: "fw_process",
|
|
577
|
+
arguments: { project_id: projectId },
|
|
578
|
+
});
|
|
579
|
+
expect(result.isError).toBe(true);
|
|
580
|
+
const data = parseResult(result);
|
|
581
|
+
expect(data.error.code).toBe("invalid_input");
|
|
582
|
+
});
|
|
583
|
+
it("fw_process returns project_not_found for unknown project", async () => {
|
|
584
|
+
const result = await client.callTool({
|
|
585
|
+
name: "fw_process",
|
|
586
|
+
arguments: { project_id: "proj_nonexistent" },
|
|
587
|
+
});
|
|
588
|
+
expect(result.isError).toBe(true);
|
|
589
|
+
const data = parseResult(result);
|
|
590
|
+
expect(data.error.code).toBe("project_not_found");
|
|
591
|
+
});
|
|
592
|
+
it("fw_job_status returns error for unknown job_id", async () => {
|
|
593
|
+
const result = await client.callTool({
|
|
594
|
+
name: "fw_job_status",
|
|
595
|
+
arguments: { job_id: "job_nonexistent" },
|
|
596
|
+
});
|
|
597
|
+
expect(result.isError).toBe(true);
|
|
598
|
+
const data = parseResult(result);
|
|
599
|
+
expect(data.error.code).toBe("invalid_input");
|
|
600
|
+
});
|
|
601
|
+
it("fw_job_status requires job_id or project_id", async () => {
|
|
602
|
+
const result = await client.callTool({
|
|
603
|
+
name: "fw_job_status",
|
|
604
|
+
arguments: {},
|
|
605
|
+
});
|
|
606
|
+
expect(result.isError).toBe(true);
|
|
607
|
+
const data = parseResult(result);
|
|
608
|
+
expect(data.error.code).toBe("invalid_input");
|
|
609
|
+
});
|
|
610
|
+
it("fw_job_status lists jobs for a project", async () => {
|
|
611
|
+
const initResult = await client.callTool({
|
|
612
|
+
name: "fw_project_init",
|
|
613
|
+
arguments: { name: "Job List Test", source_path: tmpDir },
|
|
614
|
+
});
|
|
615
|
+
const { project_id: projectId } = parseResult(initResult);
|
|
616
|
+
const result = await client.callTool({
|
|
617
|
+
name: "fw_job_status",
|
|
618
|
+
arguments: { project_id: projectId },
|
|
619
|
+
});
|
|
620
|
+
expect(result.isError).toBeFalsy();
|
|
621
|
+
const data = parseResult(result);
|
|
622
|
+
expect(Array.isArray(data.jobs)).toBe(true);
|
|
623
|
+
expect(data.jobs).toHaveLength(0);
|
|
624
|
+
});
|
|
625
|
+
it("SqliteStore createJob and getJob work correctly", async () => {
|
|
626
|
+
const store = new SqliteStore(":memory:");
|
|
627
|
+
const jobId = store.createJob("proj_test", "transcode", { asset_hash: "abc123" });
|
|
628
|
+
expect(typeof jobId).toBe("string");
|
|
629
|
+
const job = store.getJob(jobId);
|
|
630
|
+
expect(job).not.toBeNull();
|
|
631
|
+
expect(job.job_id).toBe(jobId);
|
|
632
|
+
expect(job.project_id).toBe("proj_test");
|
|
633
|
+
expect(job.type).toBe("transcode");
|
|
634
|
+
expect(job.status).toBe("pending");
|
|
635
|
+
expect(job.progress).toBe(0);
|
|
636
|
+
expect(job.params).toEqual({ asset_hash: "abc123" });
|
|
637
|
+
store.close();
|
|
638
|
+
});
|
|
639
|
+
it("SqliteStore updateJobProgress sets status to running", async () => {
|
|
640
|
+
const store = new SqliteStore(":memory:");
|
|
641
|
+
const jobId = store.createJob("proj_test", "transcode", {});
|
|
642
|
+
store.updateJobProgress(jobId, 0.5, "encoding");
|
|
643
|
+
const job = store.getJob(jobId);
|
|
644
|
+
expect(job.status).toBe("running");
|
|
645
|
+
expect(job.progress).toBe(0.5);
|
|
646
|
+
expect(job.step).toBe("encoding");
|
|
647
|
+
store.close();
|
|
648
|
+
});
|
|
649
|
+
it("SqliteStore updateJobResult sets status to completed", async () => {
|
|
650
|
+
const store = new SqliteStore(":memory:");
|
|
651
|
+
const jobId = store.createJob("proj_test", "transcode", {});
|
|
652
|
+
store.updateJobResult(jobId, { proxy_path: "/tmp/proxy.mp4" });
|
|
653
|
+
const job = store.getJob(jobId);
|
|
654
|
+
expect(job.status).toBe("completed");
|
|
655
|
+
expect(job.progress).toBe(1);
|
|
656
|
+
expect(job.result).toEqual({ proxy_path: "/tmp/proxy.mp4" });
|
|
657
|
+
store.close();
|
|
658
|
+
});
|
|
659
|
+
it("SqliteStore updateJobError sets status to failed", async () => {
|
|
660
|
+
const store = new SqliteStore(":memory:");
|
|
661
|
+
const jobId = store.createJob("proj_test", "transcode", {});
|
|
662
|
+
store.updateJobError(jobId, "ffmpeg exited with code 1");
|
|
663
|
+
const job = store.getJob(jobId);
|
|
664
|
+
expect(job.status).toBe("failed");
|
|
665
|
+
expect(job.error).toBe("ffmpeg exited with code 1");
|
|
666
|
+
store.close();
|
|
667
|
+
});
|
|
668
|
+
it("SqliteStore cancelJob sets status to cancelled", async () => {
|
|
669
|
+
const store = new SqliteStore(":memory:");
|
|
670
|
+
const jobId = store.createJob("proj_test", "transcode", {});
|
|
671
|
+
store.cancelJob(jobId);
|
|
672
|
+
const job = store.getJob(jobId);
|
|
673
|
+
expect(job.status).toBe("cancelled");
|
|
674
|
+
store.close();
|
|
675
|
+
});
|
|
676
|
+
it("SqliteStore markCrashedJobs marks running jobs as failed", async () => {
|
|
677
|
+
const store = new SqliteStore(":memory:");
|
|
678
|
+
const jobId1 = store.createJob("proj_test", "transcode", {});
|
|
679
|
+
const jobId2 = store.createJob("proj_test", "transcribe", {});
|
|
680
|
+
const jobId3 = store.createJob("proj_test", "analyze", {});
|
|
681
|
+
// Mark first two as running
|
|
682
|
+
store.updateJobProgress(jobId1, 0.3);
|
|
683
|
+
store.updateJobProgress(jobId2, 0.6);
|
|
684
|
+
// Leave jobId3 as pending
|
|
685
|
+
store.markCrashedJobs();
|
|
686
|
+
const job1 = store.getJob(jobId1);
|
|
687
|
+
const job2 = store.getJob(jobId2);
|
|
688
|
+
const job3 = store.getJob(jobId3);
|
|
689
|
+
expect(job1.status).toBe("failed");
|
|
690
|
+
expect(job1.error).toBe("daemon_crashed");
|
|
691
|
+
expect(job2.status).toBe("failed");
|
|
692
|
+
expect(job2.error).toBe("daemon_crashed");
|
|
693
|
+
// Pending job should remain unchanged
|
|
694
|
+
expect(job3.status).toBe("pending");
|
|
695
|
+
store.close();
|
|
696
|
+
});
|
|
697
|
+
it("fw_job_cancel returns error for unknown job_id", async () => {
|
|
698
|
+
const result = await client.callTool({
|
|
699
|
+
name: "fw_job_cancel",
|
|
700
|
+
arguments: { job_id: "job_nonexistent" },
|
|
701
|
+
});
|
|
702
|
+
expect(result.isError).toBe(true);
|
|
703
|
+
const data = parseResult(result);
|
|
704
|
+
expect(data.error.code).toBe("invalid_input");
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
//# sourceMappingURL=integration.test.js.map
|