opc-agent 3.0.1 → 4.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.
Files changed (151) hide show
  1. package/README.md +30 -24
  2. package/dist/channels/dingtalk.d.ts +17 -0
  3. package/dist/channels/dingtalk.js +38 -0
  4. package/dist/channels/googlechat.d.ts +14 -0
  5. package/dist/channels/googlechat.js +37 -0
  6. package/dist/channels/imessage.d.ts +13 -0
  7. package/dist/channels/imessage.js +28 -0
  8. package/dist/channels/irc.d.ts +20 -0
  9. package/dist/channels/irc.js +71 -0
  10. package/dist/channels/line.d.ts +14 -0
  11. package/dist/channels/line.js +28 -0
  12. package/dist/channels/matrix.d.ts +15 -0
  13. package/dist/channels/matrix.js +28 -0
  14. package/dist/channels/mattermost.d.ts +18 -0
  15. package/dist/channels/mattermost.js +49 -0
  16. package/dist/channels/msteams.d.ts +14 -0
  17. package/dist/channels/msteams.js +28 -0
  18. package/dist/channels/nostr.d.ts +14 -0
  19. package/dist/channels/nostr.js +28 -0
  20. package/dist/channels/qq.d.ts +15 -0
  21. package/dist/channels/qq.js +28 -0
  22. package/dist/channels/signal.d.ts +14 -0
  23. package/dist/channels/signal.js +28 -0
  24. package/dist/channels/sms.d.ts +15 -0
  25. package/dist/channels/sms.js +28 -0
  26. package/dist/channels/twitch.d.ts +17 -0
  27. package/dist/channels/twitch.js +59 -0
  28. package/dist/channels/voice-call.d.ts +27 -0
  29. package/dist/channels/voice-call.js +82 -0
  30. package/dist/channels/whatsapp.d.ts +14 -0
  31. package/dist/channels/whatsapp.js +28 -0
  32. package/dist/cli.js +36 -0
  33. package/dist/core/api-server.d.ts +25 -0
  34. package/dist/core/api-server.js +286 -0
  35. package/dist/core/audio.d.ts +50 -0
  36. package/dist/core/audio.js +68 -0
  37. package/dist/core/context-discovery.d.ts +16 -0
  38. package/dist/core/context-discovery.js +107 -0
  39. package/dist/core/context-refs.d.ts +29 -0
  40. package/dist/core/context-refs.js +162 -0
  41. package/dist/core/gateway.d.ts +53 -0
  42. package/dist/core/gateway.js +80 -0
  43. package/dist/core/heartbeat.d.ts +19 -0
  44. package/dist/core/heartbeat.js +50 -0
  45. package/dist/core/hooks.d.ts +28 -0
  46. package/dist/core/hooks.js +82 -0
  47. package/dist/core/ide-bridge.d.ts +53 -0
  48. package/dist/core/ide-bridge.js +97 -0
  49. package/dist/core/node-network.d.ts +23 -0
  50. package/dist/core/node-network.js +77 -0
  51. package/dist/core/profiles.d.ts +27 -0
  52. package/dist/core/profiles.js +131 -0
  53. package/dist/core/sandbox.d.ts +25 -0
  54. package/dist/core/sandbox.js +84 -1
  55. package/dist/core/session-manager.d.ts +33 -0
  56. package/dist/core/session-manager.js +157 -0
  57. package/dist/core/vision.d.ts +45 -0
  58. package/dist/core/vision.js +177 -0
  59. package/dist/index.d.ts +64 -1
  60. package/dist/index.js +86 -3
  61. package/dist/memory/context-compressor.d.ts +43 -0
  62. package/dist/memory/context-compressor.js +167 -0
  63. package/dist/memory/index.d.ts +4 -0
  64. package/dist/memory/index.js +5 -1
  65. package/dist/memory/user-profiler.d.ts +50 -0
  66. package/dist/memory/user-profiler.js +201 -0
  67. package/dist/schema/oad.d.ts +12 -12
  68. package/dist/security/approvals.d.ts +53 -0
  69. package/dist/security/approvals.js +115 -0
  70. package/dist/security/elevated.d.ts +41 -0
  71. package/dist/security/elevated.js +89 -0
  72. package/dist/security/index.d.ts +6 -0
  73. package/dist/security/index.js +7 -1
  74. package/dist/security/secrets.d.ts +34 -0
  75. package/dist/security/secrets.js +115 -0
  76. package/dist/tools/builtin/browser.d.ts +47 -0
  77. package/dist/tools/builtin/browser.js +284 -0
  78. package/dist/tools/builtin/home-assistant.d.ts +12 -0
  79. package/dist/tools/builtin/home-assistant.js +126 -0
  80. package/dist/tools/builtin/index.d.ts +6 -1
  81. package/dist/tools/builtin/index.js +18 -2
  82. package/dist/tools/builtin/rl-tools.d.ts +13 -0
  83. package/dist/tools/builtin/rl-tools.js +228 -0
  84. package/dist/tools/builtin/vision.d.ts +6 -0
  85. package/dist/tools/builtin/vision.js +61 -0
  86. package/package.json +2 -2
  87. package/src/channels/dingtalk.ts +46 -0
  88. package/src/channels/googlechat.ts +42 -0
  89. package/src/channels/imessage.ts +32 -0
  90. package/src/channels/irc.ts +82 -0
  91. package/src/channels/line.ts +33 -0
  92. package/src/channels/matrix.ts +34 -0
  93. package/src/channels/mattermost.ts +57 -0
  94. package/src/channels/msteams.ts +33 -0
  95. package/src/channels/nostr.ts +33 -0
  96. package/src/channels/qq.ts +34 -0
  97. package/src/channels/signal.ts +33 -0
  98. package/src/channels/sms.ts +34 -0
  99. package/src/channels/twitch.ts +65 -0
  100. package/src/channels/voice-call.ts +100 -0
  101. package/src/channels/whatsapp.ts +33 -0
  102. package/src/cli.ts +40 -0
  103. package/src/core/api-server.ts +277 -0
  104. package/src/core/audio.ts +98 -0
  105. package/src/core/context-discovery.ts +85 -0
  106. package/src/core/context-refs.ts +140 -0
  107. package/src/core/gateway.ts +106 -0
  108. package/src/core/heartbeat.ts +51 -0
  109. package/src/core/hooks.ts +105 -0
  110. package/src/core/ide-bridge.ts +133 -0
  111. package/src/core/node-network.ts +86 -0
  112. package/src/core/profiles.ts +122 -0
  113. package/src/core/sandbox.ts +100 -0
  114. package/src/core/session-manager.ts +137 -0
  115. package/src/core/vision.ts +180 -0
  116. package/src/index.ts +84 -1
  117. package/src/memory/context-compressor.ts +189 -0
  118. package/src/memory/index.ts +4 -0
  119. package/src/memory/user-profiler.ts +215 -0
  120. package/src/security/approvals.ts +143 -0
  121. package/src/security/elevated.ts +105 -0
  122. package/src/security/index.ts +6 -0
  123. package/src/security/secrets.ts +129 -0
  124. package/src/tools/builtin/browser.ts +299 -0
  125. package/src/tools/builtin/home-assistant.ts +116 -0
  126. package/src/tools/builtin/index.ts +9 -2
  127. package/src/tools/builtin/rl-tools.ts +243 -0
  128. package/src/tools/builtin/vision.ts +64 -0
  129. package/tests/api-server.test.ts +148 -0
  130. package/tests/approvals.test.ts +89 -0
  131. package/tests/audio.test.ts +40 -0
  132. package/tests/browser.test.ts +179 -0
  133. package/tests/builtin-tools.test.ts +83 -83
  134. package/tests/channels-extra.test.ts +45 -0
  135. package/tests/context-compressor.test.ts +172 -0
  136. package/tests/context-refs.test.ts +121 -0
  137. package/tests/elevated.test.ts +69 -0
  138. package/tests/gateway.test.ts +63 -71
  139. package/tests/home-assistant.test.ts +40 -0
  140. package/tests/hooks.test.ts +79 -0
  141. package/tests/ide-bridge.test.ts +38 -0
  142. package/tests/node-network.test.ts +74 -0
  143. package/tests/profiles.test.ts +61 -0
  144. package/tests/rl-tools.test.ts +93 -0
  145. package/tests/sandbox-manager.test.ts +46 -0
  146. package/tests/secrets.test.ts +107 -0
  147. package/tests/tools/builtin-extended.test.ts +138 -138
  148. package/tests/user-profiler.test.ts +169 -0
  149. package/tests/v090-features.test.ts +254 -0
  150. package/tests/vision.test.ts +61 -0
  151. package/tests/voice-call.test.ts +47 -0
