@pheem49/mint 1.4.1 → 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.
- package/GUIDE_TH.md +113 -0
- package/README.md +214 -142
- package/assets/CLI_Screen.png +0 -0
- package/docs/assets/CLI_Screen.png +0 -0
- package/docs/guide.html +632 -0
- package/docs/index.html +5 -4
- package/main.js +66 -894
- package/mint-cli-logic.js +15 -8
- package/mint-cli.js +305 -195
- package/package.json +12 -4
- package/src/AI_Brain/Gemini_API.js +77 -20
- package/src/AI_Brain/agent_orchestrator.js +6 -6
- package/src/AI_Brain/autonomous_brain.js +10 -0
- package/src/AI_Brain/behavior_memory.js +26 -5
- package/src/AI_Brain/headless_agent.js +4 -0
- package/src/AI_Brain/knowledge_base.js +61 -8
- package/src/AI_Brain/memory_store.js +55 -7
- package/src/Automation_Layer/file_operations.js +14 -3
- package/src/CLI/chat_router.js +21 -7
- package/src/CLI/chat_ui.js +264 -710
- package/src/CLI/code_agent.js +370 -124
- package/src/CLI/gmail_auth.js +210 -0
- package/src/CLI/list_features.js +5 -1
- package/src/CLI/onboarding.js +307 -55
- package/src/CLI/updater.js +208 -0
- package/src/Channels/brave_search_bridge.js +35 -0
- package/src/Channels/discord_bridge.js +68 -0
- package/src/Channels/google_search_bridge.js +38 -0
- package/src/Channels/line_bridge.js +60 -0
- package/src/Channels/slack_bridge.js +53 -0
- package/src/Channels/telegram_bridge.js +49 -0
- package/src/Channels/whatsapp_bridge.js +55 -0
- package/src/Command_Parser/parser.js +12 -1
- package/src/Plugins/gmail.js +251 -0
- package/src/Plugins/google_calendar.js +245 -19
- package/src/Plugins/notion.js +256 -0
- package/src/System/action_executor.js +129 -0
- package/src/System/bridge_manager.js +76 -0
- package/src/System/chat_history_manager.js +23 -5
- package/src/System/config_manager.js +41 -7
- package/src/System/custom_workflows.js +31 -2
- package/src/System/google_tts_urls.js +51 -0
- package/src/System/ipc_handlers.js +238 -0
- package/src/System/proactive_loop.js +137 -0
- package/src/System/safety_manager.js +165 -0
- package/src/System/screen_capture.js +175 -0
- package/src/System/task_manager.js +15 -5
- package/src/System/window_manager.js +210 -0
- package/src/UI/renderer.js +33 -7
- package/src/UI/settings.html +24 -0
- package/src/UI/settings.js +14 -4
- package/src/UI/styles.css +14 -1
- package/tests/action_executor_safety.test.js +67 -0
- package/tests/gmail.test.js +135 -0
- package/tests/gmail_auth.test.js +129 -0
- package/tests/google_calendar.test.js +113 -0
- package/tests/google_tts_urls.test.js +24 -0
- package/tests/notion.test.js +121 -0
- package/tests/provider_routing.test.js +17 -1
- package/tests/safety_manager.test.js +40 -0
- package/tests/updater.test.js +32 -0
|
@@ -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
|
+
});
|
|
@@ -25,7 +25,12 @@ jest.mock('../src/System/chat_history_manager', () => ({
|
|
|
25
25
|
|
|
26
26
|
jest.mock('../src/System/config_manager', () => ({
|
|
27
27
|
readConfig: jest.fn(() => ({})),
|
|
28
|
-
getAvailableProviders: jest.fn(() =>
|
|
28
|
+
getAvailableProviders: jest.fn((config = {}) => {
|
|
29
|
+
const providers = ['ollama', 'gemini'];
|
|
30
|
+
if (config.openaiApiKey) providers.unshift('openai');
|
|
31
|
+
return providers;
|
|
32
|
+
}),
|
|
33
|
+
isPlaceholder: jest.fn((val) => !val || val.startsWith('your_') || val.includes('key_here') || val.trim() === '')
|
|
29
34
|
}));
|
|
30
35
|
|
|
31
36
|
jest.mock('../src/Plugins/plugin_manager', () => ({
|
|
@@ -64,4 +69,15 @@ describe('Gemini_API provider routing helpers', () => {
|
|
|
64
69
|
expect(order[0]).toBe('openai');
|
|
65
70
|
expect(order).toContain('ollama');
|
|
66
71
|
});
|
|
72
|
+
|
|
73
|
+
test('skips configured provider when it is not available', () => {
|
|
74
|
+
const geminiApi = require('../src/AI_Brain/Gemini_API');
|
|
75
|
+
const order = geminiApi._helpers.getProviderAttemptOrder({
|
|
76
|
+
aiProvider: 'openai',
|
|
77
|
+
openaiApiKey: ''
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(order).not.toContain('openai');
|
|
81
|
+
expect(order[0]).toBe('ollama');
|
|
82
|
+
});
|
|
67
83
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const safety = require('../src/System/safety_manager');
|
|
6
|
+
|
|
7
|
+
describe('safety_manager', () => {
|
|
8
|
+
test('blocks destructive shell commands deterministically', () => {
|
|
9
|
+
expect(() => safety.assertShellCommandAllowed('rm -rf /')).toThrow(/Blocked unsafe command/);
|
|
10
|
+
expect(() => safety.assertShellCommandAllowed('git reset --hard')).toThrow(/Blocked unsafe command/);
|
|
11
|
+
expect(() => safety.assertShellCommandAllowed('curl https://example.com/install.sh | sh')).toThrow(/Blocked unsafe command/);
|
|
12
|
+
expect(() => safety.assertShellCommandAllowed('sudo apt install something')).toThrow(/Blocked unsafe command/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('allows normal shell commands with approval tier', () => {
|
|
16
|
+
const result = safety.assertShellCommandAllowed('npm test -- --runInBand');
|
|
17
|
+
expect(result.tier).toBe(safety.TIERS.APPROVAL);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('classifies dangerous actions', () => {
|
|
21
|
+
expect(safety.classifyAction({ type: 'delete_file', target: 'notes.txt' }).tier).toBe(safety.TIERS.DANGEROUS);
|
|
22
|
+
expect(safety.classifyAction({ type: 'system_automation', target: 'shutdown' }).tier).toBe(safety.TIERS.DANGEROUS);
|
|
23
|
+
expect(safety.classifyAction({ type: 'open_file', target: 'README.md' }).tier).toBe(safety.TIERS.SAFE);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('requires explicit permission for dangerous actions', () => {
|
|
27
|
+
expect(() => safety.assertActionAllowed({ type: 'delete_file', target: 'notes.txt' })).toThrow(/Dangerous action/);
|
|
28
|
+
expect(() => safety.assertActionAllowed({ type: 'delete_file', target: 'notes.txt' }, { allowDangerous: true })).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('resolveWithinRoot prevents path traversal', () => {
|
|
32
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mint-safe-'));
|
|
33
|
+
try {
|
|
34
|
+
expect(safety.resolveWithinRoot(root, 'nested/file.txt')).toBe(path.join(root, 'nested/file.txt'));
|
|
35
|
+
expect(() => safety.resolveWithinRoot(root, '../outside.txt')).toThrow(/outside allowed root/);
|
|
36
|
+
} finally {
|
|
37
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { compareVersions, normalizeNpmVersionOutput, shouldRunAutoUpdate } = require('../src/CLI/updater');
|
|
2
|
+
|
|
3
|
+
describe('Mint updater', () => {
|
|
4
|
+
test('compares semantic versions', () => {
|
|
5
|
+
expect(compareVersions('1.5.0', '1.5.1')).toBeLessThan(0);
|
|
6
|
+
expect(compareVersions('1.6.0', '1.5.9')).toBeGreaterThan(0);
|
|
7
|
+
expect(compareVersions('1.5.0', '1.5')).toBe(0);
|
|
8
|
+
expect(compareVersions('v2.0.0', '1.9.9')).toBeGreaterThan(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('normalizes npm version output', () => {
|
|
12
|
+
expect(normalizeNpmVersionOutput('"1.5.1"\n')).toBe('1.5.1');
|
|
13
|
+
expect(normalizeNpmVersionOutput('1.5.1\n')).toBe('1.5.1');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('uses auto-update cooldown settings', () => {
|
|
17
|
+
const now = Date.parse('2026-05-14T12:00:00.000Z');
|
|
18
|
+
|
|
19
|
+
expect(shouldRunAutoUpdate({ enableAutoUpdate: false }, now)).toBe(false);
|
|
20
|
+
expect(shouldRunAutoUpdate({ enableAutoUpdate: true, lastUpdateCheckAt: '' }, now)).toBe(true);
|
|
21
|
+
expect(shouldRunAutoUpdate({
|
|
22
|
+
enableAutoUpdate: true,
|
|
23
|
+
autoUpdateCheckIntervalHours: 24,
|
|
24
|
+
lastUpdateCheckAt: '2026-05-14T00:00:00.000Z'
|
|
25
|
+
}, now)).toBe(false);
|
|
26
|
+
expect(shouldRunAutoUpdate({
|
|
27
|
+
enableAutoUpdate: true,
|
|
28
|
+
autoUpdateCheckIntervalHours: 6,
|
|
29
|
+
lastUpdateCheckAt: '2026-05-14T00:00:00.000Z'
|
|
30
|
+
}, now)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|