@pheem49/mint 1.4.2 → 1.5.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.
Files changed (60) hide show
  1. package/GUIDE_TH.md +113 -0
  2. package/README.md +239 -76
  3. package/assets/CLI_Screen.png +0 -0
  4. package/docs/assets/CLI_Screen.png +0 -0
  5. package/docs/guide.html +632 -0
  6. package/docs/index.html +5 -4
  7. package/main.js +66 -894
  8. package/mint-cli-logic.js +13 -1
  9. package/mint-cli.js +100 -9
  10. package/package.json +12 -4
  11. package/src/AI_Brain/Gemini_API.js +77 -20
  12. package/src/AI_Brain/autonomous_brain.js +10 -0
  13. package/src/AI_Brain/behavior_memory.js +26 -5
  14. package/src/AI_Brain/headless_agent.js +4 -0
  15. package/src/AI_Brain/knowledge_base.js +61 -8
  16. package/src/AI_Brain/memory_store.js +55 -7
  17. package/src/Automation_Layer/file_operations.js +1 -1
  18. package/src/CLI/chat_router.js +3 -2
  19. package/src/CLI/chat_ui.js +263 -838
  20. package/src/CLI/code_agent.js +144 -42
  21. package/src/CLI/gmail_auth.js +210 -0
  22. package/src/CLI/list_features.js +2 -0
  23. package/src/CLI/onboarding.js +307 -55
  24. package/src/CLI/updater.js +208 -0
  25. package/src/Channels/brave_search_bridge.js +35 -0
  26. package/src/Channels/discord_bridge.js +68 -0
  27. package/src/Channels/google_search_bridge.js +38 -0
  28. package/src/Channels/line_bridge.js +60 -0
  29. package/src/Channels/slack_bridge.js +53 -0
  30. package/src/Channels/telegram_bridge.js +49 -0
  31. package/src/Channels/whatsapp_bridge.js +55 -0
  32. package/src/Command_Parser/parser.js +12 -1
  33. package/src/Plugins/gmail.js +251 -0
  34. package/src/Plugins/google_calendar.js +245 -19
  35. package/src/Plugins/notion.js +256 -0
  36. package/src/System/action_executor.js +129 -0
  37. package/src/System/bridge_manager.js +76 -0
  38. package/src/System/chat_history_manager.js +23 -5
  39. package/src/System/config_manager.js +41 -7
  40. package/src/System/custom_workflows.js +31 -2
  41. package/src/System/google_tts_urls.js +51 -0
  42. package/src/System/ipc_handlers.js +238 -0
  43. package/src/System/proactive_loop.js +137 -0
  44. package/src/System/safety_manager.js +165 -0
  45. package/src/System/screen_capture.js +175 -0
  46. package/src/System/task_manager.js +15 -5
  47. package/src/System/window_manager.js +210 -0
  48. package/src/UI/renderer.js +33 -7
  49. package/src/UI/settings.html +24 -0
  50. package/src/UI/settings.js +14 -4
  51. package/src/UI/styles.css +14 -1
  52. package/tests/action_executor_safety.test.js +67 -0
  53. package/tests/gmail.test.js +135 -0
  54. package/tests/gmail_auth.test.js +129 -0
  55. package/tests/google_calendar.test.js +113 -0
  56. package/tests/google_tts_urls.test.js +24 -0
  57. package/tests/notion.test.js +121 -0
  58. package/tests/provider_routing.test.js +17 -1
  59. package/tests/safety_manager.test.js +40 -0
  60. package/tests/updater.test.js +32 -0
