inboxd 1.0.12 → 1.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/skills/inbox-assistant/SKILL.md +156 -9
- package/CLAUDE.md +39 -8
- package/package.json +1 -1
- package/src/archive-log.js +104 -0
- package/src/cli.js +531 -17
- package/src/deletion-log.js +101 -0
- package/src/gmail-monitor.js +58 -0
- package/src/sent-log.js +35 -0
- package/tests/archive-log.test.js +196 -0
- package/tests/cleanup-suggest.test.js +239 -0
- package/tests/gmail-monitor.test.js +293 -0
- package/tests/install-service.test.js +210 -0
- package/tests/interactive-confirm.test.js +175 -0
- package/tests/json-output.test.js +189 -0
- package/tests/stats.test.js +218 -0
- package/tests/unarchive.test.js +228 -0
|
@@ -132,4 +132,297 @@ describe('Gmail Monitor Logic', () => {
|
|
|
132
132
|
}
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
describe('Mark As Read Logic', () => {
|
|
137
|
+
it('should mark email as read by removing UNREAD label', async () => {
|
|
138
|
+
const mockGmail = {
|
|
139
|
+
users: {
|
|
140
|
+
messages: {
|
|
141
|
+
modify: vi.fn().mockResolvedValue({ data: { id: 'msg123', labelIds: ['INBOX'] } })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const messageIds = ['msg123'];
|
|
147
|
+
const results = [];
|
|
148
|
+
|
|
149
|
+
for (const id of messageIds) {
|
|
150
|
+
try {
|
|
151
|
+
await mockGmail.users.messages.modify({
|
|
152
|
+
userId: 'me',
|
|
153
|
+
id: id,
|
|
154
|
+
requestBody: { removeLabelIds: ['UNREAD'] }
|
|
155
|
+
});
|
|
156
|
+
results.push({ id, success: true });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
results.push({ id, success: false, error: err.message });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
expect(results).toHaveLength(1);
|
|
163
|
+
expect(results[0].success).toBe(true);
|
|
164
|
+
expect(mockGmail.users.messages.modify).toHaveBeenCalledWith({
|
|
165
|
+
userId: 'me',
|
|
166
|
+
id: 'msg123',
|
|
167
|
+
requestBody: { removeLabelIds: ['UNREAD'] }
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should handle errors during mark as read', async () => {
|
|
172
|
+
const mockGmail = {
|
|
173
|
+
users: {
|
|
174
|
+
messages: {
|
|
175
|
+
modify: vi.fn().mockRejectedValue(new Error('API Error'))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const messageIds = ['msg123'];
|
|
181
|
+
const results = [];
|
|
182
|
+
|
|
183
|
+
for (const id of messageIds) {
|
|
184
|
+
try {
|
|
185
|
+
await mockGmail.users.messages.modify({
|
|
186
|
+
userId: 'me',
|
|
187
|
+
id: id,
|
|
188
|
+
requestBody: { removeLabelIds: ['UNREAD'] }
|
|
189
|
+
});
|
|
190
|
+
results.push({ id, success: true });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
results.push({ id, success: false, error: err.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
expect(results).toHaveLength(1);
|
|
197
|
+
expect(results[0].success).toBe(false);
|
|
198
|
+
expect(results[0].error).toBe('API Error');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle multiple message IDs for mark as read', async () => {
|
|
202
|
+
const mockModify = vi.fn()
|
|
203
|
+
.mockResolvedValueOnce({ data: { id: 'msg1' } })
|
|
204
|
+
.mockResolvedValueOnce({ data: { id: 'msg2' } })
|
|
205
|
+
.mockRejectedValueOnce(new Error('Not found'));
|
|
206
|
+
|
|
207
|
+
const mockGmail = {
|
|
208
|
+
users: { messages: { modify: mockModify } }
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const messageIds = ['msg1', 'msg2', 'msg3'];
|
|
212
|
+
const results = [];
|
|
213
|
+
|
|
214
|
+
for (const id of messageIds) {
|
|
215
|
+
try {
|
|
216
|
+
await mockGmail.users.messages.modify({
|
|
217
|
+
userId: 'me',
|
|
218
|
+
id: id,
|
|
219
|
+
requestBody: { removeLabelIds: ['UNREAD'] }
|
|
220
|
+
});
|
|
221
|
+
results.push({ id, success: true });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
results.push({ id, success: false, error: err.message });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
expect(results).toHaveLength(3);
|
|
228
|
+
expect(results[0]).toEqual({ id: 'msg1', success: true });
|
|
229
|
+
expect(results[1]).toEqual({ id: 'msg2', success: true });
|
|
230
|
+
expect(results[2]).toEqual({ id: 'msg3', success: false, error: 'Not found' });
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('Mark As Unread Logic', () => {
|
|
235
|
+
it('should mark email as unread by adding UNREAD label', async () => {
|
|
236
|
+
const mockGmail = {
|
|
237
|
+
users: {
|
|
238
|
+
messages: {
|
|
239
|
+
modify: vi.fn().mockResolvedValue({ data: { id: 'msg123', labelIds: ['INBOX', 'UNREAD'] } })
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const messageIds = ['msg123'];
|
|
245
|
+
const results = [];
|
|
246
|
+
|
|
247
|
+
for (const id of messageIds) {
|
|
248
|
+
try {
|
|
249
|
+
await mockGmail.users.messages.modify({
|
|
250
|
+
userId: 'me',
|
|
251
|
+
id: id,
|
|
252
|
+
requestBody: { addLabelIds: ['UNREAD'] }
|
|
253
|
+
});
|
|
254
|
+
results.push({ id, success: true });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
results.push({ id, success: false, error: err.message });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
expect(results).toHaveLength(1);
|
|
261
|
+
expect(results[0].success).toBe(true);
|
|
262
|
+
expect(mockGmail.users.messages.modify).toHaveBeenCalledWith({
|
|
263
|
+
userId: 'me',
|
|
264
|
+
id: 'msg123',
|
|
265
|
+
requestBody: { addLabelIds: ['UNREAD'] }
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should handle errors during mark as unread', async () => {
|
|
270
|
+
const mockGmail = {
|
|
271
|
+
users: {
|
|
272
|
+
messages: {
|
|
273
|
+
modify: vi.fn().mockRejectedValue(new Error('Permission denied'))
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const messageIds = ['msg123'];
|
|
279
|
+
const results = [];
|
|
280
|
+
|
|
281
|
+
for (const id of messageIds) {
|
|
282
|
+
try {
|
|
283
|
+
await mockGmail.users.messages.modify({
|
|
284
|
+
userId: 'me',
|
|
285
|
+
id: id,
|
|
286
|
+
requestBody: { addLabelIds: ['UNREAD'] }
|
|
287
|
+
});
|
|
288
|
+
results.push({ id, success: true });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
results.push({ id, success: false, error: err.message });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
expect(results).toHaveLength(1);
|
|
295
|
+
expect(results[0].success).toBe(false);
|
|
296
|
+
expect(results[0].error).toBe('Permission denied');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should handle multiple message IDs for mark as unread', async () => {
|
|
300
|
+
const mockModify = vi.fn()
|
|
301
|
+
.mockResolvedValueOnce({ data: { id: 'msg1' } })
|
|
302
|
+
.mockRejectedValueOnce(new Error('Not found'))
|
|
303
|
+
.mockResolvedValueOnce({ data: { id: 'msg3' } });
|
|
304
|
+
|
|
305
|
+
const mockGmail = {
|
|
306
|
+
users: { messages: { modify: mockModify } }
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const messageIds = ['msg1', 'msg2', 'msg3'];
|
|
310
|
+
const results = [];
|
|
311
|
+
|
|
312
|
+
for (const id of messageIds) {
|
|
313
|
+
try {
|
|
314
|
+
await mockGmail.users.messages.modify({
|
|
315
|
+
userId: 'me',
|
|
316
|
+
id: id,
|
|
317
|
+
requestBody: { addLabelIds: ['UNREAD'] }
|
|
318
|
+
});
|
|
319
|
+
results.push({ id, success: true });
|
|
320
|
+
} catch (err) {
|
|
321
|
+
results.push({ id, success: false, error: err.message });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
expect(results).toHaveLength(3);
|
|
326
|
+
expect(results[0]).toEqual({ id: 'msg1', success: true });
|
|
327
|
+
expect(results[1]).toEqual({ id: 'msg2', success: false, error: 'Not found' });
|
|
328
|
+
expect(results[2]).toEqual({ id: 'msg3', success: true });
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('Archive Emails Logic', () => {
|
|
333
|
+
it('should archive email by removing INBOX label', async () => {
|
|
334
|
+
const mockGmail = {
|
|
335
|
+
users: {
|
|
336
|
+
messages: {
|
|
337
|
+
modify: vi.fn().mockResolvedValue({ data: { id: 'msg123', labelIds: ['CATEGORY_UPDATES'] } })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const messageIds = ['msg123'];
|
|
343
|
+
const results = [];
|
|
344
|
+
|
|
345
|
+
for (const id of messageIds) {
|
|
346
|
+
try {
|
|
347
|
+
await mockGmail.users.messages.modify({
|
|
348
|
+
userId: 'me',
|
|
349
|
+
id: id,
|
|
350
|
+
requestBody: { removeLabelIds: ['INBOX'] }
|
|
351
|
+
});
|
|
352
|
+
results.push({ id, success: true });
|
|
353
|
+
} catch (err) {
|
|
354
|
+
results.push({ id, success: false, error: err.message });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
expect(results).toHaveLength(1);
|
|
359
|
+
expect(results[0].success).toBe(true);
|
|
360
|
+
expect(mockGmail.users.messages.modify).toHaveBeenCalledWith({
|
|
361
|
+
userId: 'me',
|
|
362
|
+
id: 'msg123',
|
|
363
|
+
requestBody: { removeLabelIds: ['INBOX'] }
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should handle errors during archive', async () => {
|
|
368
|
+
const mockGmail = {
|
|
369
|
+
users: {
|
|
370
|
+
messages: {
|
|
371
|
+
modify: vi.fn().mockRejectedValue(new Error('Rate limit exceeded'))
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const messageIds = ['msg123'];
|
|
377
|
+
const results = [];
|
|
378
|
+
|
|
379
|
+
for (const id of messageIds) {
|
|
380
|
+
try {
|
|
381
|
+
await mockGmail.users.messages.modify({
|
|
382
|
+
userId: 'me',
|
|
383
|
+
id: id,
|
|
384
|
+
requestBody: { removeLabelIds: ['INBOX'] }
|
|
385
|
+
});
|
|
386
|
+
results.push({ id, success: true });
|
|
387
|
+
} catch (err) {
|
|
388
|
+
results.push({ id, success: false, error: err.message });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
expect(results).toHaveLength(1);
|
|
393
|
+
expect(results[0].success).toBe(false);
|
|
394
|
+
expect(results[0].error).toBe('Rate limit exceeded');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should handle multiple message IDs for archive', async () => {
|
|
398
|
+
const mockModify = vi.fn()
|
|
399
|
+
.mockResolvedValueOnce({ data: { id: 'msg1' } })
|
|
400
|
+
.mockResolvedValueOnce({ data: { id: 'msg2' } });
|
|
401
|
+
|
|
402
|
+
const mockGmail = {
|
|
403
|
+
users: { messages: { modify: mockModify } }
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const messageIds = ['msg1', 'msg2'];
|
|
407
|
+
const results = [];
|
|
408
|
+
|
|
409
|
+
for (const id of messageIds) {
|
|
410
|
+
try {
|
|
411
|
+
await mockGmail.users.messages.modify({
|
|
412
|
+
userId: 'me',
|
|
413
|
+
id: id,
|
|
414
|
+
requestBody: { removeLabelIds: ['INBOX'] }
|
|
415
|
+
});
|
|
416
|
+
results.push({ id, success: true });
|
|
417
|
+
} catch (err) {
|
|
418
|
+
results.push({ id, success: false, error: err.message });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
expect(results).toHaveLength(2);
|
|
423
|
+
expect(results[0]).toEqual({ id: 'msg1', success: true });
|
|
424
|
+
expect(results[1]).toEqual({ id: 'msg2', success: true });
|
|
425
|
+
expect(mockModify).toHaveBeenCalledTimes(2);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
135
428
|
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
// Test install-service logic for both macOS and Linux
|
|
6
|
+
// Tests the service file generation without actually installing
|
|
7
|
+
|
|
8
|
+
describe('Install Service Command', () => {
|
|
9
|
+
const homeDir = os.homedir();
|
|
10
|
+
|
|
11
|
+
describe('Platform detection', () => {
|
|
12
|
+
it('should recognize darwin as macOS', () => {
|
|
13
|
+
const platform = 'darwin';
|
|
14
|
+
expect(platform === 'darwin').toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should recognize linux as Linux', () => {
|
|
18
|
+
const platform = 'linux';
|
|
19
|
+
expect(platform === 'linux').toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should reject unsupported platforms', () => {
|
|
23
|
+
const platform = 'win32';
|
|
24
|
+
const isSupported = platform === 'darwin' || platform === 'linux';
|
|
25
|
+
expect(isSupported).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('macOS launchd configuration', () => {
|
|
30
|
+
const launchAgentsDir = path.join(homeDir, 'Library/LaunchAgents');
|
|
31
|
+
const plistName = 'com.danielparedes.inboxd.plist';
|
|
32
|
+
const plistPath = path.join(launchAgentsDir, plistName);
|
|
33
|
+
|
|
34
|
+
it('should use correct plist path', () => {
|
|
35
|
+
expect(plistPath).toContain('Library/LaunchAgents');
|
|
36
|
+
expect(plistPath).toContain('com.danielparedes.inboxd.plist');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should generate valid plist structure', () => {
|
|
40
|
+
const interval = 5;
|
|
41
|
+
const seconds = interval * 60;
|
|
42
|
+
const nodePath = '/usr/local/bin/node';
|
|
43
|
+
const scriptPath = '/path/to/cli.js';
|
|
44
|
+
const workingDir = '/path/to';
|
|
45
|
+
|
|
46
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
48
|
+
<plist version="1.0">
|
|
49
|
+
<dict>
|
|
50
|
+
<key>Label</key>
|
|
51
|
+
<string>com.danielparedes.inboxd</string>
|
|
52
|
+
<key>ProgramArguments</key>
|
|
53
|
+
<array>
|
|
54
|
+
<string>${nodePath}</string>
|
|
55
|
+
<string>${scriptPath}</string>
|
|
56
|
+
<string>check</string>
|
|
57
|
+
<string>--quiet</string>
|
|
58
|
+
</array>
|
|
59
|
+
<key>StartInterval</key>
|
|
60
|
+
<integer>${seconds}</integer>
|
|
61
|
+
</dict>
|
|
62
|
+
</plist>`;
|
|
63
|
+
|
|
64
|
+
expect(plistContent).toContain('com.danielparedes.inboxd');
|
|
65
|
+
expect(plistContent).toContain('<integer>300</integer>'); // 5 * 60
|
|
66
|
+
expect(plistContent).toContain('check');
|
|
67
|
+
expect(plistContent).toContain('--quiet');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should convert minutes to seconds', () => {
|
|
71
|
+
const intervalMinutes = 10;
|
|
72
|
+
const seconds = intervalMinutes * 60;
|
|
73
|
+
expect(seconds).toBe(600);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Linux systemd configuration', () => {
|
|
78
|
+
const systemdUserDir = path.join(homeDir, '.config/systemd/user');
|
|
79
|
+
const servicePath = path.join(systemdUserDir, 'inboxd.service');
|
|
80
|
+
const timerPath = path.join(systemdUserDir, 'inboxd.timer');
|
|
81
|
+
|
|
82
|
+
it('should use correct systemd paths', () => {
|
|
83
|
+
expect(systemdUserDir).toContain('.config/systemd/user');
|
|
84
|
+
expect(servicePath).toContain('inboxd.service');
|
|
85
|
+
expect(timerPath).toContain('inboxd.timer');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should generate valid service unit', () => {
|
|
89
|
+
const nodePath = '/usr/bin/node';
|
|
90
|
+
const scriptPath = '/path/to/cli.js';
|
|
91
|
+
const workingDir = '/path/to';
|
|
92
|
+
|
|
93
|
+
const serviceContent = `[Unit]
|
|
94
|
+
Description=inboxd - Gmail monitoring and notifications
|
|
95
|
+
After=network-online.target
|
|
96
|
+
Wants=network-online.target
|
|
97
|
+
|
|
98
|
+
[Service]
|
|
99
|
+
Type=oneshot
|
|
100
|
+
ExecStart=${nodePath} ${scriptPath} check --quiet
|
|
101
|
+
WorkingDirectory=${workingDir}
|
|
102
|
+
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
103
|
+
|
|
104
|
+
[Install]
|
|
105
|
+
WantedBy=default.target
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
expect(serviceContent).toContain('[Unit]');
|
|
109
|
+
expect(serviceContent).toContain('[Service]');
|
|
110
|
+
expect(serviceContent).toContain('[Install]');
|
|
111
|
+
expect(serviceContent).toContain('Type=oneshot');
|
|
112
|
+
expect(serviceContent).toContain('check --quiet');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should generate valid timer unit', () => {
|
|
116
|
+
const interval = 5;
|
|
117
|
+
|
|
118
|
+
const timerContent = `[Unit]
|
|
119
|
+
Description=Run inboxd every ${interval} minutes
|
|
120
|
+
|
|
121
|
+
[Timer]
|
|
122
|
+
OnBootSec=1min
|
|
123
|
+
OnUnitActiveSec=${interval}min
|
|
124
|
+
Persistent=true
|
|
125
|
+
|
|
126
|
+
[Install]
|
|
127
|
+
WantedBy=timers.target
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
expect(timerContent).toContain('[Unit]');
|
|
131
|
+
expect(timerContent).toContain('[Timer]');
|
|
132
|
+
expect(timerContent).toContain('[Install]');
|
|
133
|
+
expect(timerContent).toContain('OnUnitActiveSec=5min');
|
|
134
|
+
expect(timerContent).toContain('Persistent=true');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should use correct timer interval format', () => {
|
|
138
|
+
const intervals = [1, 5, 10, 15, 30];
|
|
139
|
+
|
|
140
|
+
intervals.forEach(interval => {
|
|
141
|
+
const timerInterval = `${interval}min`;
|
|
142
|
+
expect(timerInterval).toMatch(/^\d+min$/);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('Interval parsing', () => {
|
|
148
|
+
it('should parse interval option as integer', () => {
|
|
149
|
+
const optionValue = '10';
|
|
150
|
+
const interval = parseInt(optionValue, 10);
|
|
151
|
+
expect(interval).toBe(10);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should use default interval of 5 minutes', () => {
|
|
155
|
+
const defaultInterval = '5';
|
|
156
|
+
const interval = parseInt(defaultInterval, 10);
|
|
157
|
+
expect(interval).toBe(5);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('Uninstall logic', () => {
|
|
162
|
+
it('should identify service files for removal (macOS)', () => {
|
|
163
|
+
const plistPath = path.join(homeDir, 'Library/LaunchAgents/com.danielparedes.inboxd.plist');
|
|
164
|
+
expect(plistPath).toContain('com.danielparedes.inboxd.plist');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should identify service files for removal (Linux)', () => {
|
|
168
|
+
const servicePath = path.join(homeDir, '.config/systemd/user/inboxd.service');
|
|
169
|
+
const timerPath = path.join(homeDir, '.config/systemd/user/inboxd.timer');
|
|
170
|
+
|
|
171
|
+
expect(servicePath).toContain('inboxd.service');
|
|
172
|
+
expect(timerPath).toContain('inboxd.timer');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should track if files were removed', () => {
|
|
176
|
+
let removed = false;
|
|
177
|
+
|
|
178
|
+
// Simulate file removal
|
|
179
|
+
const files = [
|
|
180
|
+
{ path: '/path/to/service', exists: true },
|
|
181
|
+
{ path: '/path/to/timer', exists: true },
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
files.forEach(file => {
|
|
185
|
+
if (file.exists) {
|
|
186
|
+
// fs.unlinkSync(file.path)
|
|
187
|
+
removed = true;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(removed).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('Path generation', () => {
|
|
196
|
+
it('should resolve script path correctly', () => {
|
|
197
|
+
// Simulates path.resolve(__dirname, 'cli.js')
|
|
198
|
+
const mockDirname = '/Users/test/inboxd/src';
|
|
199
|
+
const scriptPath = path.resolve(mockDirname, 'cli.js');
|
|
200
|
+
expect(scriptPath).toContain('cli.js');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should resolve working directory correctly', () => {
|
|
204
|
+
// Simulates path.resolve(__dirname, '..')
|
|
205
|
+
const mockDirname = '/Users/test/inboxd/src';
|
|
206
|
+
const workingDir = path.resolve(mockDirname, '..');
|
|
207
|
+
expect(workingDir).not.toContain('/src');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Test interactive confirmation logic for send/reply commands
|
|
4
|
+
// Tests the prompt handling without actual readline interaction
|
|
5
|
+
|
|
6
|
+
describe('Interactive Confirm', () => {
|
|
7
|
+
describe('Prompt function', () => {
|
|
8
|
+
// Mirrors the prompt function from cli.js
|
|
9
|
+
function prompt(rl, question) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
rl.question(question, (answer) => {
|
|
12
|
+
resolve(answer.trim());
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
it('should resolve with user answer', async () => {
|
|
18
|
+
const mockRl = {
|
|
19
|
+
question: vi.fn((q, callback) => callback('yes')),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const answer = await prompt(mockRl, 'Confirm? ');
|
|
23
|
+
expect(answer).toBe('yes');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should trim whitespace from answer', async () => {
|
|
27
|
+
const mockRl = {
|
|
28
|
+
question: vi.fn((q, callback) => callback(' yes ')),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const answer = await prompt(mockRl, 'Confirm? ');
|
|
32
|
+
expect(answer).toBe('yes');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should call question with provided prompt', async () => {
|
|
36
|
+
const mockRl = {
|
|
37
|
+
question: vi.fn((q, callback) => callback('y')),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
await prompt(mockRl, 'Send this email? (y/N): ');
|
|
41
|
+
expect(mockRl.question).toHaveBeenCalledWith(
|
|
42
|
+
'Send this email? (y/N): ',
|
|
43
|
+
expect.any(Function)
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('Answer validation for send', () => {
|
|
49
|
+
function isConfirmed(answer) {
|
|
50
|
+
return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('should accept "y" as confirmation', () => {
|
|
54
|
+
expect(isConfirmed('y')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should accept "Y" as confirmation (case-insensitive)', () => {
|
|
58
|
+
expect(isConfirmed('Y')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should accept "yes" as confirmation', () => {
|
|
62
|
+
expect(isConfirmed('yes')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should accept "YES" as confirmation (case-insensitive)', () => {
|
|
66
|
+
expect(isConfirmed('YES')).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should reject "n" as not confirmed', () => {
|
|
70
|
+
expect(isConfirmed('n')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should reject "no" as not confirmed', () => {
|
|
74
|
+
expect(isConfirmed('no')).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should reject empty string as not confirmed', () => {
|
|
78
|
+
expect(isConfirmed('')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should reject random input as not confirmed', () => {
|
|
82
|
+
expect(isConfirmed('maybe')).toBe(false);
|
|
83
|
+
expect(isConfirmed('okay')).toBe(false);
|
|
84
|
+
expect(isConfirmed('sure')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('readline interface lifecycle', () => {
|
|
89
|
+
it('should close interface after getting answer', () => {
|
|
90
|
+
const mockRl = {
|
|
91
|
+
question: vi.fn((q, callback) => callback('y')),
|
|
92
|
+
close: vi.fn(),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Simulate the pattern in cli.js
|
|
96
|
+
mockRl.question('Confirm?', () => {});
|
|
97
|
+
mockRl.close();
|
|
98
|
+
|
|
99
|
+
expect(mockRl.close).toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('--confirm flag behavior', () => {
|
|
104
|
+
it('should skip prompt when --confirm is provided', () => {
|
|
105
|
+
const options = { confirm: true };
|
|
106
|
+
|
|
107
|
+
if (options.confirm) {
|
|
108
|
+
// Skip prompt, proceed directly
|
|
109
|
+
expect(true).toBe(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should require prompt when --confirm is not provided', () => {
|
|
114
|
+
const options = { confirm: false };
|
|
115
|
+
const needsPrompt = !options.confirm;
|
|
116
|
+
|
|
117
|
+
expect(needsPrompt).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should require prompt when --confirm is undefined', () => {
|
|
121
|
+
const options = {};
|
|
122
|
+
const needsPrompt = !options.confirm;
|
|
123
|
+
|
|
124
|
+
expect(needsPrompt).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Cancel behavior', () => {
|
|
129
|
+
it('should cancel when user answers "n"', () => {
|
|
130
|
+
const answer = 'n';
|
|
131
|
+
const shouldProceed = answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
|
|
132
|
+
|
|
133
|
+
expect(shouldProceed).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should cancel when user presses enter (empty)', () => {
|
|
137
|
+
const answer = '';
|
|
138
|
+
const shouldProceed = answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
|
|
139
|
+
|
|
140
|
+
expect(shouldProceed).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('Send command confirmation prompt', () => {
|
|
145
|
+
it('should use correct prompt message for send', () => {
|
|
146
|
+
const promptMessage = 'Send this email? (y/N): ';
|
|
147
|
+
expect(promptMessage).toContain('Send');
|
|
148
|
+
expect(promptMessage).toContain('y/N');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('Reply command confirmation prompt', () => {
|
|
153
|
+
it('should use correct prompt message for reply', () => {
|
|
154
|
+
const promptMessage = 'Send this reply? (y/N): ';
|
|
155
|
+
expect(promptMessage).toContain('reply');
|
|
156
|
+
expect(promptMessage).toContain('y/N');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('Integration with --dry-run', () => {
|
|
161
|
+
it('should not prompt when --dry-run is provided', () => {
|
|
162
|
+
const options = { dryRun: true, confirm: false };
|
|
163
|
+
|
|
164
|
+
// --dry-run takes precedence, no send happens
|
|
165
|
+
if (options.dryRun) {
|
|
166
|
+
// Preview only, skip confirmation
|
|
167
|
+
expect(true).toBe(true);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// This line should not be reached
|
|
172
|
+
expect(false).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|