heyio 0.42.0 → 1.0.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/README.md +40 -52
- package/dist/api/auth.js +35 -38
- package/dist/api/server.js +157 -1139
- package/dist/config.js +49 -32
- package/dist/copilot/agents.js +72 -1055
- package/dist/copilot/client.js +6 -17
- package/dist/copilot/io-scheduler.js +55 -139
- package/dist/copilot/model-router.js +100 -72
- package/dist/copilot/orchestrator.js +91 -515
- package/dist/copilot/scheduler.js +67 -189
- package/dist/copilot/skills.js +41 -366
- package/dist/copilot/system-message.js +40 -200
- package/dist/copilot/tools.js +191 -2042
- package/dist/daemon.js +54 -201
- package/dist/index.js +15 -133
- package/dist/mcp/config.js +23 -31
- package/dist/mcp/index.js +2 -3
- package/dist/mcp/registry.js +33 -88
- package/dist/notify.js +18 -100
- package/dist/paths.js +13 -24
- package/dist/setup.js +35 -0
- package/dist/store/db.js +111 -297
- package/dist/store/feed.js +29 -97
- package/dist/store/instances.js +56 -121
- package/dist/store/schedules.js +21 -73
- package/dist/store/squads.js +35 -186
- package/dist/store/tasks.js +25 -168
- package/dist/telegram/bot.js +20 -312
- package/dist/telegram/handlers.js +39 -3
- package/dist/watchdog.js +31 -45
- package/dist/wiki/fs.js +38 -155
- package/dist/wiki/search.js +31 -44
- package/package.json +5 -8
- package/web-dist/assets/ChatView-EFFiln1H.js +11 -0
- package/web-dist/assets/FeedView-bN4NMOL7.js +6 -0
- package/web-dist/assets/LoginView-CNtasq3n.js +1 -0
- package/web-dist/assets/McpView-C2CHiwsi.js +1 -0
- package/web-dist/assets/SchedulesView-CyilLban.js +1 -0
- package/web-dist/assets/SettingsView-1wLXKEF4.js +1 -0
- package/web-dist/assets/SkillsView-BLsD-0u0.js +1 -0
- package/web-dist/assets/SquadDetailView-CsCw2ZLp.js +21 -0
- package/web-dist/assets/SquadsView-DQ3vFlyO.js +6 -0
- package/web-dist/assets/WikiView-19M3oqnq.js +21 -0
- package/web-dist/assets/api-WGvTsXaE.js +1 -0
- package/web-dist/assets/index-D7M5O-_l.css +1 -0
- package/web-dist/assets/index-DZOS9syn.js +95 -0
- package/web-dist/assets/plus-BOvyX1BC.js +6 -0
- package/web-dist/assets/trash-2-DHoetkC4.js +6 -0
- package/web-dist/favicon.svg +4 -1
- package/web-dist/index.html +7 -10
- package/dist/api/logout.test.js +0 -129
- package/dist/api/mcp.test.js +0 -285
- package/dist/api/wiki.test.js +0 -283
- package/dist/auth/session-logic.js +0 -79
- package/dist/auth/session-logic.test.js +0 -201
- package/dist/copilot/auto-complete-instance.test.js +0 -104
- package/dist/copilot/cron.js +0 -136
- package/dist/copilot/event-summary.js +0 -286
- package/dist/copilot/instance-deactivate.test.js +0 -119
- package/dist/copilot/model-router.test.js +0 -71
- package/dist/copilot/review-backfill.js +0 -57
- package/dist/copilot/session-timeout.js +0 -112
- package/dist/copilot/session-timeout.test.js +0 -372
- package/dist/copilot/skills.test.js +0 -55
- package/dist/copilot/universes.js +0 -469
- package/dist/instance-watchdog.js +0 -104
- package/dist/instance-watchdog.test.js +0 -183
- package/dist/mcp/client.js +0 -109
- package/dist/mcp/client.test.js +0 -99
- package/dist/mcp/config.test.js +0 -49
- package/dist/mcp/registry.test.js +0 -79
- package/dist/notify.test.js +0 -232
- package/dist/store/feed.test.js +0 -279
- package/dist/store/instances.test.js +0 -310
- package/dist/store/io-schedules.js +0 -63
- package/dist/store/notifications.js +0 -79
- package/dist/store/notifications.test.js +0 -197
- package/dist/store/schedule-runs.js +0 -46
- package/dist/store/squads.test.js +0 -405
- package/dist/store/tasks.test.js +0 -150
- package/dist/store/worktrees.js +0 -83
- package/dist/tui/index.js +0 -286
- package/dist/update.js +0 -81
- package/dist/watchdog.test.js +0 -83
- package/dist/wiki/wiki-squad.test.js +0 -54
- package/web-dist/assets/AgentActivityView-CedxxE6K.js +0 -1
- package/web-dist/assets/ChatView-DMkYQo_V.js +0 -4
- package/web-dist/assets/FeedView-BH4q-31V.js +0 -1
- package/web-dist/assets/InboxView-BVwVP4EW.js +0 -1
- package/web-dist/assets/LoginView-DRPDhnwu.js +0 -1
- package/web-dist/assets/McpView-D8yWz-lq.js +0 -1
- package/web-dist/assets/SchedulesView-BzzyncGF.js +0 -1
- package/web-dist/assets/SettingsTabs.vue_vue_type_script_setup_true_lang-oW3ySu7Y.js +0 -1
- package/web-dist/assets/SkillsView-oxpYuhx7.js +0 -1
- package/web-dist/assets/SquadsView-CaKUIKlq.js +0 -1
- package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-8U15Qp_Q.js +0 -1
- package/web-dist/assets/WikiView-C5jXUlfW.js +0 -1
- package/web-dist/assets/index-BrWzNw-N.css +0 -10
- package/web-dist/assets/index-f67odrrt.js +0 -81
- package/web-dist/icons.svg +0 -24
package/dist/notify.test.js
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for src/notify.ts — isMeaningfulOutput heuristic and notifyBackground
|
|
3
|
-
* dispatch routing.
|
|
4
|
-
*
|
|
5
|
-
* DB isolation: setDbPathForTests() redirects the SQLite singleton to a
|
|
6
|
-
* fresh tmp file so these tests never touch ~/.io/io.db.
|
|
7
|
-
*/
|
|
8
|
-
import { before, after, afterEach, describe, it } from "node:test";
|
|
9
|
-
import assert from "node:assert/strict";
|
|
10
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
-
import { tmpdir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { setDbPathForTests, closeDb } from "./store/db.js";
|
|
14
|
-
import { config } from "./config.js";
|
|
15
|
-
import { isMeaningfulOutput, notifyBackground, setTelegramSender, setTuiSender, setSseBroadcaster, _resetNotifySendersForTests, } from "./notify.js";
|
|
16
|
-
// ── DB isolation ────────────────────────────────────────────────────────────
|
|
17
|
-
let tmpDir;
|
|
18
|
-
before(() => {
|
|
19
|
-
tmpDir = mkdtempSync(join(tmpdir(), "io-notify-test-"));
|
|
20
|
-
setDbPathForTests(join(tmpDir, "io.db"));
|
|
21
|
-
});
|
|
22
|
-
after(() => {
|
|
23
|
-
closeDb();
|
|
24
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
-
});
|
|
26
|
-
// ── Config teardown ─────────────────────────────────────────────────────────
|
|
27
|
-
const origMode = config.backgroundNotifyMode;
|
|
28
|
-
const origTelegram = config.backgroundNotifyTelegram;
|
|
29
|
-
const origTui = config.backgroundNotifyTui;
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
config.backgroundNotifyMode = origMode;
|
|
32
|
-
config.backgroundNotifyTelegram = origTelegram;
|
|
33
|
-
config.backgroundNotifyTui = origTui;
|
|
34
|
-
_resetNotifySendersForTests();
|
|
35
|
-
});
|
|
36
|
-
// ── isMeaningfulOutput ───────────────────────────────────────────────────────
|
|
37
|
-
describe("isMeaningfulOutput", () => {
|
|
38
|
-
describe("returns false for short/empty input", () => {
|
|
39
|
-
it("empty string", () => assert.equal(isMeaningfulOutput(""), false));
|
|
40
|
-
it("whitespace only", () => assert.equal(isMeaningfulOutput(" "), false));
|
|
41
|
-
it("under 20 chars", () => assert.equal(isMeaningfulOutput("short"), false));
|
|
42
|
-
it("exactly 19 chars", () => assert.equal(isMeaningfulOutput("a".repeat(19)), false));
|
|
43
|
-
});
|
|
44
|
-
describe("returns false for heartbeat phrases", () => {
|
|
45
|
-
const phrases = [
|
|
46
|
-
"no active tasks",
|
|
47
|
-
"no active task",
|
|
48
|
-
"nothing to report.",
|
|
49
|
-
"Nothing to report.",
|
|
50
|
-
"ALL CLEAR",
|
|
51
|
-
"no updates",
|
|
52
|
-
"no update",
|
|
53
|
-
"no changes",
|
|
54
|
-
"no change",
|
|
55
|
-
"idle",
|
|
56
|
-
"IDLE",
|
|
57
|
-
"heartbeat",
|
|
58
|
-
"ok",
|
|
59
|
-
"OK",
|
|
60
|
-
];
|
|
61
|
-
for (const phrase of phrases) {
|
|
62
|
-
it(`"${phrase}"`, () => assert.equal(isMeaningfulOutput(phrase), false));
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
it("returns false when heartbeat phrase is first non-empty line (blank preamble)", () => {
|
|
66
|
-
const text = "\n\n \nno active tasks\nsome more content here that is long";
|
|
67
|
-
assert.equal(isMeaningfulOutput(text), false);
|
|
68
|
-
});
|
|
69
|
-
it("returns false when heartbeat is surrounded by whitespace on its line", () => {
|
|
70
|
-
assert.equal(isMeaningfulOutput(" idle "), false);
|
|
71
|
-
});
|
|
72
|
-
it("returns true for normal multi-sentence prose ≥20 chars", () => {
|
|
73
|
-
assert.equal(isMeaningfulOutput("Task 1 completed successfully. Moving to step 2."), true);
|
|
74
|
-
});
|
|
75
|
-
it("returns true for a bulleted report whose first line is not a heartbeat", () => {
|
|
76
|
-
const text = [
|
|
77
|
-
"Squad status update:",
|
|
78
|
-
"- Lion-O: working on PR #80",
|
|
79
|
-
"- Tygra: reviewing PR #79",
|
|
80
|
-
"- Cheetara: idle",
|
|
81
|
-
].join("\n");
|
|
82
|
-
assert.equal(isMeaningfulOutput(text), true);
|
|
83
|
-
});
|
|
84
|
-
it("returns true for exactly 20 chars", () => {
|
|
85
|
-
assert.equal(isMeaningfulOutput("a".repeat(20)), true);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
// ── notifyBackground dispatch routing ───────────────────────────────────────
|
|
89
|
-
const MEANINGFUL_TEXT = "Squad task monitor: PR #80 merged. Lion-O completed the delegation stats feature. All checks passing.";
|
|
90
|
-
const HEARTBEAT_TEXT = "no active tasks";
|
|
91
|
-
const SOURCE = {
|
|
92
|
-
type: "squad-schedule",
|
|
93
|
-
scheduleId: 7,
|
|
94
|
-
squadSlug: "michaeljolley-io",
|
|
95
|
-
scheduleName: "Active Task Monitor",
|
|
96
|
-
};
|
|
97
|
-
describe("notifyBackground", () => {
|
|
98
|
-
it('mode="off" → skipped:"off", no senders called, no row inserted', async () => {
|
|
99
|
-
config.backgroundNotifyMode = "off";
|
|
100
|
-
let called = false;
|
|
101
|
-
setSseBroadcaster(() => { called = true; });
|
|
102
|
-
setTuiSender(() => { called = true; });
|
|
103
|
-
setTelegramSender(async () => { called = true; });
|
|
104
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
105
|
-
assert.equal(result.skipped, "off");
|
|
106
|
-
assert.equal(result.id, undefined);
|
|
107
|
-
assert.equal(called, false);
|
|
108
|
-
});
|
|
109
|
-
it("empty text → skipped:empty, no row inserted", async () => {
|
|
110
|
-
config.backgroundNotifyMode = "all";
|
|
111
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: " " });
|
|
112
|
-
assert.equal(result.skipped, "empty");
|
|
113
|
-
assert.equal(result.id, undefined);
|
|
114
|
-
});
|
|
115
|
-
it('mode="meaningful" + heartbeat text → skipped:"not-meaningful", no row', async () => {
|
|
116
|
-
config.backgroundNotifyMode = "meaningful";
|
|
117
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: HEARTBEAT_TEXT });
|
|
118
|
-
assert.equal(result.skipped, "not-meaningful");
|
|
119
|
-
assert.equal(result.id, undefined);
|
|
120
|
-
});
|
|
121
|
-
it('mode="all" + heartbeat + all senders → all dispatch, row inserted', async () => {
|
|
122
|
-
config.backgroundNotifyMode = "all";
|
|
123
|
-
config.backgroundNotifyTelegram = true;
|
|
124
|
-
config.backgroundNotifyTui = true;
|
|
125
|
-
const calls = { sse: false, tui: false, telegram: false };
|
|
126
|
-
setSseBroadcaster(() => { calls.sse = true; });
|
|
127
|
-
setTuiSender(() => { calls.tui = true; });
|
|
128
|
-
setTelegramSender(async () => { calls.telegram = true; });
|
|
129
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: HEARTBEAT_TEXT });
|
|
130
|
-
assert.ok(result.id, "should return a row id");
|
|
131
|
-
assert.equal(result.dispatched.sse, true);
|
|
132
|
-
assert.equal(result.dispatched.tui, true);
|
|
133
|
-
assert.equal(result.dispatched.telegram, true);
|
|
134
|
-
assert.equal(calls.sse, true);
|
|
135
|
-
assert.equal(calls.tui, true);
|
|
136
|
-
assert.equal(calls.telegram, true);
|
|
137
|
-
assert.equal(result.skipped, undefined);
|
|
138
|
-
});
|
|
139
|
-
it('mode="meaningful" + meaningful text + all senders → all dispatch', async () => {
|
|
140
|
-
config.backgroundNotifyMode = "meaningful";
|
|
141
|
-
config.backgroundNotifyTelegram = true;
|
|
142
|
-
config.backgroundNotifyTui = true;
|
|
143
|
-
const calls = { sse: false, tui: false, telegram: false };
|
|
144
|
-
setSseBroadcaster(() => { calls.sse = true; });
|
|
145
|
-
setTuiSender(() => { calls.tui = true; });
|
|
146
|
-
setTelegramSender(async () => { calls.telegram = true; });
|
|
147
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
148
|
-
assert.ok(result.id);
|
|
149
|
-
assert.equal(result.dispatched.sse, true);
|
|
150
|
-
assert.equal(result.dispatched.tui, true);
|
|
151
|
-
assert.equal(result.dispatched.telegram, true);
|
|
152
|
-
});
|
|
153
|
-
it("backgroundNotifyTelegram=false → telegram skipped, tui+sse still fire", async () => {
|
|
154
|
-
config.backgroundNotifyMode = "all";
|
|
155
|
-
config.backgroundNotifyTelegram = false;
|
|
156
|
-
config.backgroundNotifyTui = true;
|
|
157
|
-
const calls = { sse: false, tui: false, telegram: false };
|
|
158
|
-
setSseBroadcaster(() => { calls.sse = true; });
|
|
159
|
-
setTuiSender(() => { calls.tui = true; });
|
|
160
|
-
setTelegramSender(async () => { calls.telegram = true; });
|
|
161
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
162
|
-
assert.equal(result.dispatched.telegram, false, "telegram should be skipped");
|
|
163
|
-
assert.equal(result.dispatched.tui, true);
|
|
164
|
-
assert.equal(result.dispatched.sse, true);
|
|
165
|
-
assert.equal(calls.telegram, false);
|
|
166
|
-
});
|
|
167
|
-
it("backgroundNotifyTui=false → tui skipped, telegram+sse still fire", async () => {
|
|
168
|
-
config.backgroundNotifyMode = "all";
|
|
169
|
-
config.backgroundNotifyTelegram = true;
|
|
170
|
-
config.backgroundNotifyTui = false;
|
|
171
|
-
const calls = { sse: false, tui: false, telegram: false };
|
|
172
|
-
setSseBroadcaster(() => { calls.sse = true; });
|
|
173
|
-
setTuiSender(() => { calls.tui = true; });
|
|
174
|
-
setTelegramSender(async () => { calls.telegram = true; });
|
|
175
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
176
|
-
assert.equal(result.dispatched.tui, false, "tui should be skipped");
|
|
177
|
-
assert.equal(result.dispatched.telegram, true);
|
|
178
|
-
assert.equal(result.dispatched.sse, true);
|
|
179
|
-
assert.equal(calls.tui, false);
|
|
180
|
-
});
|
|
181
|
-
it("telegram sender throws → dispatched.telegram=false, others true, no rethrow", async () => {
|
|
182
|
-
config.backgroundNotifyMode = "all";
|
|
183
|
-
config.backgroundNotifyTelegram = true;
|
|
184
|
-
config.backgroundNotifyTui = true;
|
|
185
|
-
const calls = { sse: false, tui: false };
|
|
186
|
-
setSseBroadcaster(() => { calls.sse = true; });
|
|
187
|
-
setTuiSender(() => { calls.tui = true; });
|
|
188
|
-
setTelegramSender(async () => { throw new Error("telegram down"); });
|
|
189
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
190
|
-
assert.equal(result.dispatched.telegram, false);
|
|
191
|
-
assert.equal(result.dispatched.sse, true);
|
|
192
|
-
assert.equal(result.dispatched.tui, true);
|
|
193
|
-
assert.ok(result.id, "row should still be inserted");
|
|
194
|
-
});
|
|
195
|
-
it("TUI sender throws → dispatched.tui=false, others true, no rethrow", async () => {
|
|
196
|
-
config.backgroundNotifyMode = "all";
|
|
197
|
-
config.backgroundNotifyTelegram = true;
|
|
198
|
-
config.backgroundNotifyTui = true;
|
|
199
|
-
setSseBroadcaster(() => { });
|
|
200
|
-
setTuiSender(() => { throw new Error("tui down"); });
|
|
201
|
-
setTelegramSender(async () => { });
|
|
202
|
-
const result = await notifyBackground({ source: SOURCE, title: "T", text: MEANINGFUL_TEXT });
|
|
203
|
-
assert.equal(result.dispatched.tui, false);
|
|
204
|
-
assert.equal(result.dispatched.sse, true);
|
|
205
|
-
assert.equal(result.dispatched.telegram, true);
|
|
206
|
-
});
|
|
207
|
-
it("squad-schedule source detail round-trips to SSE broadcaster payload", async () => {
|
|
208
|
-
config.backgroundNotifyMode = "all";
|
|
209
|
-
let capturedPayload;
|
|
210
|
-
setSseBroadcaster((payload) => { capturedPayload = payload; });
|
|
211
|
-
const source = {
|
|
212
|
-
type: "squad-schedule",
|
|
213
|
-
scheduleId: 42,
|
|
214
|
-
squadSlug: "thundercats",
|
|
215
|
-
scheduleName: "Morning Standup",
|
|
216
|
-
};
|
|
217
|
-
const result = await notifyBackground({
|
|
218
|
-
source,
|
|
219
|
-
title: "Morning Standup Result",
|
|
220
|
-
text: MEANINGFUL_TEXT,
|
|
221
|
-
});
|
|
222
|
-
assert.ok(capturedPayload, "SSE broadcaster should have been called");
|
|
223
|
-
assert.equal(capturedPayload.id, result.id);
|
|
224
|
-
assert.equal(capturedPayload.source.type, "squad-schedule");
|
|
225
|
-
assert.equal(capturedPayload.source.scheduleId, 42);
|
|
226
|
-
assert.equal(capturedPayload.source.squadSlug, "thundercats");
|
|
227
|
-
assert.equal(capturedPayload.source.scheduleName, "Morning Standup");
|
|
228
|
-
assert.equal(capturedPayload.title, "Morning Standup Result");
|
|
229
|
-
assert.ok(capturedPayload.createdAt, "createdAt should be set");
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
//# sourceMappingURL=notify.test.js.map
|
package/dist/store/feed.test.js
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for src/store/feed.ts — unified feed store.
|
|
3
|
-
*
|
|
4
|
-
* DB isolation: setDbPathForTests() redirects the SQLite singleton to a
|
|
5
|
-
* fresh tmp file so these tests never touch ~/.io/io.db.
|
|
6
|
-
*/
|
|
7
|
-
import { before, after, beforeEach, describe, it } from "node:test";
|
|
8
|
-
import assert from "node:assert/strict";
|
|
9
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { setDbPathForTests, closeDb, getDb } from "./db.js";
|
|
13
|
-
import { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, markFeedEntriesRead, deleteFeedEntry, deleteFeedEntries, pruneOldFeedEntries, } from "./feed.js";
|
|
14
|
-
// ── DB isolation ─────────────────────────────────────────────────────────────
|
|
15
|
-
let tmpDir;
|
|
16
|
-
before(() => {
|
|
17
|
-
tmpDir = mkdtempSync(join(tmpdir(), "io-feed-test-"));
|
|
18
|
-
setDbPathForTests(join(tmpDir, "io.db"));
|
|
19
|
-
});
|
|
20
|
-
after(() => {
|
|
21
|
-
closeDb();
|
|
22
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
-
});
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
getDb().prepare("DELETE FROM unified_feed").run();
|
|
26
|
-
});
|
|
27
|
-
// ── createFeedEntry ───────────────────────────────────────────────────────────
|
|
28
|
-
describe("createFeedEntry", () => {
|
|
29
|
-
it("creates a deliverable entry with correct fields", () => {
|
|
30
|
-
const entry = createFeedEntry({ type: "inbox", title: "Task done", body: "Here are the results." });
|
|
31
|
-
assert.equal(entry.type, "inbox");
|
|
32
|
-
assert.equal(entry.title, "Task done");
|
|
33
|
-
assert.equal(entry.body, "Here are the results.");
|
|
34
|
-
assert.equal(entry.read_at, null);
|
|
35
|
-
assert.equal(entry.source_type, null);
|
|
36
|
-
assert.equal(entry.source_ref, null);
|
|
37
|
-
assert.ok(entry.id > 0);
|
|
38
|
-
assert.ok(entry.created_at);
|
|
39
|
-
});
|
|
40
|
-
it("creates a notification entry with correct fields", () => {
|
|
41
|
-
const entry = createFeedEntry({ type: "notification", title: "Schedule ran", body: "Background task complete." });
|
|
42
|
-
assert.equal(entry.type, "notification");
|
|
43
|
-
assert.equal(entry.read_at, null);
|
|
44
|
-
});
|
|
45
|
-
it("stores source_type and source_ref when provided", () => {
|
|
46
|
-
const entry = createFeedEntry({
|
|
47
|
-
type: "notification",
|
|
48
|
-
title: "Sched",
|
|
49
|
-
body: "Done",
|
|
50
|
-
source_type: "io-schedule",
|
|
51
|
-
source_ref: JSON.stringify({ id: 42 }),
|
|
52
|
-
});
|
|
53
|
-
assert.equal(entry.source_type, "io-schedule");
|
|
54
|
-
assert.equal(entry.source_ref, JSON.stringify({ id: 42 }));
|
|
55
|
-
});
|
|
56
|
-
it("autoincrements ids", () => {
|
|
57
|
-
const a = createFeedEntry({ type: "inbox", title: "A", body: "a" });
|
|
58
|
-
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
59
|
-
assert.ok(b.id > a.id);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
// ── listFeedEntries ───────────────────────────────────────────────────────────
|
|
63
|
-
describe("listFeedEntries", () => {
|
|
64
|
-
it("returns all entries newest first", () => {
|
|
65
|
-
createFeedEntry({ type: "inbox", title: "First", body: "x" });
|
|
66
|
-
createFeedEntry({ type: "notification", title: "Second", body: "y" });
|
|
67
|
-
const entries = listFeedEntries();
|
|
68
|
-
assert.equal(entries.length, 2);
|
|
69
|
-
assert.equal(entries[0].title, "Second");
|
|
70
|
-
assert.equal(entries[1].title, "First");
|
|
71
|
-
});
|
|
72
|
-
it("filters by type=deliverable", () => {
|
|
73
|
-
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
74
|
-
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
75
|
-
const entries = listFeedEntries({ type: "inbox" });
|
|
76
|
-
assert.equal(entries.length, 1);
|
|
77
|
-
assert.equal(entries[0].type, "inbox");
|
|
78
|
-
});
|
|
79
|
-
it("filters by type=notification", () => {
|
|
80
|
-
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
81
|
-
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
82
|
-
const entries = listFeedEntries({ type: "notification" });
|
|
83
|
-
assert.equal(entries.length, 1);
|
|
84
|
-
assert.equal(entries[0].type, "notification");
|
|
85
|
-
});
|
|
86
|
-
it("filters by unreadOnly=true", () => {
|
|
87
|
-
const e = createFeedEntry({ type: "notification", title: "Read me", body: "u" });
|
|
88
|
-
createFeedEntry({ type: "notification", title: "Still unread", body: "v" });
|
|
89
|
-
markFeedEntryRead(e.id);
|
|
90
|
-
const entries = listFeedEntries({ unreadOnly: true });
|
|
91
|
-
assert.equal(entries.length, 1);
|
|
92
|
-
assert.equal(entries[0].title, "Still unread");
|
|
93
|
-
});
|
|
94
|
-
it("respects limit", () => {
|
|
95
|
-
for (let i = 0; i < 5; i++) {
|
|
96
|
-
createFeedEntry({ type: "notification", title: `N${i}`, body: "x" });
|
|
97
|
-
}
|
|
98
|
-
const entries = listFeedEntries({ limit: 3 });
|
|
99
|
-
assert.equal(entries.length, 3);
|
|
100
|
-
});
|
|
101
|
-
it("returns empty array on clean DB", () => {
|
|
102
|
-
assert.deepEqual(listFeedEntries(), []);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
// ── countUnreadFeedEntries ────────────────────────────────────────────────────
|
|
106
|
-
describe("countUnreadFeedEntries", () => {
|
|
107
|
-
it("returns 0 on clean DB", () => {
|
|
108
|
-
assert.equal(countUnreadFeedEntries(), 0);
|
|
109
|
-
});
|
|
110
|
-
it("increments on insert", () => {
|
|
111
|
-
createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
112
|
-
assert.equal(countUnreadFeedEntries(), 1);
|
|
113
|
-
createFeedEntry({ type: "notification", title: "N", body: "b" });
|
|
114
|
-
assert.equal(countUnreadFeedEntries(), 2);
|
|
115
|
-
});
|
|
116
|
-
it("decreases when marked read", () => {
|
|
117
|
-
const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
118
|
-
markFeedEntryRead(e.id);
|
|
119
|
-
assert.equal(countUnreadFeedEntries(), 0);
|
|
120
|
-
});
|
|
121
|
-
it("filters by type", () => {
|
|
122
|
-
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
123
|
-
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
124
|
-
assert.equal(countUnreadFeedEntries("inbox"), 1);
|
|
125
|
-
assert.equal(countUnreadFeedEntries("notification"), 1);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
// ── markFeedEntryRead ─────────────────────────────────────────────────────────
|
|
129
|
-
describe("markFeedEntryRead", () => {
|
|
130
|
-
it("returns false for a non-existent id", () => {
|
|
131
|
-
assert.equal(markFeedEntryRead(9999), false);
|
|
132
|
-
});
|
|
133
|
-
it("returns true and sets read_at for an unread entry", () => {
|
|
134
|
-
const e = createFeedEntry({ type: "notification", title: "T", body: "b" });
|
|
135
|
-
const result = markFeedEntryRead(e.id);
|
|
136
|
-
assert.equal(result, true);
|
|
137
|
-
const all = listFeedEntries();
|
|
138
|
-
const updated = all.find((x) => x.id === e.id);
|
|
139
|
-
assert.ok(updated.read_at !== null);
|
|
140
|
-
});
|
|
141
|
-
it("is idempotent — returns true if already read", () => {
|
|
142
|
-
const e = createFeedEntry({ type: "notification", title: "T", body: "b" });
|
|
143
|
-
markFeedEntryRead(e.id);
|
|
144
|
-
assert.equal(markFeedEntryRead(e.id), true);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
// ── markAllFeedEntriesRead ────────────────────────────────────────────────────
|
|
148
|
-
describe("markAllFeedEntriesRead", () => {
|
|
149
|
-
it("returns count of newly-marked rows", () => {
|
|
150
|
-
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
151
|
-
createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
152
|
-
const count = markAllFeedEntriesRead();
|
|
153
|
-
assert.equal(count, 2);
|
|
154
|
-
});
|
|
155
|
-
it("subsequent call returns 0 (all already read)", () => {
|
|
156
|
-
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
157
|
-
markAllFeedEntriesRead();
|
|
158
|
-
assert.equal(markAllFeedEntriesRead(), 0);
|
|
159
|
-
});
|
|
160
|
-
it("respects type filter — only marks matching type", () => {
|
|
161
|
-
createFeedEntry({ type: "inbox", title: "D", body: "d" });
|
|
162
|
-
createFeedEntry({ type: "notification", title: "N", body: "n" });
|
|
163
|
-
const count = markAllFeedEntriesRead("notification");
|
|
164
|
-
assert.equal(count, 1);
|
|
165
|
-
assert.equal(countUnreadFeedEntries("inbox"), 1);
|
|
166
|
-
assert.equal(countUnreadFeedEntries("notification"), 0);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
// ── markFeedEntriesRead (batch) ───────────────────────────────────────────────
|
|
170
|
-
describe("markFeedEntriesRead", () => {
|
|
171
|
-
it("marks multiple entries read and returns change count", () => {
|
|
172
|
-
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
173
|
-
const b = createFeedEntry({ type: "inbox", title: "B", body: "b" });
|
|
174
|
-
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
175
|
-
const count = markFeedEntriesRead([a.id, b.id, c.id]);
|
|
176
|
-
assert.equal(count, 3);
|
|
177
|
-
assert.equal(countUnreadFeedEntries(), 0);
|
|
178
|
-
});
|
|
179
|
-
it("returns 0 for an empty array without throwing", () => {
|
|
180
|
-
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
181
|
-
assert.equal(markFeedEntriesRead([]), 0);
|
|
182
|
-
assert.equal(countUnreadFeedEntries(), 1);
|
|
183
|
-
});
|
|
184
|
-
it("works correctly for a single id", () => {
|
|
185
|
-
const e = createFeedEntry({ type: "inbox", title: "Solo", body: "b" });
|
|
186
|
-
assert.equal(markFeedEntriesRead([e.id]), 1);
|
|
187
|
-
const entries = listFeedEntries();
|
|
188
|
-
assert.ok(entries[0].read_at !== null);
|
|
189
|
-
});
|
|
190
|
-
it("does not throw for non-existent ids — returns 0 changes", () => {
|
|
191
|
-
assert.equal(markFeedEntriesRead([9991, 9992, 9993]), 0);
|
|
192
|
-
});
|
|
193
|
-
it("is idempotent — already-read entries count as 0 changes", () => {
|
|
194
|
-
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
195
|
-
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
196
|
-
markFeedEntriesRead([a.id, b.id]);
|
|
197
|
-
assert.equal(markFeedEntriesRead([a.id, b.id]), 0);
|
|
198
|
-
});
|
|
199
|
-
it("skips already-read entries and marks only unread ones", () => {
|
|
200
|
-
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
201
|
-
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
202
|
-
markFeedEntriesRead([a.id]);
|
|
203
|
-
const count = markFeedEntriesRead([a.id, b.id]);
|
|
204
|
-
assert.equal(count, 1);
|
|
205
|
-
assert.equal(countUnreadFeedEntries(), 0);
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
// ── deleteFeedEntry ───────────────────────────────────────────────────────────
|
|
209
|
-
describe("deleteFeedEntry", () => {
|
|
210
|
-
it("returns false for a non-existent id", () => {
|
|
211
|
-
assert.equal(deleteFeedEntry(9999), false);
|
|
212
|
-
});
|
|
213
|
-
it("returns true and removes the entry", () => {
|
|
214
|
-
const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
215
|
-
assert.equal(deleteFeedEntry(e.id), true);
|
|
216
|
-
const entries = listFeedEntries();
|
|
217
|
-
assert.equal(entries.find((x) => x.id === e.id), undefined);
|
|
218
|
-
});
|
|
219
|
-
it("second delete returns false (not idempotent)", () => {
|
|
220
|
-
const e = createFeedEntry({ type: "inbox", title: "T", body: "b" });
|
|
221
|
-
deleteFeedEntry(e.id);
|
|
222
|
-
assert.equal(deleteFeedEntry(e.id), false);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
// ── deleteFeedEntries (batch) ─────────────────────────────────────────────────
|
|
226
|
-
describe("deleteFeedEntries", () => {
|
|
227
|
-
it("deletes multiple entries and returns change count", () => {
|
|
228
|
-
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
229
|
-
const b = createFeedEntry({ type: "inbox", title: "B", body: "b" });
|
|
230
|
-
const c = createFeedEntry({ type: "notification", title: "C", body: "c" });
|
|
231
|
-
const count = deleteFeedEntries([a.id, b.id, c.id]);
|
|
232
|
-
assert.equal(count, 3);
|
|
233
|
-
assert.deepEqual(listFeedEntries(), []);
|
|
234
|
-
});
|
|
235
|
-
it("returns 0 for an empty array without throwing", () => {
|
|
236
|
-
createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
237
|
-
assert.equal(deleteFeedEntries([]), 0);
|
|
238
|
-
assert.equal(listFeedEntries().length, 1);
|
|
239
|
-
});
|
|
240
|
-
it("works correctly for a single id", () => {
|
|
241
|
-
const a = createFeedEntry({ type: "notification", title: "A", body: "a" });
|
|
242
|
-
const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
|
|
243
|
-
assert.equal(deleteFeedEntries([a.id]), 1);
|
|
244
|
-
const remaining = listFeedEntries();
|
|
245
|
-
assert.equal(remaining.length, 1);
|
|
246
|
-
assert.equal(remaining[0].id, b.id);
|
|
247
|
-
});
|
|
248
|
-
it("does not throw for non-existent ids — returns 0 changes", () => {
|
|
249
|
-
assert.equal(deleteFeedEntries([9991, 9992, 9993]), 0);
|
|
250
|
-
});
|
|
251
|
-
it("mix of existing and non-existent ids — only deletes what exists", () => {
|
|
252
|
-
const e = createFeedEntry({ type: "inbox", title: "Real", body: "b" });
|
|
253
|
-
const count = deleteFeedEntries([e.id, 9999]);
|
|
254
|
-
assert.equal(count, 1);
|
|
255
|
-
assert.deepEqual(listFeedEntries(), []);
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
// ── pruneOldFeedEntries ───────────────────────────────────────────────────────
|
|
259
|
-
describe("pruneOldFeedEntries", () => {
|
|
260
|
-
it("returns 0 when nothing is old enough to prune", () => {
|
|
261
|
-
createFeedEntry({ type: "notification", title: "Fresh", body: "b" });
|
|
262
|
-
assert.equal(pruneOldFeedEntries(30), 0);
|
|
263
|
-
});
|
|
264
|
-
it("deletes rows older than threshold and returns count", () => {
|
|
265
|
-
getDb()
|
|
266
|
-
.prepare("INSERT INTO unified_feed (type, title, body, created_at) VALUES ('notification', 'Old', 'x', datetime('now', '-31 days'))")
|
|
267
|
-
.run();
|
|
268
|
-
createFeedEntry({ type: "notification", title: "Fresh", body: "y" });
|
|
269
|
-
const pruned = pruneOldFeedEntries(30);
|
|
270
|
-
assert.equal(pruned, 1);
|
|
271
|
-
const remaining = listFeedEntries();
|
|
272
|
-
assert.equal(remaining.length, 1);
|
|
273
|
-
assert.equal(remaining[0].title, "Fresh");
|
|
274
|
-
});
|
|
275
|
-
it("is safe on an empty table", () => {
|
|
276
|
-
assert.equal(pruneOldFeedEntries(30), 0);
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
//# sourceMappingURL=feed.test.js.map
|