@@ -0,0 +1,64 @@
1
+ import type { MCPTool, MCPToolResult } from '../mcp';
2
+ import { VisionManager } from '../../core/vision';
3
+ import type { ImageInput } from '../../core/vision';
4
+
5
+ const manager = new VisionManager();
6
+
7
+ export const visionAnalyzeTool: MCPTool = {
8
+ name: 'vision_analyze',
9
+ description: 'Analyze an image using vision AI. Provide image as URL or base64.',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ image_url: { type: 'string', description: 'URL of the image to analyze' },
14
+ image_base64: { type: 'string', description: 'Base64-encoded image data' },
15
+ prompt: { type: 'string', description: 'Optional prompt for analysis' },
16
+ },
17
+ },
18
+ async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
19
+ const imgInput: ImageInput = input.image_url
20
+ ? { type: 'url', data: input.image_url as string }
21
+ : { type: 'base64', data: input.image_base64 as string };
22
+ const result = await manager.analyze(imgInput, input.prompt as string | undefined);
23
+ return { content: JSON.stringify(result) };
24
+ },
25
+ };
26
+
27
+ export const visionExtractTextTool: MCPTool = {
28
+ name: 'vision_extract_text',
29
+ description: 'Extract text (OCR) from an image.',
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ image_url: { type: 'string', description: 'URL of the image' },
34
+ image_base64: { type: 'string', description: 'Base64-encoded image data' },
35
+ },
36
+ },
37
+ async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
38
+ const imgInput: ImageInput = input.image_url
39
+ ? { type: 'url', data: input.image_url as string }
40
+ : { type: 'base64', data: input.image_base64 as string };
41
+ const text = await manager.extractText(imgInput);
42
+ return { content: text };
43
+ },
44
+ };
45
+
46
+ export const visionCompareTool: MCPTool = {
47
+ name: 'vision_compare',
48
+ description: 'Compare multiple images.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ image_urls: { type: 'array', items: { type: 'string' }, description: 'URLs of images to compare' },
53
+ prompt: { type: 'string', description: 'Optional comparison prompt' },
54
+ },
55
+ },
56
+ async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
57
+ const urls = input.image_urls as string[];
58
+ const images: ImageInput[] = urls.map(url => ({ type: 'url' as const, data: url }));
59
+ const result = await manager.compareImages(images, input.prompt as string | undefined);
60
+ return { content: result };
61
+ },
62
+ };
63
+
64
+ export const visionTools: MCPTool[] = [visionAnalyzeTool, visionExtractTextTool, visionCompareTool];
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { APIServer } from '../src/core/api-server';
3
+
4
+ function createMockAgent(overrides: any = {}) {
5
+ return {
6
+ name: 'test-agent',
7
+ state: 'running',
8
+ config: { model: 'test-model', name: 'test-agent' },
9
+ chat: async (msg: string) => `echo: ${msg}`,
10
+ ...overrides,
11
+ };
12
+ }
13
+
14
+ async function request(port: number, method: string, path: string, body?: any, headers?: Record<string, string>) {
15
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
16
+ method,
17
+ headers: { 'Content-Type': 'application/json', ...headers },
18
+ body: body ? JSON.stringify(body) : undefined,
19
+ });
20
+ return res;
21
+ }
22
+
23
+ describe('APIServer', () => {
24
+ let server: APIServer;
25
+ const port = 19876;
26
+
27
+ beforeAll(async () => {
28
+ server = new APIServer({ port, host: '127.0.0.1', agent: createMockAgent() });
29
+ await server.start();
30
+ });
31
+ afterAll(async () => { await server.stop(); });
32
+
33
+ it('GET /health returns ok', async () => {
34
+ const res = await request(port, 'GET', '/health');
35
+ expect(res.status).toBe(200);
36
+ const data: any = await res.json();
37
+ expect(data.status).toBe('ok');
38
+ });
39
+
40
+ it('GET /v1/models lists models', async () => {
41
+ const res = await request(port, 'GET', '/v1/models');
42
+ const data: any = await res.json();
43
+ expect(data.object).toBe('list');
44
+ expect(data.data[0].id).toBe('test-model');
45
+ });
46
+
47
+ it('GET /v1/agent/status returns agent info', async () => {
48
+ const res = await request(port, 'GET', '/v1/agent/status');
49
+ const data: any = await res.json();
50
+ expect(data.name).toBe('test-agent');
51
+ expect(data.state).toBe('running');
52
+ });
53
+
54
+ it('POST /v1/chat/completions non-streaming', async () => {
55
+ const res = await request(port, 'POST', '/v1/chat/completions', {
56
+ model: 'test-model',
57
+ messages: [{ role: 'user', content: 'hello' }],
58
+ });
59
+ expect(res.status).toBe(200);
60
+ const data: any = await res.json();
61
+ expect(data.choices[0].message.content).toBe('echo: hello');
62
+ expect(data.choices[0].finish_reason).toBe('stop');
63
+ expect(data.id).toMatch(/^chatcmpl-/);
64
+ expect(data.object).toBe('chat.completion');
65
+ });
66
+
67
+ it('POST /v1/chat/completions streaming', async () => {
68
+ const res = await request(port, 'POST', '/v1/chat/completions', {
69
+ messages: [{ role: 'user', content: 'hi' }],
70
+ stream: true,
71
+ });
72
+ expect(res.status).toBe(200);
73
+ expect(res.headers.get('content-type')).toBe('text/event-stream');
74
+ const text = await res.text();
75
+ expect(text).toContain('data: ');
76
+ expect(text).toContain('[DONE]');
77
+ // Parse SSE chunks
78
+ const chunks = text.split('\n').filter(l => l.startsWith('data: ') && !l.includes('[DONE]'));
79
+ expect(chunks.length).toBeGreaterThan(0);
80
+ const first = JSON.parse(chunks[0].slice(6));
81
+ expect(first.object).toBe('chat.completion.chunk');
82
+ });
83
+
84
+ it('POST /v1/chat/completions rejects missing messages', async () => {
85
+ const res = await request(port, 'POST', '/v1/chat/completions', { model: 'x' });
86
+ expect(res.status).toBe(400);
87
+ const data: any = await res.json();
88
+ expect(data.error.type).toBe('invalid_request_error');
89
+ });
90
+
91
+ it('POST /v1/chat/completions rejects invalid JSON', async () => {
92
+ const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: 'not json',
96
+ });
97
+ expect(res.status).toBe(400);
98
+ });
99
+
100
+ it('OPTIONS returns CORS headers', async () => {
101
+ const res = await fetch(`http://127.0.0.1:${port}/v1/models`, { method: 'OPTIONS' });
102
+ expect(res.status).toBe(204);
103
+ expect(res.headers.get('access-control-allow-origin')).toBe('*');
104
+ });
105
+
106
+ it('unknown route returns 404', async () => {
107
+ const res = await request(port, 'GET', '/v1/unknown');
108
+ expect(res.status).toBe(404);
109
+ const data: any = await res.json();
110
+ expect(data.error.code).toBe(404);
111
+ });
112
+
113
+ it('POST /v1/embeddings returns 501 without embed provider', async () => {
114
+ const res = await request(port, 'POST', '/v1/embeddings', { input: 'hello' });
115
+ expect(res.status).toBe(501);
116
+ });
117
+
118
+ it('POST /v1/embeddings rejects missing input', async () => {
119
+ const res = await request(port, 'POST', '/v1/embeddings', { model: 'x' });
120
+ expect(res.status).toBe(400);
121
+ });
122
+ });
123
+
124
+ describe('APIServer with auth', () => {
125
+ let server: APIServer;
126
+ const port = 19877;
127
+
128
+ beforeAll(async () => {
129
+ server = new APIServer({ port, host: '127.0.0.1', apiKey: 'secret-key', agent: createMockAgent() });
130
+ await server.start();
131
+ });
132
+ afterAll(async () => { await server.stop(); });
133
+
134
+ it('rejects unauthenticated requests', async () => {
135
+ const res = await request(port, 'GET', '/v1/models');
136
+ expect(res.status).toBe(401);
137
+ });
138
+
139
+ it('accepts valid Bearer token', async () => {
140
+ const res = await request(port, 'GET', '/v1/models', undefined, { Authorization: 'Bearer secret-key' });
141
+ expect(res.status).toBe(200);
142
+ });
143
+
144
+ it('health check works without auth', async () => {
145
+ const res = await request(port, 'GET', '/health');
146
+ expect(res.status).toBe(200);
147
+ });
148
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ExecApprovalManager } from '../src/security/approvals';
3
+
4
+ describe('ExecApprovalManager', () => {
5
+ it('should default to elevated-only policy', () => {
6
+ const mgr = new ExecApprovalManager();
7
+ expect(mgr.getPolicy()).toBe('elevated-only');
8
+ mgr.destroy();
9
+ });
10
+
11
+ it('should require approval for elevated commands in elevated-only mode', () => {
12
+ const mgr = new ExecApprovalManager({ policy: 'elevated-only' });
13
+ expect(mgr.needsApproval('ls', true)).toBe(true);
14
+ expect(mgr.needsApproval('ls', false)).toBe(false);
15
+ mgr.destroy();
16
+ });
17
+
18
+ it('should always require approval in always mode', () => {
19
+ const mgr = new ExecApprovalManager({ policy: 'always' });
20
+ expect(mgr.needsApproval('ls', false)).toBe(true);
21
+ mgr.destroy();
22
+ });
23
+
24
+ it('should never require approval in never mode', () => {
25
+ const mgr = new ExecApprovalManager({ policy: 'never' });
26
+ expect(mgr.needsApproval('rm -rf /', true)).toBe(false);
27
+ mgr.destroy();
28
+ });
29
+
30
+ it('should skip approval for allowlisted commands', () => {
31
+ const mgr = new ExecApprovalManager({ policy: 'allowlist', allowedCommands: ['git ', 'npm test'] });
32
+ expect(mgr.needsApproval('git pull', false)).toBe(false);
33
+ expect(mgr.needsApproval('rm -rf /', false)).toBe(true);
34
+ mgr.destroy();
35
+ });
36
+
37
+ it('should create and approve requests', () => {
38
+ const mgr = new ExecApprovalManager();
39
+ const req = mgr.request('sudo reboot', true);
40
+ expect(req.status).toBe('pending');
41
+ expect(mgr.getPending()).toHaveLength(1);
42
+ const approved = mgr.approve(req.id, 'admin');
43
+ expect(approved.status).toBe('approved');
44
+ expect(mgr.getPending()).toHaveLength(0);
45
+ expect(mgr.getHistory()).toHaveLength(1);
46
+ mgr.destroy();
47
+ });
48
+
49
+ it('should deny requests', () => {
50
+ const mgr = new ExecApprovalManager();
51
+ const req = mgr.request('rm -rf /', true);
52
+ const denied = mgr.deny(req.id, 'admin', 'too dangerous');
53
+ expect(denied.status).toBe('denied');
54
+ expect(denied.reason).toBe('too dangerous');
55
+ mgr.destroy();
56
+ });
57
+
58
+ it('should throw on double approve', () => {
59
+ const mgr = new ExecApprovalManager();
60
+ const req = mgr.request('test', false);
61
+ mgr.approve(req.id, 'admin');
62
+ expect(() => mgr.approve(req.id, 'admin')).toThrow();
63
+ mgr.destroy();
64
+ });
65
+
66
+ it('should expire pending requests', () => {
67
+ const mgr = new ExecApprovalManager({ expiryMs: 1 });
68
+ const req = mgr.request('test', false);
69
+ // Wait a tick then check
70
+ return new Promise<void>(resolve => {
71
+ setTimeout(() => {
72
+ mgr.checkExpiry();
73
+ expect(mgr.getPending()).toHaveLength(0);
74
+ const found = mgr.getRequest(req.id);
75
+ expect(found?.status).toBe('expired');
76
+ mgr.destroy();
77
+ resolve();
78
+ }, 10);
79
+ });
80
+ });
81
+
82
+ it('should fire onRequest callback', () => {
83
+ let called = false;
84
+ const mgr = new ExecApprovalManager({ onRequest: () => { called = true; } });
85
+ mgr.request('test', false);
86
+ expect(called).toBe(true);
87
+ mgr.destroy();
88
+ });
89
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { AudioProcessor } from '../src/core/audio';
3
+
4
+ describe('AudioProcessor', () => {
5
+ it('should detect WAV format', () => {
6
+ const wav = Buffer.from('RIFF\x00\x00\x00\x00WAVEfmt ');
7
+ expect(AudioProcessor.detectFormat(wav)).toBe('wav');
8
+ });
9
+
10
+ it('should detect MP3 format (ID3)', () => {
11
+ const mp3 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x00');
12
+ expect(AudioProcessor.detectFormat(mp3)).toBe('mp3');
13
+ });
14
+
15
+ it('should return unknown for unrecognized format', () => {
16
+ const unknown = Buffer.from('NOTAFORMAT');
17
+ expect(AudioProcessor.detectFormat(unknown)).toBe('unknown');
18
+ });
19
+
20
+ it('should split buffer into chunks', () => {
21
+ const buf = Buffer.alloc(100, 0x42);
22
+ const chunks = AudioProcessor.split(buf, 30);
23
+ expect(chunks).toHaveLength(4);
24
+ expect(chunks[0].length).toBe(30);
25
+ expect(chunks[3].length).toBe(10);
26
+ });
27
+
28
+ it('should throw without provider on transcribe', async () => {
29
+ const proc = new AudioProcessor();
30
+ await expect(proc.transcribe(Buffer.alloc(0))).rejects.toThrow('No transcribe provider');
31
+ });
32
+
33
+ it('should call transcribe provider', async () => {
34
+ const proc = new AudioProcessor({
35
+ transcribe: async () => ({ text: 'hello world' }),
36
+ });
37
+ const result = await proc.transcribe(Buffer.alloc(10));
38
+ expect(result.text).toBe('hello world');
39
+ });
40
+ });
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { BrowserManager } from '../src/tools/builtin/browser';
3
+
4
+ // Mock playwright
5
+ const mockPage = {
6
+ goto: vi.fn().mockResolvedValue(undefined),
7
+ title: vi.fn().mockResolvedValue('Test Page'),
8
+ url: vi.fn().mockReturnValue('https://example.com'),
9
+ innerText: vi.fn().mockResolvedValue('Hello World'),
10
+ click: vi.fn().mockResolvedValue(undefined),
11
+ fill: vi.fn().mockResolvedValue(undefined),
12
+ screenshot: vi.fn().mockResolvedValue(Buffer.from('fake-png')),
13
+ $$eval: vi.fn().mockResolvedValue([]),
14
+ evaluate: vi.fn().mockResolvedValue('result'),
15
+ waitForSelector: vi.fn().mockResolvedValue(true),
16
+ goBack: vi.fn().mockResolvedValue(undefined),
17
+ };
18
+
19
+ const mockContext = {
20
+ newPage: vi.fn().mockResolvedValue(mockPage),
21
+ };
22
+
23
+ const mockBrowser = {
24
+ newContext: vi.fn().mockResolvedValue(mockContext),
25
+ close: vi.fn().mockResolvedValue(undefined),
26
+ };
27
+
28
+ const mockPlaywright = () => ({
29
+ chromium: {
30
+ launch: vi.fn().mockResolvedValue(mockBrowser),
31
+ },
32
+ });
33
+
34
+ describe('BrowserManager', () => {
35
+ let manager: BrowserManager;
36
+
37
+ beforeEach(() => {
38
+ manager = new BrowserManager(mockPlaywright);
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await manager.close();
44
+ });
45
+
46
+ it('lazy initializes browser on first call', async () => {
47
+ await manager.ensureBrowser();
48
+ expect(mockBrowser.newContext).toHaveBeenCalledOnce();
49
+ });
50
+
51
+ it('reuses browser on subsequent calls', async () => {
52
+ await manager.ensureBrowser();
53
+ await manager.ensureBrowser();
54
+ expect(mockBrowser.newContext).toHaveBeenCalledOnce();
55
+ });
56
+
57
+ it('navigate returns title, text, url', async () => {
58
+ const result = await manager.navigate('https://example.com');
59
+ expect(result.title).toBe('Test Page');
60
+ expect(result.text).toBe('Hello World');
61
+ expect(result.url).toBe('https://example.com');
62
+ expect(mockPage.goto).toHaveBeenCalledWith('https://example.com', expect.any(Object));
63
+ });
64
+
65
+ it('click calls page.click with selector', async () => {
66
+ await manager.click('#btn');
67
+ expect(mockPage.click).toHaveBeenCalledWith('#btn', expect.any(Object));
68
+ });
69
+
70
+ it('type calls page.fill', async () => {
71
+ await manager.type('#input', 'hello');
72
+ expect(mockPage.fill).toHaveBeenCalledWith('#input', 'hello', expect.any(Object));
73
+ });
74
+
75
+ it('screenshot returns base64', async () => {
76
+ const result = await manager.screenshot();
77
+ expect(typeof result).toBe('string');
78
+ expect(result).toBe(Buffer.from('fake-png').toString('base64'));
79
+ });
80
+
81
+ it('extract returns text, links, images', async () => {
82
+ mockPage.$$eval.mockResolvedValueOnce(['https://link1.com']).mockResolvedValueOnce(['img1.png']);
83
+ const result = await manager.extract();
84
+ expect(result.text).toBe('Hello World');
85
+ expect(result.links).toEqual(['https://link1.com']);
86
+ expect(result.images).toEqual(['img1.png']);
87
+ });
88
+
89
+ it('scroll calls evaluate with direction', async () => {
90
+ await manager.scroll('down', 300);
91
+ expect(mockPage.evaluate).toHaveBeenCalled();
92
+ });
93
+
94
+ it('back calls goBack', async () => {
95
+ await manager.back();
96
+ expect(mockPage.goBack).toHaveBeenCalled();
97
+ });
98
+
99
+ it('evaluate runs script', async () => {
100
+ mockPage.evaluate.mockResolvedValueOnce(42);
101
+ const result = await manager.evaluate('1+1');
102
+ expect(result).toBe(42);
103
+ });
104
+
105
+ it('getImages calls $$eval', async () => {
106
+ mockPage.$$eval.mockResolvedValueOnce([{ src: 'a.png', alt: 'A' }]);
107
+ const images = await manager.getImages();
108
+ expect(images).toEqual([{ src: 'a.png', alt: 'A' }]);
109
+ });
110
+
111
+ it('waitFor returns true when found', async () => {
112
+ const result = await manager.waitFor('.test');
113
+ expect(result).toBe(true);
114
+ });
115
+
116
+ it('waitFor returns false on timeout', async () => {
117
+ mockPage.waitForSelector.mockRejectedValueOnce(new Error('timeout'));
118
+ const result = await manager.waitFor('.missing', 100);
119
+ expect(result).toBe(false);
120
+ });
121
+
122
+ it('close cleans up browser', async () => {
123
+ await manager.ensureBrowser();
124
+ await manager.close();
125
+ expect(mockBrowser.close).toHaveBeenCalled();
126
+ });
127
+
128
+ it('close is safe to call without browser', async () => {
129
+ await manager.close(); // should not throw
130
+ });
131
+
132
+ it('throws helpful error when playwright not installed', async () => {
133
+ const noPlaywright = new BrowserManager();
134
+ await expect(noPlaywright.ensureBrowser()).rejects.toThrow('Install playwright: npm i playwright');
135
+ });
136
+ });
137
+
138
+ describe('Browser tool parameter validation', () => {
139
+ it('browser_navigate requires url', async () => {
140
+ const { browserNavigateTool } = await import('../src/tools/builtin/browser');
141
+ const result = await browserNavigateTool.execute({});
142
+ expect(result.isError).toBe(true);
143
+ expect(result.content).toContain('url');
144
+ });
145
+
146
+ it('browser_click requires selector', async () => {
147
+ const { browserClickTool } = await import('../src/tools/builtin/browser');
148
+ const result = await browserClickTool.execute({});
149
+ expect(result.isError).toBe(true);
150
+ expect(result.content).toContain('selector');
151
+ });
152
+
153
+ it('browser_type requires selector and text', async () => {
154
+ const { browserTypeTool } = await import('../src/tools/builtin/browser');
155
+ const result = await browserTypeTool.execute({});
156
+ expect(result.isError).toBe(true);
157
+ });
158
+
159
+ it('browser_eval requires script', async () => {
160
+ const { browserEvalTool } = await import('../src/tools/builtin/browser');
161
+ const result = await browserEvalTool.execute({});
162
+ expect(result.isError).toBe(true);
163
+ expect(result.content).toContain('script');
164
+ });
165
+
166
+ it('browser_wait requires selector', async () => {
167
+ const { browserWaitTool } = await import('../src/tools/builtin/browser');
168
+ const result = await browserWaitTool.execute({});
169
+ expect(result.isError).toBe(true);
170
+ expect(result.content).toContain('selector');
171
+ });
172
+
173
+ it('browser_scroll requires direction', async () => {
174
+ const { browserScrollTool } = await import('../src/tools/builtin/browser');
175
+ const result = await browserScrollTool.execute({});
176
+ expect(result.isError).toBe(true);
177
+ expect(result.content).toContain('direction');
178
+ });
179
+ });