@@ -23,9 +23,17 @@ const DEFAULT_CONFIG = {
23
23
  ttsPitch: 1.0,
24
24
  pluginSpotifyEnabled: true,
25
25
  pluginCalendarEnabled: false,
26
+ pluginGmailEnabled: false,
27
+ pluginNotionEnabled: false,
26
28
  pluginDiscordEnabled: false,
27
29
  showDesktopWidget: true,
28
- mcpServers: {}
30
+ mcpServers: {},
31
+ openaiModel: 'gpt-4o',
32
+ anthropicModel: 'claude-3-5-sonnet-latest',
33
+ hfModel: 'meta-llama/Meta-Llama-3-8B-Instruct',
34
+ localApiBaseUrl: '',
35
+ localModelName: 'local-model',
36
+ ollamaHost: ''
29
37
  };
30
38
 
31
39
  let currentConfig = { ...DEFAULT_CONFIG };
@@ -68,7 +76,7 @@ function applyConfig(config) {
68
76
  if (hfInput) hfInput.value = config.hfApiKey || '';
69
77
 
70
78
  const ollamaHostInput = document.getElementById('ollama-host-input');
71
- if (ollamaHostInput) ollamaHostInput.value = config.ollamaHost || 'http://localhost:11434';
79
+ if (ollamaHostInput) ollamaHostInput.value = config.ollamaHost || '';
72
80
 
73
81
  // Apply Gemini model
74
82
  applyModelSelection(config.geminiModel);
@@ -95,7 +103,7 @@ function applyConfig(config) {
95
103
 
96
104
  const localApiBaseUrlInput = document.getElementById('local-api-base-url');
97
105
  if (localApiBaseUrlInput) {
98
- localApiBaseUrlInput.value = config.localApiBaseUrl || 'http://localhost:1234/v1';
106
+ localApiBaseUrlInput.value = config.localApiBaseUrl || '';
99
107
  }
100
108
 
101
109
  const localModelNameInput = document.getElementById('local-model-name');
@@ -142,6 +150,8 @@ function applyConfig(config) {
142
150
  // Plugins logic
143
151
  updatePluginButton('spotify', config.pluginSpotifyEnabled);
144
152
  updatePluginButton('calendar', config.pluginCalendarEnabled);
153
+ updatePluginButton('gmail', config.pluginGmailEnabled);
154
+ updatePluginButton('notion', config.pluginNotionEnabled);
145
155
  updatePluginButton('discord', config.pluginDiscordEnabled);
146
156
 
147
157
  // Apply Automation Browser
@@ -691,7 +701,7 @@ function updatePluginButton(pluginName, isEnabled) {
691
701
  }
692
702
 
693
703
  // Bind plugin buttons
694
- ['spotify', 'calendar', 'discord'].forEach(plugin => {
704
+ ['spotify', 'calendar', 'gmail', 'notion', 'discord'].forEach(plugin => {
695
705
  const btn = document.getElementById(`btn-plugin-${plugin}`);
696
706
  if (btn) {
697
707
  btn.addEventListener('click', () => {
package/src/UI/styles.css CHANGED
@@ -251,6 +251,19 @@ h1 {
251
251
  padding: 0 6px;
252
252
  opacity: 0.7;
253
253
  font-weight: 400;
254
+ display: flex;
255
+ align-items: center;
256
+ gap: 8px;
257
+ flex-wrap: wrap;
258
+ }
259
+
260
+ .provider-badge {
261
+ max-width: min(320px, 70vw);
262
+ overflow: hidden;
263
+ text-overflow: ellipsis;
264
+ white-space: nowrap;
265
+ color: var(--accent);
266
+ font-weight: 600;
254
267
  }
255
268
 
256
269
  @keyframes messagePopIn {
@@ -648,4 +661,4 @@ input:checked + .slider:before {
648
661
 
649
662
  .suggestion-chip:active {
650
663
  transform: scale(0.97);
651
- }
664
+ }
@@ -0,0 +1,67 @@
1
+ jest.mock('electron', () => ({
2
+ clipboard: {
3
+ writeText: jest.fn()
4
+ }
5
+ }));
6
+
7
+ jest.mock('../src/Automation_Layer/file_operations', () => ({
8
+ createFolder: jest.fn(() => ({ success: true })),
9
+ openFile: jest.fn(async () => true),
10
+ deleteFile: jest.fn(async () => ({ success: true })),
11
+ findPath: jest.fn(() => ({ success: false, message: 'not found', matches: [] }))
12
+ }));
13
+
14
+ jest.mock('../src/Automation_Layer/open_app', () => ({
15
+ openApp: jest.fn()
16
+ }));
17
+
18
+ jest.mock('../src/Automation_Layer/open_website', () => ({
19
+ openWebsite: jest.fn(),
20
+ openSearch: jest.fn()
21
+ }));
22
+
23
+ jest.mock('../src/Automation_Layer/browser_automation', () => ({
24
+ performWebAutomation: jest.fn(async () => 'done')
25
+ }));
26
+
27
+ jest.mock('../src/AI_Brain/knowledge_base', () => ({
28
+ indexFile: jest.fn(async () => 'indexed'),
29
+ indexFolder: jest.fn(async () => 'indexed')
30
+ }));
31
+
32
+ jest.mock('../src/Plugins/plugin_manager', () => ({
33
+ executePlugin: jest.fn()
34
+ }));
35
+
36
+ jest.mock('../src/Plugins/mcp_manager', () => ({
37
+ callTool: jest.fn()
38
+ }));
39
+
40
+ jest.mock('../src/System/granular_automation', () => ({
41
+ mouseMove: jest.fn(),
42
+ mouseClick: jest.fn(),
43
+ typeText: jest.fn(),
44
+ keyTap: jest.fn()
45
+ }));
46
+
47
+ jest.mock('../src/System/system_automation', () => ({
48
+ shutdown: jest.fn(),
49
+ restart: jest.fn(),
50
+ sleep: jest.fn(),
51
+ setVolume: jest.fn(),
52
+ mute: jest.fn(),
53
+ setBrightness: jest.fn(),
54
+ minimizeAll: jest.fn()
55
+ }));
56
+
57
+ describe('action_executor safety integration', () => {
58
+ test('blocks dangerous delete actions unless explicitly allowed', async () => {
59
+ const { executeAction } = require('../src/System/action_executor');
60
+ await expect(executeAction({ type: 'delete_file', target: 'notes.txt' })).rejects.toThrow(/Dangerous action/);
61
+ });
62
+
63
+ test('allows dangerous actions with explicit permission flag', async () => {
64
+ const { executeAction } = require('../src/System/action_executor');
65
+ await expect(executeAction({ type: 'delete_file', target: 'notes.txt' }, { allowDangerous: true })).resolves.toBeUndefined();
66
+ });
67
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests: gmail.js plugin
3
+ */
4
+
5
+ jest.mock('axios', () => ({
6
+ post: jest.fn(),
7
+ get: jest.fn()
8
+ }));
9
+
10
+ jest.mock('../src/System/config_manager', () => ({
11
+ readConfig: jest.fn()
12
+ }));
13
+
14
+ const axios = require('axios');
15
+ const { readConfig } = require('../src/System/config_manager');
16
+ const gmail = require('../src/Plugins/gmail');
17
+
18
+ describe('gmail plugin', () => {
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ });
22
+
23
+ test('has required plugin fields', () => {
24
+ expect(gmail.name).toBe('gmail');
25
+ expect(typeof gmail.description).toBe('string');
26
+ expect(typeof gmail.execute).toBe('function');
27
+ });
28
+
29
+ test('returns configuration message when OAuth config is missing', async () => {
30
+ readConfig.mockReturnValue({});
31
+
32
+ const result = await gmail.execute('inbox');
33
+
34
+ expect(result).toContain('Gmail API is not configured');
35
+ expect(axios.get).not.toHaveBeenCalled();
36
+ });
37
+
38
+ test('searches Gmail and fetches metadata for results', async () => {
39
+ readConfig.mockReturnValue({
40
+ gmailClientId: 'client-id',
41
+ gmailClientSecret: 'client-secret',
42
+ gmailRefreshToken: 'refresh-token',
43
+ gmailUserId: 'me'
44
+ });
45
+ axios.post.mockResolvedValueOnce({ data: { access_token: 'access-token' } });
46
+ axios.get
47
+ .mockResolvedValueOnce({ data: { messages: [{ id: 'msg-1' }] } })
48
+ .mockResolvedValueOnce({
49
+ data: {
50
+ id: 'msg-1',
51
+ snippet: 'Hello snippet',
52
+ payload: {
53
+ headers: [
54
+ { name: 'From', value: 'A <a@example.com>' },
55
+ { name: 'Subject', value: 'Hello' },
56
+ { name: 'Date', value: 'Fri, 15 May 2026 10:00:00 +0700' }
57
+ ]
58
+ }
59
+ }
60
+ });
61
+
62
+ const result = await gmail.execute(JSON.stringify({ action: 'search', query: 'is:unread', limit: 1 }));
63
+
64
+ expect(axios.get).toHaveBeenCalledTimes(2);
65
+ expect(axios.get.mock.calls[0][0]).toBe('https://gmail.googleapis.com/gmail/v1/users/me/messages');
66
+ expect(result).toContain('Hello');
67
+ expect(result).toContain('msg-1');
68
+ });
69
+
70
+ test('reads full Gmail message body', async () => {
71
+ readConfig.mockReturnValue({
72
+ gmailClientId: 'client-id',
73
+ gmailClientSecret: 'client-secret',
74
+ gmailRefreshToken: 'refresh-token',
75
+ gmailUserId: 'me'
76
+ });
77
+ axios.post.mockResolvedValueOnce({ data: { access_token: 'access-token' } });
78
+ axios.get.mockResolvedValueOnce({
79
+ data: {
80
+ id: 'msg-1',
81
+ payload: {
82
+ headers: [
83
+ { name: 'From', value: 'A <a@example.com>' },
84
+ { name: 'Subject', value: 'Hello' }
85
+ ],
86
+ mimeType: 'text/plain',
87
+ body: { data: gmail._helpers.encodeBase64Url('Full body text') }
88
+ }
89
+ }
90
+ });
91
+
92
+ const result = await gmail.execute(JSON.stringify({ action: 'read', id: 'msg-1' }));
93
+
94
+ expect(result).toContain('Full body text');
95
+ expect(result).toContain('Hello');
96
+ });
97
+
98
+ test('creates Gmail draft only', async () => {
99
+ readConfig.mockReturnValue({
100
+ gmailClientId: 'client-id',
101
+ gmailClientSecret: 'client-secret',
102
+ gmailRefreshToken: 'refresh-token',
103
+ gmailUserId: 'me'
104
+ });
105
+ axios.post
106
+ .mockResolvedValueOnce({ data: { access_token: 'access-token' } })
107
+ .mockResolvedValueOnce({ data: { id: 'draft-1' } });
108
+
109
+ const result = await gmail.execute(JSON.stringify({
110
+ action: 'draft',
111
+ to: 'person@example.com',
112
+ subject: 'Draft subject',
113
+ body: 'Draft body'
114
+ }));
115
+
116
+ expect(axios.post).toHaveBeenCalledTimes(2);
117
+ expect(axios.post.mock.calls[1][0]).toBe('https://gmail.googleapis.com/gmail/v1/users/me/drafts');
118
+ expect(axios.post.mock.calls[1][1].message.raw).toBeTruthy();
119
+ expect(result).toContain('Created Gmail draft');
120
+ expect(result).toContain('Review it in Gmail before sending');
121
+ });
122
+
123
+ test('buildRawEmail removes newline injection from headers', () => {
124
+ const raw = gmail._helpers.buildRawEmail({
125
+ to: 'person@example.com\nBcc: attacker@example.com',
126
+ subject: 'Hello\r\nBad: header',
127
+ body: 'Body'
128
+ });
129
+ const decoded = gmail._helpers.decodeBase64Url(raw);
130
+
131
+ expect(decoded).toContain('To: person@example.com Bcc: attacker@example.com');
132
+ expect(decoded).toContain('Subject: Hello Bad: header');
133
+ expect(decoded).not.toContain('\nBcc: attacker@example.com');
134
+ });
135
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tests: gmail_auth.js OAuth helper
3
+ */
4
+
5
+ jest.mock('axios', () => ({
6
+ post: jest.fn()
7
+ }));
8
+
9
+ jest.mock('../src/System/config_manager', () => ({
10
+ readConfig: jest.fn(),
11
+ writeConfig: jest.fn()
12
+ }));
13
+
14
+ const axios = require('axios');
15
+ const gmailAuth = require('../src/CLI/gmail_auth');
16
+
17
+ describe('gmail_auth helper', () => {
18
+ beforeEach(() => {
19
+ jest.clearAllMocks();
20
+ });
21
+
22
+ test('buildAuthUrl includes offline access and consent prompt', () => {
23
+ const url = gmailAuth.buildAuthUrl({
24
+ clientId: 'client-id',
25
+ redirectUri: 'http://127.0.0.1:3333/oauth2callback',
26
+ state: 'state-1'
27
+ });
28
+
29
+ const parsed = new URL(url);
30
+ expect(parsed.origin + parsed.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
31
+ expect(parsed.searchParams.get('client_id')).toBe('client-id');
32
+ expect(parsed.searchParams.get('access_type')).toBe('offline');
33
+ expect(parsed.searchParams.get('prompt')).toBe('consent');
34
+ expect(parsed.searchParams.get('scope')).toContain('gmail.readonly');
35
+ expect(parsed.searchParams.get('scope')).toContain('gmail.compose');
36
+ });
37
+
38
+ test('exchanges authorization code for token', async () => {
39
+ axios.post.mockResolvedValueOnce({
40
+ data: {
41
+ refresh_token: 'refresh-token'
42
+ }
43
+ });
44
+
45
+ const token = await gmailAuth.exchangeCodeForToken({
46
+ clientId: 'client-id',
47
+ clientSecret: 'client-secret',
48
+ code: 'code-1',
49
+ redirectUri: 'http://127.0.0.1:3333/oauth2callback'
50
+ });
51
+
52
+ expect(token.refresh_token).toBe('refresh-token');
53
+ expect(axios.post).toHaveBeenCalledWith(
54
+ 'https://oauth2.googleapis.com/token',
55
+ expect.stringContaining('grant_type=authorization_code'),
56
+ expect.any(Object)
57
+ );
58
+ });
59
+
60
+ test('runGmailAuth opens browser, accepts callback, and saves refresh token', async () => {
61
+ axios.post.mockResolvedValueOnce({
62
+ data: {
63
+ refresh_token: 'new-refresh-token'
64
+ }
65
+ });
66
+
67
+ const writeConfig = jest.fn(() => ({ success: true }));
68
+ const result = await gmailAuth.runGmailAuth({
69
+ readConfig: () => ({
70
+ gmailClientId: 'client-id',
71
+ gmailClientSecret: 'client-secret',
72
+ gmailUserId: 'me'
73
+ }),
74
+ writeConfig,
75
+ logger: { log: jest.fn() },
76
+ openBrowser: jest.fn(),
77
+ getAuthorizationCode: async ({ authUrl, state, redirectUri }) => {
78
+ expect(authUrl).toContain(encodeURIComponent(redirectUri));
79
+ expect(state).toBeTruthy();
80
+ return 'auth-code';
81
+ },
82
+ timeoutMs: 5000
83
+ });
84
+
85
+ expect(result.success).toBe(true);
86
+ expect(writeConfig).toHaveBeenCalledWith(expect.objectContaining({
87
+ gmailRefreshToken: 'new-refresh-token',
88
+ gmailUserId: 'me',
89
+ pluginGmailEnabled: true
90
+ }));
91
+ });
92
+
93
+ test('runGmailAuth can print link without opening browser', async () => {
94
+ axios.post.mockResolvedValueOnce({
95
+ data: {
96
+ refresh_token: 'manual-refresh-token'
97
+ }
98
+ });
99
+
100
+ const openBrowser = jest.fn();
101
+ const writeConfig = jest.fn(() => ({ success: true }));
102
+
103
+ const result = await gmailAuth.runGmailAuth({
104
+ readConfig: () => ({
105
+ gmailClientId: 'client-id',
106
+ gmailClientSecret: 'client-secret',
107
+ gmailUserId: 'me'
108
+ }),
109
+ writeConfig,
110
+ logger: { log: jest.fn() },
111
+ openBrowser: false,
112
+ getAuthorizationCode: async () => 'manual-code'
113
+ });
114
+
115
+ expect(result.success).toBe(true);
116
+ expect(openBrowser).not.toHaveBeenCalled();
117
+ expect(writeConfig).toHaveBeenCalledWith(expect.objectContaining({
118
+ gmailRefreshToken: 'manual-refresh-token'
119
+ }));
120
+ });
121
+
122
+ test('runGmailAuth requires saved client credentials', async () => {
123
+ await expect(gmailAuth.runGmailAuth({
124
+ readConfig: () => ({}),
125
+ openBrowser: jest.fn(),
126
+ logger: { log: jest.fn() }
127
+ })).rejects.toThrow('Missing Gmail OAuth Client ID');
128
+ });
129
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Tests: google_calendar.js plugin
3
+ */
4
+
5
+ jest.mock('axios', () => ({
6
+ post: jest.fn(),
7
+ get: jest.fn()
8
+ }));
9
+
10
+ jest.mock('electron', () => ({
11
+ shell: {
12
+ openExternal: jest.fn()
13
+ }
14
+ }));
15
+
16
+ jest.mock('../src/System/config_manager', () => ({
17
+ readConfig: jest.fn()
18
+ }));
19
+
20
+ const axios = require('axios');
21
+ const { shell } = require('electron');
22
+ const { readConfig } = require('../src/System/config_manager');
23
+
24
+ describe('google_calendar plugin', () => {
25
+ let plugin;
26
+
27
+ beforeEach(() => {
28
+ jest.clearAllMocks();
29
+ plugin = require('../src/Plugins/google_calendar');
30
+ });
31
+
32
+ test('has required plugin fields', () => {
33
+ expect(plugin.name).toBe('google_calendar');
34
+ expect(typeof plugin.description).toBe('string');
35
+ expect(typeof plugin.execute).toBe('function');
36
+ });
37
+
38
+ test('falls back to opening Google Calendar when API config is missing', async () => {
39
+ readConfig.mockReturnValue({});
40
+
41
+ const result = await plugin.execute('open');
42
+
43
+ expect(shell.openExternal).toHaveBeenCalledWith('https://calendar.google.com/');
44
+ expect(result).toContain('Google Calendar');
45
+ });
46
+
47
+ test('creates event through Google Calendar API', async () => {
48
+ readConfig.mockReturnValue({
49
+ googleCalendarClientId: 'client-id',
50
+ googleCalendarClientSecret: 'client-secret',
51
+ googleCalendarRefreshToken: 'refresh-token',
52
+ googleCalendarId: 'primary'
53
+ });
54
+ axios.post
55
+ .mockResolvedValueOnce({ data: { access_token: 'access-token' } })
56
+ .mockResolvedValueOnce({
57
+ data: {
58
+ summary: 'Demo meeting',
59
+ htmlLink: 'https://calendar.google.com/event/demo'
60
+ }
61
+ });
62
+
63
+ const result = await plugin.execute(JSON.stringify({
64
+ action: 'create',
65
+ summary: 'Demo meeting',
66
+ start: '2026-05-15T10:00:00+07:00',
67
+ end: '2026-05-15T11:00:00+07:00'
68
+ }));
69
+
70
+ expect(axios.post).toHaveBeenCalledTimes(2);
71
+ expect(axios.post.mock.calls[1][0]).toContain('/calendars/primary/events');
72
+ expect(axios.post.mock.calls[1][1].summary).toBe('Demo meeting');
73
+ expect(result).toContain('Demo meeting');
74
+ expect(result).toContain('https://calendar.google.com/event/demo');
75
+ });
76
+
77
+ test('lists upcoming events through Google Calendar API', async () => {
78
+ readConfig.mockReturnValue({
79
+ googleCalendarClientId: 'client-id',
80
+ googleCalendarClientSecret: 'client-secret',
81
+ googleCalendarRefreshToken: 'refresh-token',
82
+ googleCalendarId: 'primary'
83
+ });
84
+ axios.post.mockResolvedValueOnce({ data: { access_token: 'access-token' } });
85
+ axios.get.mockResolvedValueOnce({
86
+ data: {
87
+ items: [
88
+ {
89
+ summary: 'Planning',
90
+ start: { dateTime: '2026-05-15T10:00:00+07:00' },
91
+ end: { dateTime: '2026-05-15T11:00:00+07:00' }
92
+ }
93
+ ]
94
+ }
95
+ });
96
+
97
+ const result = await plugin.execute(JSON.stringify({ action: 'list', days: 3 }));
98
+
99
+ expect(axios.get).toHaveBeenCalledTimes(1);
100
+ expect(axios.get.mock.calls[0][0]).toContain('/calendars/primary/events');
101
+ expect(result).toContain('Planning');
102
+ });
103
+
104
+ test('builds all-day event payload with exclusive end date', () => {
105
+ const payload = plugin._helpers.buildEventPayload({
106
+ summary: 'All day',
107
+ date: '2026-05-15'
108
+ });
109
+
110
+ expect(payload.start).toEqual({ date: '2026-05-15' });
111
+ expect(payload.end).toEqual({ date: '2026-05-16' });
112
+ });
113
+ });
@@ -0,0 +1,24 @@
1
+ const { getGoogleTtsUrls, splitTextForTts } = require('../src/System/google_tts_urls');
2
+
3
+ describe('google_tts_urls', () => {
4
+ test('returns no URLs for empty text', () => {
5
+ expect(getGoogleTtsUrls('')).toEqual([]);
6
+ });
7
+
8
+ test('builds Google Translate TTS URLs with encoded text', () => {
9
+ const urls = getGoogleTtsUrls('hello world', { lang: 'en' });
10
+
11
+ expect(urls).toHaveLength(1);
12
+ expect(urls[0].shortText).toBe('hello world');
13
+ expect(urls[0].url).toContain('translate_tts?');
14
+ expect(urls[0].url).toContain('q=hello+world');
15
+ expect(urls[0].url).toContain('tl=en');
16
+ });
17
+
18
+ test('splits long text into bounded chunks', () => {
19
+ const chunks = splitTextForTts('a '.repeat(150), 50);
20
+
21
+ expect(chunks.length).toBeGreaterThan(1);
22
+ expect(chunks.every(chunk => chunk.length <= 50)).toBe(true);
23
+ });
24
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Tests: notion.js plugin
3
+ */
4
+
5
+ jest.mock('axios', () => ({
6
+ post: jest.fn(),
7
+ patch: jest.fn()
8
+ }));
9
+
10
+ jest.mock('../src/System/config_manager', () => ({
11
+ readConfig: jest.fn()
12
+ }));
13
+
14
+ const axios = require('axios');
15
+ const { readConfig } = require('../src/System/config_manager');
16
+ const notion = require('../src/Plugins/notion');
17
+
18
+ describe('notion plugin', () => {
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ });
22
+
23
+ test('has required plugin fields', () => {
24
+ expect(notion.name).toBe('notion');
25
+ expect(typeof notion.description).toBe('string');
26
+ expect(typeof notion.execute).toBe('function');
27
+ });
28
+
29
+ test('returns configuration message when API key is missing', async () => {
30
+ readConfig.mockReturnValue({});
31
+
32
+ const result = await notion.execute('My note');
33
+
34
+ expect(result).toContain('Notion API is not configured');
35
+ expect(axios.post).not.toHaveBeenCalled();
36
+ });
37
+
38
+ test('creates a page in default database', async () => {
39
+ readConfig.mockReturnValue({
40
+ notionApiKey: 'secret',
41
+ notionDatabaseId: 'db-id',
42
+ notionTitleProperty: 'Name'
43
+ });
44
+ axios.post.mockResolvedValueOnce({
45
+ data: {
46
+ url: 'https://notion.so/page'
47
+ }
48
+ });
49
+
50
+ const result = await notion.execute(JSON.stringify({
51
+ action: 'create_page',
52
+ title: 'Project note',
53
+ content: 'Body text'
54
+ }));
55
+
56
+ expect(axios.post).toHaveBeenCalledTimes(1);
57
+ expect(axios.post.mock.calls[0][0]).toBe('https://api.notion.com/v1/pages');
58
+ expect(axios.post.mock.calls[0][1].parent).toEqual({ database_id: 'db-id' });
59
+ expect(axios.post.mock.calls[0][1].properties.Name.title[0].text.content).toBe('Project note');
60
+ expect(result).toContain('Project note');
61
+ expect(result).toContain('https://notion.so/page');
62
+ });
63
+
64
+ test('queries database pages', async () => {
65
+ readConfig.mockReturnValue({
66
+ notionApiKey: 'secret',
67
+ notionDatabaseId: 'db-id'
68
+ });
69
+ axios.post.mockResolvedValueOnce({
70
+ data: {
71
+ results: [
72
+ {
73
+ url: 'https://notion.so/one',
74
+ properties: {
75
+ Name: {
76
+ type: 'title',
77
+ title: [{ plain_text: 'First page' }]
78
+ }
79
+ }
80
+ }
81
+ ]
82
+ }
83
+ });
84
+
85
+ const result = await notion.execute(JSON.stringify({ action: 'query_database', limit: 1 }));
86
+
87
+ expect(axios.post).toHaveBeenCalledWith(
88
+ 'https://api.notion.com/v1/databases/db-id/query',
89
+ { page_size: 1 },
90
+ expect.any(Object)
91
+ );
92
+ expect(result).toContain('First page');
93
+ });
94
+
95
+ test('appends blocks to default page', async () => {
96
+ readConfig.mockReturnValue({
97
+ notionApiKey: 'secret',
98
+ notionPageId: 'page-id'
99
+ });
100
+ axios.patch.mockResolvedValueOnce({
101
+ data: { results: [{ id: 'block-1' }] }
102
+ });
103
+
104
+ const result = await notion.execute(JSON.stringify({
105
+ action: 'append_block',
106
+ content: 'Follow-up note'
107
+ }));
108
+
109
+ expect(axios.patch).toHaveBeenCalledTimes(1);
110
+ expect(axios.patch.mock.calls[0][0]).toBe('https://api.notion.com/v1/blocks/page-id/children');
111
+ expect(result).toContain('Appended 1 block');
112
+ });
113
+
114
+ test('plain text becomes create_page instruction', () => {
115
+ const parsed = notion._helpers.parseInstruction('Title line\n\nBody line');
116
+
117
+ expect(parsed.action).toBe('create_page');
118
+ expect(parsed.title).toBe('Title line');
119
+ expect(parsed.content).toBe('Body line');
120
+ });
121
+ });