@openparachute/vault 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/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { buildPredicate, registerTriggers } from "./triggers.ts";
|
|
3
|
+
import { HookRegistry } from "../core/src/hooks.ts";
|
|
4
|
+
import type { Note, Store, Attachment } from "../core/src/types.ts";
|
|
5
|
+
import type { TriggerConfig } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
function makeNote(overrides: Partial<Note> = {}): Note {
|
|
8
|
+
return {
|
|
9
|
+
id: "test-1",
|
|
10
|
+
content: "hello world",
|
|
11
|
+
tags: [],
|
|
12
|
+
metadata: {},
|
|
13
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
14
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("buildPredicate", () => {
|
|
20
|
+
it("matches when all conditions are met", () => {
|
|
21
|
+
const pred = buildPredicate(
|
|
22
|
+
{ tags: ["reader"], has_content: true, missing_metadata: ["audio_rendered_at"] },
|
|
23
|
+
"tts_reader",
|
|
24
|
+
);
|
|
25
|
+
const note = makeNote({ tags: ["reader"], content: "some text" });
|
|
26
|
+
expect(pred(note)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("rejects when pending marker is set", () => {
|
|
30
|
+
const pred = buildPredicate({ tags: ["reader"] }, "tts_reader");
|
|
31
|
+
const note = makeNote({
|
|
32
|
+
tags: ["reader"],
|
|
33
|
+
metadata: { tts_reader_pending_at: "2025-01-01" },
|
|
34
|
+
});
|
|
35
|
+
expect(pred(note)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects when rendered marker is set", () => {
|
|
39
|
+
const pred = buildPredicate({ tags: ["reader"] }, "tts_reader");
|
|
40
|
+
const note = makeNote({
|
|
41
|
+
tags: ["reader"],
|
|
42
|
+
metadata: { tts_reader_rendered_at: "2025-01-01" },
|
|
43
|
+
});
|
|
44
|
+
expect(pred(note)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects when required tag is missing", () => {
|
|
48
|
+
const pred = buildPredicate({ tags: ["reader"] }, "tts_reader");
|
|
49
|
+
const note = makeNote({ tags: ["other"] });
|
|
50
|
+
expect(pred(note)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects when has_content=true and content is empty", () => {
|
|
54
|
+
const pred = buildPredicate({ has_content: true }, "test");
|
|
55
|
+
expect(pred(makeNote({ content: "" }))).toBe(false);
|
|
56
|
+
expect(pred(makeNote({ content: " " }))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects when has_content=false and content is present", () => {
|
|
60
|
+
const pred = buildPredicate({ has_content: false }, "test");
|
|
61
|
+
expect(pred(makeNote({ content: "hello" }))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("matches has_content=false when content is empty", () => {
|
|
65
|
+
const pred = buildPredicate({ has_content: false }, "test");
|
|
66
|
+
expect(pred(makeNote({ content: "" }))).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects when missing_metadata key is present", () => {
|
|
70
|
+
const pred = buildPredicate({ missing_metadata: ["done"] }, "test");
|
|
71
|
+
const note = makeNote({ metadata: { done: true } });
|
|
72
|
+
expect(pred(note)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("matches when missing_metadata key is absent", () => {
|
|
76
|
+
const pred = buildPredicate({ missing_metadata: ["done"] }, "test");
|
|
77
|
+
const note = makeNote({ metadata: {} });
|
|
78
|
+
expect(pred(note)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects when has_metadata key is absent", () => {
|
|
82
|
+
const pred = buildPredicate({ has_metadata: ["source"] }, "test");
|
|
83
|
+
const note = makeNote({ metadata: {} });
|
|
84
|
+
expect(pred(note)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("matches when has_metadata key is present", () => {
|
|
88
|
+
const pred = buildPredicate({ has_metadata: ["source"] }, "test");
|
|
89
|
+
const note = makeNote({ metadata: { source: "voice" } });
|
|
90
|
+
expect(pred(note)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("requires all tags to match", () => {
|
|
94
|
+
const pred = buildPredicate({ tags: ["reader", "important"] }, "test");
|
|
95
|
+
expect(pred(makeNote({ tags: ["reader"] }))).toBe(false);
|
|
96
|
+
expect(pred(makeNote({ tags: ["reader", "important"] }))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("registerTriggers — dispatch modes", () => {
|
|
101
|
+
let webhookServer: ReturnType<typeof Bun.serve>;
|
|
102
|
+
let webhookPort: number;
|
|
103
|
+
let lastRequest: { method: string; url: string; headers: Headers; body: unknown; formData?: FormData } | null = null;
|
|
104
|
+
let webhookHandler: (req: Request) => Response | Promise<Response>;
|
|
105
|
+
|
|
106
|
+
beforeAll(() => {
|
|
107
|
+
webhookHandler = () => Response.json({});
|
|
108
|
+
webhookServer = Bun.serve({
|
|
109
|
+
hostname: "127.0.0.1",
|
|
110
|
+
port: 0,
|
|
111
|
+
async fetch(req) {
|
|
112
|
+
const url = new URL(req.url);
|
|
113
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
114
|
+
if (contentType.includes("json")) {
|
|
115
|
+
lastRequest = { method: req.method, url: url.pathname, headers: req.headers, body: await req.json() };
|
|
116
|
+
} else if (contentType.includes("multipart")) {
|
|
117
|
+
const formData = await req.formData();
|
|
118
|
+
lastRequest = { method: req.method, url: url.pathname, headers: req.headers, body: null, formData };
|
|
119
|
+
} else {
|
|
120
|
+
lastRequest = { method: req.method, url: url.pathname, headers: req.headers, body: await req.text() };
|
|
121
|
+
}
|
|
122
|
+
return webhookHandler(req);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
webhookPort = webhookServer.port;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(() => {
|
|
129
|
+
webhookServer?.stop(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function makeMockStore(note: Note, attachments: Attachment[] = []): Store {
|
|
133
|
+
const notes = new Map<string, Note>();
|
|
134
|
+
notes.set(note.id, { ...note });
|
|
135
|
+
const attachmentStore = new Map<string, Attachment[]>();
|
|
136
|
+
attachmentStore.set(note.id, [...attachments]);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
getNote: (id: string) => notes.get(id) ?? null,
|
|
140
|
+
updateNote: (id: string, updates: Record<string, unknown>) => {
|
|
141
|
+
const n = notes.get(id);
|
|
142
|
+
if (!n) throw new Error(`note ${id} not found`);
|
|
143
|
+
if (updates.content !== undefined) n.content = updates.content as string;
|
|
144
|
+
if (updates.metadata !== undefined) n.metadata = updates.metadata as Record<string, unknown>;
|
|
145
|
+
notes.set(id, n);
|
|
146
|
+
return n;
|
|
147
|
+
},
|
|
148
|
+
getAttachments: (id: string) => attachmentStore.get(id) ?? [],
|
|
149
|
+
addAttachment: (noteId: string, path: string, mimeType: string, meta?: Record<string, unknown>) => {
|
|
150
|
+
const att: Attachment = { id: crypto.randomUUID(), noteId, path, mimeType, metadata: meta, createdAt: new Date().toISOString() };
|
|
151
|
+
const existing = attachmentStore.get(noteId) ?? [];
|
|
152
|
+
existing.push(att);
|
|
153
|
+
attachmentStore.set(noteId, existing);
|
|
154
|
+
return att;
|
|
155
|
+
},
|
|
156
|
+
} as unknown as Store;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it("send=json dispatches full note payload (default behavior)", async () => {
|
|
160
|
+
const hooks = new HookRegistry();
|
|
161
|
+
const note = makeNote({ id: "n1", content: "hello", tags: ["test"] });
|
|
162
|
+
const store = makeMockStore(note);
|
|
163
|
+
|
|
164
|
+
webhookHandler = () => Response.json({ metadata: { processed: true } });
|
|
165
|
+
|
|
166
|
+
registerTriggers(hooks, [{
|
|
167
|
+
name: "json_test",
|
|
168
|
+
when: { tags: ["test"] },
|
|
169
|
+
action: { webhook: `http://127.0.0.1:${webhookPort}/hook` },
|
|
170
|
+
}], { error: () => {}, info: () => {} });
|
|
171
|
+
|
|
172
|
+
await hooks.dispatch("created", note, store);
|
|
173
|
+
// Give async handler time to complete
|
|
174
|
+
await new Promise(r => setTimeout(r, 50));
|
|
175
|
+
|
|
176
|
+
expect(lastRequest).not.toBeNull();
|
|
177
|
+
expect(lastRequest!.method).toBe("POST");
|
|
178
|
+
const body = lastRequest!.body as Record<string, unknown>;
|
|
179
|
+
expect(body.trigger).toBe("json_test");
|
|
180
|
+
expect((body.note as Record<string, unknown>).content).toBe("hello");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("send=attachment sends multipart form-data with audio file", async () => {
|
|
184
|
+
const hooks = new HookRegistry();
|
|
185
|
+
const note = makeNote({ id: "n2", content: "", tags: ["capture"] });
|
|
186
|
+
|
|
187
|
+
// Create a temp audio file
|
|
188
|
+
const tmpDir = `/tmp/trigger-test-${Date.now()}`;
|
|
189
|
+
const { mkdirSync, writeFileSync } = await import("fs");
|
|
190
|
+
mkdirSync(`${tmpDir}/2026-04-11`, { recursive: true });
|
|
191
|
+
writeFileSync(`${tmpDir}/2026-04-11/recording.wav`, Buffer.from("fake-wav-bytes"));
|
|
192
|
+
|
|
193
|
+
const attachment: Attachment = {
|
|
194
|
+
id: "att-1",
|
|
195
|
+
noteId: "n2",
|
|
196
|
+
path: "2026-04-11/recording.wav",
|
|
197
|
+
mimeType: "audio/wav",
|
|
198
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
199
|
+
};
|
|
200
|
+
const store = makeMockStore(note, [attachment]);
|
|
201
|
+
|
|
202
|
+
// Mock getVaultNameForStore and assetsDir to use our tmp dir
|
|
203
|
+
const originalAssetsDir = process.env.ASSETS_DIR;
|
|
204
|
+
process.env.ASSETS_DIR = tmpDir;
|
|
205
|
+
|
|
206
|
+
webhookHandler = () => Response.json({ text: "transcribed content" });
|
|
207
|
+
|
|
208
|
+
registerTriggers(hooks, [{
|
|
209
|
+
name: "attachment_test",
|
|
210
|
+
when: { tags: ["capture"], has_content: false },
|
|
211
|
+
action: {
|
|
212
|
+
webhook: `http://127.0.0.1:${webhookPort}/transcribe`,
|
|
213
|
+
send: "attachment",
|
|
214
|
+
},
|
|
215
|
+
}], { error: () => {}, info: () => {} });
|
|
216
|
+
|
|
217
|
+
await hooks.dispatch("created", note, store);
|
|
218
|
+
await new Promise(r => setTimeout(r, 50));
|
|
219
|
+
|
|
220
|
+
expect(lastRequest).not.toBeNull();
|
|
221
|
+
expect(lastRequest!.formData).toBeDefined();
|
|
222
|
+
const file = lastRequest!.formData!.get("file");
|
|
223
|
+
expect(file).toBeInstanceOf(File);
|
|
224
|
+
expect((file as File).name).toBe("recording.wav");
|
|
225
|
+
|
|
226
|
+
// Verify note content was updated
|
|
227
|
+
const updated = store.getNote("n2");
|
|
228
|
+
expect(updated?.content).toBe("transcribed content");
|
|
229
|
+
|
|
230
|
+
// Cleanup
|
|
231
|
+
if (originalAssetsDir) process.env.ASSETS_DIR = originalAssetsDir;
|
|
232
|
+
else delete process.env.ASSETS_DIR;
|
|
233
|
+
const { rmSync } = await import("fs");
|
|
234
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("send=content sends TTS input and saves audio response as attachment", async () => {
|
|
238
|
+
const hooks = new HookRegistry();
|
|
239
|
+
const note = makeNote({ id: "n3", content: "Hello world", tags: ["reader"] });
|
|
240
|
+
const store = makeMockStore(note);
|
|
241
|
+
|
|
242
|
+
const tmpDir = `/tmp/trigger-test-tts-${Date.now()}`;
|
|
243
|
+
const { mkdirSync } = await import("fs");
|
|
244
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
245
|
+
|
|
246
|
+
const originalAssetsDir = process.env.ASSETS_DIR;
|
|
247
|
+
process.env.ASSETS_DIR = tmpDir;
|
|
248
|
+
|
|
249
|
+
const fakeAudio = Buffer.from("fake-ogg-opus-audio");
|
|
250
|
+
webhookHandler = () => new Response(fakeAudio, {
|
|
251
|
+
headers: {
|
|
252
|
+
"Content-Type": "audio/ogg",
|
|
253
|
+
"X-TTS-Provider": "kokoro",
|
|
254
|
+
"X-TTS-Voice": "af_heart",
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
registerTriggers(hooks, [{
|
|
259
|
+
name: "content_test",
|
|
260
|
+
when: { tags: ["reader"], has_content: true },
|
|
261
|
+
action: {
|
|
262
|
+
webhook: `http://127.0.0.1:${webhookPort}/speech`,
|
|
263
|
+
send: "content",
|
|
264
|
+
},
|
|
265
|
+
}], { error: () => {}, info: () => {} });
|
|
266
|
+
|
|
267
|
+
await hooks.dispatch("created", note, store);
|
|
268
|
+
await new Promise(r => setTimeout(r, 50));
|
|
269
|
+
|
|
270
|
+
expect(lastRequest).not.toBeNull();
|
|
271
|
+
const body = lastRequest!.body as Record<string, unknown>;
|
|
272
|
+
expect(body.input).toBe("Hello world");
|
|
273
|
+
|
|
274
|
+
// Verify attachment was created
|
|
275
|
+
const attachments = store.getAttachments("n3");
|
|
276
|
+
expect(attachments.length).toBe(1);
|
|
277
|
+
expect(attachments[0].mimeType).toBe("audio/ogg");
|
|
278
|
+
|
|
279
|
+
// Verify metadata includes provider info
|
|
280
|
+
const updated = store.getNote("n3");
|
|
281
|
+
const meta = updated?.metadata as Record<string, unknown>;
|
|
282
|
+
expect(meta.tts_provider).toBe("kokoro");
|
|
283
|
+
expect(meta.tts_voice).toBe("af_heart");
|
|
284
|
+
expect(meta.content_test_rendered_at).toBeDefined();
|
|
285
|
+
|
|
286
|
+
// Cleanup
|
|
287
|
+
if (originalAssetsDir) process.env.ASSETS_DIR = originalAssetsDir;
|
|
288
|
+
else delete process.env.ASSETS_DIR;
|
|
289
|
+
const { rmSync } = await import("fs");
|
|
290
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("send=attachment skips when no audio attachment exists", async () => {
|
|
294
|
+
const hooks = new HookRegistry();
|
|
295
|
+
const note = makeNote({ id: "n4", content: "", tags: ["capture"] });
|
|
296
|
+
const store = makeMockStore(note);
|
|
297
|
+
|
|
298
|
+
registerTriggers(hooks, [{
|
|
299
|
+
name: "skip_test",
|
|
300
|
+
when: { tags: ["capture"], has_content: false },
|
|
301
|
+
action: {
|
|
302
|
+
webhook: `http://127.0.0.1:${webhookPort}/transcribe`,
|
|
303
|
+
send: "attachment",
|
|
304
|
+
},
|
|
305
|
+
}], { error: () => {}, info: () => {} });
|
|
306
|
+
|
|
307
|
+
await hooks.dispatch("created", note, store);
|
|
308
|
+
await new Promise(r => setTimeout(r, 50));
|
|
309
|
+
|
|
310
|
+
const updated = store.getNote("n4");
|
|
311
|
+
const meta = updated?.metadata as Record<string, unknown>;
|
|
312
|
+
expect(meta.skip_test_skipped_reason).toBe("no audio attachment found");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("send=content skips when note content is empty", async () => {
|
|
316
|
+
const hooks = new HookRegistry();
|
|
317
|
+
// Note: predicate has_content would normally filter this, but test the dispatch guard
|
|
318
|
+
const note = makeNote({ id: "n5", content: "", tags: ["reader"] });
|
|
319
|
+
const store = makeMockStore(note);
|
|
320
|
+
|
|
321
|
+
registerTriggers(hooks, [{
|
|
322
|
+
name: "empty_test",
|
|
323
|
+
when: { tags: ["reader"] }, // no has_content filter — tests the dispatch guard
|
|
324
|
+
action: {
|
|
325
|
+
webhook: `http://127.0.0.1:${webhookPort}/speech`,
|
|
326
|
+
send: "content",
|
|
327
|
+
},
|
|
328
|
+
}], { error: () => {}, info: () => {} });
|
|
329
|
+
|
|
330
|
+
await hooks.dispatch("created", note, store);
|
|
331
|
+
await new Promise(r => setTimeout(r, 50));
|
|
332
|
+
|
|
333
|
+
const updated = store.getNote("n5");
|
|
334
|
+
const meta = updated?.metadata as Record<string, unknown>;
|
|
335
|
+
expect(meta.empty_test_skipped_reason).toBe("note has no content to synthesize");
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe("registerTriggers — validation", () => {
|
|
340
|
+
it("skips triggers with invalid webhook URLs", () => {
|
|
341
|
+
const hooks = new HookRegistry();
|
|
342
|
+
const errors: string[] = [];
|
|
343
|
+
const logger = {
|
|
344
|
+
error: (...args: unknown[]) => errors.push(args.map(String).join(" ")),
|
|
345
|
+
info: () => {},
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
registerTriggers(hooks, [
|
|
349
|
+
{
|
|
350
|
+
name: "bad-url",
|
|
351
|
+
when: { tags: ["test"] },
|
|
352
|
+
action: { webhook: "not-a-url" },
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "bad-scheme",
|
|
356
|
+
when: { tags: ["test"] },
|
|
357
|
+
action: { webhook: "ftp://example.com/hook" },
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "good",
|
|
361
|
+
when: { tags: ["test"] },
|
|
362
|
+
action: { webhook: "http://localhost:8080/hook" },
|
|
363
|
+
},
|
|
364
|
+
], logger);
|
|
365
|
+
|
|
366
|
+
expect(hooks.size).toBe(1); // only "good" registered
|
|
367
|
+
expect(errors.length).toBe(2);
|
|
368
|
+
expect(errors[0]).toContain("bad-url");
|
|
369
|
+
expect(errors[1]).toContain("bad-scheme");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("registers triggers with send/response modes", () => {
|
|
373
|
+
const hooks = new HookRegistry();
|
|
374
|
+
const infos: string[] = [];
|
|
375
|
+
const logger = {
|
|
376
|
+
error: () => {},
|
|
377
|
+
info: (...args: unknown[]) => infos.push(args.map(String).join(" ")),
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
registerTriggers(hooks, [
|
|
381
|
+
{
|
|
382
|
+
name: "tts",
|
|
383
|
+
when: { tags: ["reader"] },
|
|
384
|
+
action: { webhook: "http://localhost:3100/v1/audio/speech", send: "content" },
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "transcribe",
|
|
388
|
+
when: { tags: ["capture"] },
|
|
389
|
+
action: { webhook: "http://localhost:3200/v1/audio/transcriptions", send: "attachment" },
|
|
390
|
+
},
|
|
391
|
+
], logger);
|
|
392
|
+
|
|
393
|
+
expect(hooks.size).toBe(2);
|
|
394
|
+
expect(infos.some(s => s.includes("send=content"))).toBe(true);
|
|
395
|
+
expect(infos.some(s => s.includes("send=attachment"))).toBe(true);
|
|
396
|
+
});
|
|
397
|
+
});
|