linkedin-automation-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/.env.example +12 -0
  2. package/.github/workflows/ci.yml +66 -0
  3. package/.github/workflows/publish.yml +48 -0
  4. package/.husky/pre-commit +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +10 -0
  7. package/AGENTS.md +294 -0
  8. package/CHANGELOG.md +40 -0
  9. package/GIT_RELEASE.md +167 -0
  10. package/LICENSE +21 -0
  11. package/Makefile +30 -0
  12. package/NPM_PUBLISHING.md +230 -0
  13. package/PYEOF +0 -0
  14. package/README.md +295 -0
  15. package/TESTING-GUIDE.md +151 -0
  16. package/cmd/linkedin/main.go +9 -0
  17. package/dist/agent/action-executor.d.ts +81 -0
  18. package/dist/agent/action-executor.d.ts.map +1 -0
  19. package/dist/agent/action-executor.js +170 -0
  20. package/dist/agent/action-executor.js.map +1 -0
  21. package/dist/agent/action-executor.test.d.ts +2 -0
  22. package/dist/agent/action-executor.test.d.ts.map +1 -0
  23. package/dist/agent/action-executor.test.js +366 -0
  24. package/dist/agent/action-executor.test.js.map +1 -0
  25. package/dist/agent/claude-client.d.ts +74 -0
  26. package/dist/agent/claude-client.d.ts.map +1 -0
  27. package/dist/agent/claude-client.js +314 -0
  28. package/dist/agent/claude-client.js.map +1 -0
  29. package/dist/agent/claude-client.test.d.ts +2 -0
  30. package/dist/agent/claude-client.test.d.ts.map +1 -0
  31. package/dist/agent/claude-client.test.js +590 -0
  32. package/dist/agent/claude-client.test.js.map +1 -0
  33. package/dist/agent/dom-extractor.d.ts +50 -0
  34. package/dist/agent/dom-extractor.d.ts.map +1 -0
  35. package/dist/agent/dom-extractor.js +374 -0
  36. package/dist/agent/dom-extractor.js.map +1 -0
  37. package/dist/agent/dom-extractor.test.d.ts +7 -0
  38. package/dist/agent/dom-extractor.test.d.ts.map +1 -0
  39. package/dist/agent/dom-extractor.test.js +504 -0
  40. package/dist/agent/dom-extractor.test.js.map +1 -0
  41. package/dist/agent/extension-client.d.ts +75 -0
  42. package/dist/agent/extension-client.d.ts.map +1 -0
  43. package/dist/agent/extension-client.js +245 -0
  44. package/dist/agent/extension-client.js.map +1 -0
  45. package/dist/agent/index.d.ts +8 -0
  46. package/dist/agent/index.d.ts.map +1 -0
  47. package/dist/agent/index.js +16 -0
  48. package/dist/agent/index.js.map +1 -0
  49. package/dist/agent/page-agent.d.ts +76 -0
  50. package/dist/agent/page-agent.d.ts.map +1 -0
  51. package/dist/agent/page-agent.js +236 -0
  52. package/dist/agent/page-agent.js.map +1 -0
  53. package/dist/agent/types.d.ts +236 -0
  54. package/dist/agent/types.d.ts.map +1 -0
  55. package/dist/agent/types.js +37 -0
  56. package/dist/agent/types.js.map +1 -0
  57. package/dist/cli/agent-commands.d.ts +3 -0
  58. package/dist/cli/agent-commands.d.ts.map +1 -0
  59. package/dist/cli/agent-commands.js +250 -0
  60. package/dist/cli/agent-commands.js.map +1 -0
  61. package/dist/cli/auth.d.ts +3 -0
  62. package/dist/cli/auth.d.ts.map +1 -0
  63. package/dist/cli/auth.js +288 -0
  64. package/dist/cli/auth.js.map +1 -0
  65. package/dist/cli/company.d.ts +3 -0
  66. package/dist/cli/company.d.ts.map +1 -0
  67. package/dist/cli/company.js +55 -0
  68. package/dist/cli/company.js.map +1 -0
  69. package/dist/cli/connection.d.ts +3 -0
  70. package/dist/cli/connection.d.ts.map +1 -0
  71. package/dist/cli/connection.js +79 -0
  72. package/dist/cli/connection.js.map +1 -0
  73. package/dist/cli/index.d.ts +7 -0
  74. package/dist/cli/index.d.ts.map +1 -0
  75. package/dist/cli/index.js +17 -0
  76. package/dist/cli/index.js.map +1 -0
  77. package/dist/cli/messages.d.ts +3 -0
  78. package/dist/cli/messages.d.ts.map +1 -0
  79. package/dist/cli/messages.js +268 -0
  80. package/dist/cli/messages.js.map +1 -0
  81. package/dist/cli/profile.d.ts +3 -0
  82. package/dist/cli/profile.d.ts.map +1 -0
  83. package/dist/cli/profile.js +81 -0
  84. package/dist/cli/profile.js.map +1 -0
  85. package/dist/cli/profile.test.d.ts +2 -0
  86. package/dist/cli/profile.test.d.ts.map +1 -0
  87. package/dist/cli/profile.test.js +15 -0
  88. package/dist/cli/profile.test.js.map +1 -0
  89. package/dist/cli/reply.d.ts +3 -0
  90. package/dist/cli/reply.d.ts.map +1 -0
  91. package/dist/cli/reply.js +129 -0
  92. package/dist/cli/reply.js.map +1 -0
  93. package/dist/core/audit.d.ts +17 -0
  94. package/dist/core/audit.d.ts.map +1 -0
  95. package/dist/core/audit.js +121 -0
  96. package/dist/core/audit.js.map +1 -0
  97. package/dist/core/audit.test.d.ts +2 -0
  98. package/dist/core/audit.test.d.ts.map +1 -0
  99. package/dist/core/audit.test.js +142 -0
  100. package/dist/core/audit.test.js.map +1 -0
  101. package/dist/core/browser-cookies.d.ts +19 -0
  102. package/dist/core/browser-cookies.d.ts.map +1 -0
  103. package/dist/core/browser-cookies.js +181 -0
  104. package/dist/core/browser-cookies.js.map +1 -0
  105. package/dist/core/browser.d.ts +50 -0
  106. package/dist/core/browser.d.ts.map +1 -0
  107. package/dist/core/browser.js +318 -0
  108. package/dist/core/browser.js.map +1 -0
  109. package/dist/core/config.d.ts +20 -0
  110. package/dist/core/config.d.ts.map +1 -0
  111. package/dist/core/config.js +103 -0
  112. package/dist/core/config.js.map +1 -0
  113. package/dist/core/config.test.d.ts +2 -0
  114. package/dist/core/config.test.d.ts.map +1 -0
  115. package/dist/core/config.test.js +111 -0
  116. package/dist/core/config.test.js.map +1 -0
  117. package/dist/core/storage.d.ts +19 -0
  118. package/dist/core/storage.d.ts.map +1 -0
  119. package/dist/core/storage.js +124 -0
  120. package/dist/core/storage.js.map +1 -0
  121. package/dist/core/storage.test.d.ts +2 -0
  122. package/dist/core/storage.test.d.ts.map +1 -0
  123. package/dist/core/storage.test.js +142 -0
  124. package/dist/core/storage.test.js.map +1 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +63 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/linkedin/auth.d.ts +22 -0
  130. package/dist/linkedin/auth.d.ts.map +1 -0
  131. package/dist/linkedin/auth.js +167 -0
  132. package/dist/linkedin/auth.js.map +1 -0
  133. package/dist/linkedin/company-extractor.d.ts +36 -0
  134. package/dist/linkedin/company-extractor.d.ts.map +1 -0
  135. package/dist/linkedin/company-extractor.js +211 -0
  136. package/dist/linkedin/company-extractor.js.map +1 -0
  137. package/dist/linkedin/company-extractor.test.d.ts +2 -0
  138. package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
  139. package/dist/linkedin/company-extractor.test.js +52 -0
  140. package/dist/linkedin/company-extractor.test.js.map +1 -0
  141. package/dist/linkedin/connector.d.ts +45 -0
  142. package/dist/linkedin/connector.d.ts.map +1 -0
  143. package/dist/linkedin/connector.js +245 -0
  144. package/dist/linkedin/connector.js.map +1 -0
  145. package/dist/linkedin/message-sender.d.ts +32 -0
  146. package/dist/linkedin/message-sender.d.ts.map +1 -0
  147. package/dist/linkedin/message-sender.js +112 -0
  148. package/dist/linkedin/message-sender.js.map +1 -0
  149. package/dist/linkedin/messages.d.ts +78 -0
  150. package/dist/linkedin/messages.d.ts.map +1 -0
  151. package/dist/linkedin/messages.js +745 -0
  152. package/dist/linkedin/messages.js.map +1 -0
  153. package/dist/linkedin/profile.d.ts +37 -0
  154. package/dist/linkedin/profile.d.ts.map +1 -0
  155. package/dist/linkedin/profile.js +268 -0
  156. package/dist/linkedin/profile.js.map +1 -0
  157. package/dist/linkedin/profile.test.d.ts +2 -0
  158. package/dist/linkedin/profile.test.d.ts.map +1 -0
  159. package/dist/linkedin/profile.test.js +68 -0
  160. package/dist/linkedin/profile.test.js.map +1 -0
  161. package/dist/linkedin/reply.d.ts +21 -0
  162. package/dist/linkedin/reply.d.ts.map +1 -0
  163. package/dist/linkedin/reply.js +76 -0
  164. package/dist/linkedin/reply.js.map +1 -0
  165. package/dist/linkedin/selector-engine.d.ts +69 -0
  166. package/dist/linkedin/selector-engine.d.ts.map +1 -0
  167. package/dist/linkedin/selector-engine.js +339 -0
  168. package/dist/linkedin/selector-engine.js.map +1 -0
  169. package/dist/linkedin/selector-engine.test.d.ts +2 -0
  170. package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
  171. package/dist/linkedin/selector-engine.test.js +135 -0
  172. package/dist/linkedin/selector-engine.test.js.map +1 -0
  173. package/dist/linkedin/selectors.d.ts +65 -0
  174. package/dist/linkedin/selectors.d.ts.map +1 -0
  175. package/dist/linkedin/selectors.js +261 -0
  176. package/dist/linkedin/selectors.js.map +1 -0
  177. package/dist/templates/engine.d.ts +37 -0
  178. package/dist/templates/engine.d.ts.map +1 -0
  179. package/dist/templates/engine.js +215 -0
  180. package/dist/templates/engine.js.map +1 -0
  181. package/dist/templates/engine.test.d.ts +2 -0
  182. package/dist/templates/engine.test.d.ts.map +1 -0
  183. package/dist/templates/engine.test.js +212 -0
  184. package/dist/templates/engine.test.js.map +1 -0
  185. package/dist/templates/index.d.ts +2 -0
  186. package/dist/templates/index.d.ts.map +1 -0
  187. package/dist/templates/index.js +7 -0
  188. package/dist/templates/index.js.map +1 -0
  189. package/dist/types/index.d.ts +113 -0
  190. package/dist/types/index.d.ts.map +1 -0
  191. package/dist/types/index.js +3 -0
  192. package/dist/types/index.js.map +1 -0
  193. package/dist/types/index.test.d.ts +2 -0
  194. package/dist/types/index.test.d.ts.map +1 -0
  195. package/dist/types/index.test.js +90 -0
  196. package/dist/types/index.test.js.map +1 -0
  197. package/dist/utils/paths.d.ts +8 -0
  198. package/dist/utils/paths.d.ts.map +1 -0
  199. package/dist/utils/paths.js +68 -0
  200. package/dist/utils/paths.js.map +1 -0
  201. package/dist/utils/rate-limiter.d.ts +22 -0
  202. package/dist/utils/rate-limiter.d.ts.map +1 -0
  203. package/dist/utils/rate-limiter.js +57 -0
  204. package/dist/utils/rate-limiter.js.map +1 -0
  205. package/dist/utils/retry.d.ts +18 -0
  206. package/dist/utils/retry.d.ts.map +1 -0
  207. package/dist/utils/retry.js +49 -0
  208. package/dist/utils/retry.js.map +1 -0
  209. package/docs/connection-command.md +52 -0
  210. package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
  211. package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
  212. package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
  213. package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
  214. package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
  215. package/docs/plans/2026-02-28-messages-show-design.md +243 -0
  216. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
  217. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
  218. package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
  219. package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
  220. package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
  221. package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
  222. package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
  223. package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
  224. package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
  225. package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
  226. package/eslint.config.mjs +58 -0
  227. package/go.mod +9 -0
  228. package/go.sum +10 -0
  229. package/import-cookies.js +376 -0
  230. package/internal/cmd/actions.go +123 -0
  231. package/internal/cmd/auth.go +108 -0
  232. package/internal/cmd/connect.go +42 -0
  233. package/internal/cmd/message.go +44 -0
  234. package/internal/cmd/people.go +454 -0
  235. package/internal/cmd/profiles.go +121 -0
  236. package/internal/cmd/root.go +89 -0
  237. package/internal/cmd/sequence.go +192 -0
  238. package/internal/config/config.go +187 -0
  239. package/internal/config/config_test.go +121 -0
  240. package/internal/config/profile.go +65 -0
  241. package/internal/linkedin/navigator.go +195 -0
  242. package/internal/linkedin/selectors.go +39 -0
  243. package/internal/linkedin/validator.go +69 -0
  244. package/internal/pinchtab/client.go +183 -0
  245. package/internal/pinchtab/client_test.go +67 -0
  246. package/internal/pinchtab/types.go +50 -0
  247. package/internal/ratelimit/limiter.go +115 -0
  248. package/internal/ratelimit/limits.go +32 -0
  249. package/package.json +67 -0
  250. package/release.sh +66 -0
  251. package/scripts/debug-linkedin.js +156 -0
  252. package/scripts/debug-login.js +193 -0
  253. package/scripts/extract-from-edge.js +96 -0
  254. package/scripts/import-cookies.js +101 -0
  255. package/scripts/poc-show-data.js +205 -0
  256. package/scripts/proof-of-access.js +87 -0
  257. package/scripts/prove-connection.js +110 -0
  258. package/scripts/show-linkedin-data.js +173 -0
  259. package/src/agent/action-executor.test.ts +464 -0
  260. package/src/agent/action-executor.ts +203 -0
  261. package/src/agent/claude-client.test.ts +707 -0
  262. package/src/agent/claude-client.ts +422 -0
  263. package/src/agent/dom-extractor.test.ts +574 -0
  264. package/src/agent/dom-extractor.ts +437 -0
  265. package/src/agent/extension-client.ts +306 -0
  266. package/src/agent/index.ts +28 -0
  267. package/src/agent/page-agent.ts +292 -0
  268. package/src/agent/types.ts +288 -0
  269. package/src/cli/agent-commands.ts +274 -0
  270. package/src/cli/auth.ts +343 -0
  271. package/src/cli/company.ts +66 -0
  272. package/src/cli/connection.ts +89 -0
  273. package/src/cli/index.ts +7 -0
  274. package/src/cli/messages.ts +338 -0
  275. package/src/cli/profile.test.ts +14 -0
  276. package/src/cli/profile.ts +95 -0
  277. package/src/cli/reply.ts +110 -0
  278. package/src/core/audit.test.ts +134 -0
  279. package/src/core/audit.ts +98 -0
  280. package/src/core/browser-cookies.ts +203 -0
  281. package/src/core/browser.ts +304 -0
  282. package/src/core/config.test.ts +90 -0
  283. package/src/core/config.ts +81 -0
  284. package/src/core/storage.test.ts +129 -0
  285. package/src/core/storage.ts +100 -0
  286. package/src/index.ts +70 -0
  287. package/src/linkedin/auth.ts +218 -0
  288. package/src/linkedin/company-extractor.test.ts +58 -0
  289. package/src/linkedin/company-extractor.ts +222 -0
  290. package/src/linkedin/connector.ts +336 -0
  291. package/src/linkedin/message-sender.ts +141 -0
  292. package/src/linkedin/messages.ts +894 -0
  293. package/src/linkedin/profile.test.ts +79 -0
  294. package/src/linkedin/profile.ts +314 -0
  295. package/src/linkedin/reply.ts +96 -0
  296. package/src/linkedin/selector-engine.test.ts +167 -0
  297. package/src/linkedin/selector-engine.ts +393 -0
  298. package/src/linkedin/selectors.ts +268 -0
  299. package/src/templates/defaults/followup.txt +14 -0
  300. package/src/templates/defaults/meeting.txt +16 -0
  301. package/src/templates/defaults/welcome.txt +14 -0
  302. package/src/templates/engine.test.ts +228 -0
  303. package/src/templates/engine.ts +208 -0
  304. package/src/templates/index.ts +1 -0
  305. package/src/types/index.test.ts +94 -0
  306. package/src/types/index.ts +143 -0
  307. package/src/types/sql.js.d.ts +23 -0
  308. package/src/utils/paths.ts +33 -0
  309. package/src/utils/rate-limiter.ts +75 -0
  310. package/src/utils/retry.ts +78 -0
  311. package/test-cli.sh +85 -0
  312. package/test-real-data.sh +97 -0
  313. package/tsconfig.json +23 -0
  314. package/vitest.config.ts +35 -0
@@ -0,0 +1,707 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { ClaudeClient, ClaudeAPIError } from './claude-client';
3
+ import type { DOMRepresentation, ActionContext, ActionPlan } from './types';
4
+
5
+ describe('ClaudeClient', () => {
6
+ let client: ClaudeClient;
7
+ const mockFetch = vi.fn();
8
+
9
+ const validConfig = {
10
+ apiKey: 'test-api-key',
11
+ model: 'qwen3.5-plus',
12
+ baseUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic/v1',
13
+ };
14
+
15
+ const mockDOM: DOMRepresentation = {
16
+ url: 'https://linkedin.com/in/test-user',
17
+ title: 'Test User - LinkedIn',
18
+ elements: [
19
+ {
20
+ id: 'btn-1',
21
+ tag: 'button',
22
+ role: 'button',
23
+ ariaLabel: 'Connect',
24
+ text: 'Connect',
25
+ visible: true,
26
+ enabled: true,
27
+ bbox: { x: 100, y: 200, width: 120, height: 40 },
28
+ },
29
+ ],
30
+ metadata: {
31
+ pageType: 'profile',
32
+ profileName: 'Test User',
33
+ connectionState: 'not_connected',
34
+ },
35
+ };
36
+
37
+ const mockContext: ActionContext = {
38
+ previousActions: [],
39
+ goal: 'Send a connection request to Test User',
40
+ retryCount: 0,
41
+ };
42
+
43
+ const createMockResponse = (actionPlan: {
44
+ reasoning?: string;
45
+ expectedOutcome?: string;
46
+ actions?: Record<string, unknown>[];
47
+ status?: ActionPlan['status'];
48
+ }) => {
49
+ return {
50
+ id: 'msg-123',
51
+ type: 'message',
52
+ role: 'assistant',
53
+ content: [
54
+ {
55
+ type: 'text',
56
+ text: JSON.stringify({
57
+ reasoning: actionPlan.reasoning || 'Test reasoning',
58
+ expectedOutcome: actionPlan.expectedOutcome || 'Test outcome',
59
+ actions: actionPlan.actions || [],
60
+ status: actionPlan.status || 'pending',
61
+ }),
62
+ },
63
+ ],
64
+ model: 'qwen3.5-plus',
65
+ stop_reason: 'end_turn',
66
+ stop_sequence: null,
67
+ usage: {
68
+ input_tokens: 100,
69
+ output_tokens: 50,
70
+ },
71
+ };
72
+ };
73
+
74
+ beforeEach(() => {
75
+ mockFetch.mockClear();
76
+ globalThis.fetch = mockFetch;
77
+ client = new ClaudeClient(validConfig);
78
+ });
79
+
80
+ afterEach(() => {
81
+ vi.restoreAllMocks();
82
+ });
83
+
84
+ describe('constructor', () => {
85
+ it('should create a client with valid config', () => {
86
+ const client = new ClaudeClient(validConfig);
87
+ expect(client).toBeInstanceOf(ClaudeClient);
88
+ });
89
+
90
+ it('should use default baseUrl when not provided', () => {
91
+ const client = new ClaudeClient({
92
+ apiKey: 'test-key',
93
+ model: 'qwen3.5-plus',
94
+ });
95
+ expect(client).toBeInstanceOf(ClaudeClient);
96
+ });
97
+
98
+ it('should use default model when not provided', async () => {
99
+ const client = new ClaudeClient({
100
+ apiKey: 'test-key',
101
+ baseUrl: 'https://custom.api.com',
102
+ model: 'qwen3.5-plus',
103
+ });
104
+
105
+ mockFetch.mockResolvedValueOnce({
106
+ ok: true,
107
+ status: 200,
108
+ statusText: 'OK',
109
+ json: async () =>
110
+ createMockResponse({
111
+ actions: [{ type: 'click', elementId: 'btn-1', description: 'Click' }],
112
+ }),
113
+ });
114
+
115
+ await client.generateActionPlan(mockDOM, mockContext);
116
+
117
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
118
+ expect(body.model).toBe('qwen3.5-plus');
119
+ });
120
+ });
121
+
122
+ describe('generateActionPlan', () => {
123
+ it('should send correct API request', async () => {
124
+ const mockResponse = createMockResponse({
125
+ actions: [{ type: 'click', elementId: 'btn-1', description: 'Click connect' }],
126
+ });
127
+
128
+ mockFetch.mockResolvedValueOnce({
129
+ ok: true,
130
+ status: 200,
131
+ statusText: 'OK',
132
+ json: async () => mockResponse,
133
+ } as unknown as globalThis.Response);
134
+
135
+ await client.generateActionPlan(mockDOM, mockContext);
136
+
137
+ expect(mockFetch).toHaveBeenCalledTimes(1);
138
+ const [url, options] = mockFetch.mock.calls[0];
139
+
140
+ expect(url).toBe(`${validConfig.baseUrl}/messages`);
141
+ expect(options.method).toBe('POST');
142
+ expect(options.headers).toEqual({
143
+ 'Content-Type': 'application/json',
144
+ Authorization: 'Bearer test-api-key',
145
+ });
146
+
147
+ const body = JSON.parse(options.body as string);
148
+ expect(body.model).toBe('qwen3.5-plus');
149
+ expect(body.max_tokens).toBe(4096);
150
+ expect(body.messages).toHaveLength(2);
151
+ expect(body.messages[0].role).toBe('system');
152
+ expect(body.messages[1].role).toBe('user');
153
+ });
154
+
155
+ it('should parse successful response into ActionPlan', async () => {
156
+ const mockResponse = createMockResponse({
157
+ reasoning: 'Need to click connect button',
158
+ expectedOutcome: 'Connection request modal opens',
159
+ actions: [{ type: 'click', elementId: 'btn-1', description: 'Click connect button' }],
160
+ status: 'pending',
161
+ });
162
+
163
+ mockFetch.mockResolvedValueOnce({
164
+ ok: true,
165
+ status: 200,
166
+ statusText: 'OK',
167
+ json: async () => mockResponse,
168
+ } as unknown as globalThis.Response);
169
+
170
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
171
+
172
+ expect(plan.reasoning).toBe('Need to click connect button');
173
+ expect(plan.expectedOutcome).toBe('Connection request modal opens');
174
+ expect(plan.actions).toHaveLength(1);
175
+ expect(plan.actions[0].type).toBe('click');
176
+ expect(plan.status).toBe('pending');
177
+ });
178
+
179
+ it('should include goal and DOM in user message', async () => {
180
+ const mockResponse = createMockResponse({});
181
+
182
+ mockFetch.mockResolvedValueOnce({
183
+ ok: true,
184
+ status: 200,
185
+ statusText: 'OK',
186
+ json: async () => mockResponse,
187
+ } as unknown as globalThis.Response);
188
+
189
+ await client.generateActionPlan(mockDOM, mockContext);
190
+
191
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
192
+ const userMessage = body.messages[1].content;
193
+
194
+ expect(userMessage).toContain(`Goal: ${mockContext.goal}`);
195
+ expect(userMessage).toContain('Current page DOM:');
196
+ expect(userMessage).toContain(JSON.stringify(mockDOM, null, 2));
197
+ });
198
+
199
+ it('should include previous actions in user message', async () => {
200
+ const contextWithHistory: ActionContext = {
201
+ ...mockContext,
202
+ previousActions: [
203
+ {
204
+ action: {
205
+ type: 'navigate',
206
+ url: 'https://linkedin.com/in/test-user',
207
+ description: 'Navigate to profile',
208
+ },
209
+ success: true,
210
+ timestamp: new Date(),
211
+ },
212
+ {
213
+ action: {
214
+ type: 'click',
215
+ elementId: 'btn-1',
216
+ description: 'Click connect',
217
+ },
218
+ success: false,
219
+ timestamp: new Date(),
220
+ error: 'Element not found',
221
+ },
222
+ ],
223
+ };
224
+
225
+ const mockResponse = createMockResponse({});
226
+
227
+ mockFetch.mockResolvedValueOnce({
228
+ ok: true,
229
+ status: 200,
230
+ statusText: 'OK',
231
+ json: async () => mockResponse,
232
+ } as unknown as globalThis.Response);
233
+
234
+ await client.generateActionPlan(mockDOM, contextWithHistory);
235
+
236
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
237
+ const userMessage = body.messages[1].content;
238
+
239
+ expect(userMessage).toContain('Previous actions taken:');
240
+ expect(userMessage).toContain('navigate');
241
+ expect(userMessage).toContain('click');
242
+ expect(userMessage).toContain('Element not found');
243
+ });
244
+
245
+ it('should handle multiple actions in response', async () => {
246
+ const mockResponse = createMockResponse({
247
+ actions: [
248
+ { type: 'click', elementId: 'btn-1', description: 'Click connect' },
249
+ { type: 'wait', durationMs: 1000, description: 'Wait for modal' },
250
+ { type: 'type', elementId: 'input-1', text: 'Hello', description: 'Type note' },
251
+ ],
252
+ });
253
+
254
+ mockFetch.mockResolvedValueOnce({
255
+ ok: true,
256
+ status: 200,
257
+ statusText: 'OK',
258
+ json: async () => mockResponse,
259
+ } as unknown as globalThis.Response);
260
+
261
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
262
+
263
+ expect(plan.actions).toHaveLength(3);
264
+ expect(plan.actions[0].type).toBe('click');
265
+ expect(plan.actions[1].type).toBe('wait');
266
+ expect(plan.actions[2].type).toBe('type');
267
+ });
268
+ });
269
+
270
+ describe('error handling', () => {
271
+ it('should throw ClaudeAPIError on network failure', async () => {
272
+ mockFetch.mockRejectedValue(new Error('Network error'));
273
+
274
+ await expect(client.generateActionPlan(mockDOM, mockContext)).rejects.toThrow(ClaudeAPIError);
275
+ await expect(client.generateActionPlan(mockDOM, mockContext)).rejects.toThrow(
276
+ 'Failed to connect to Claude API'
277
+ );
278
+ });
279
+
280
+ it('should throw ClaudeAPIError on HTTP error status', async () => {
281
+ mockFetch.mockResolvedValue({
282
+ ok: false,
283
+ status: 401,
284
+ statusText: 'Unauthorized',
285
+ text: async () => JSON.stringify({ error: 'Invalid API key' }),
286
+ });
287
+
288
+ await expect(client.generateActionPlan(mockDOM, mockContext)).rejects.toThrow(ClaudeAPIError);
289
+ await expect(client.generateActionPlan(mockDOM, mockContext)).rejects.toThrow('401');
290
+ });
291
+
292
+ it('should return error plan when response has no text content', async () => {
293
+ mockFetch.mockResolvedValueOnce({
294
+ ok: true,
295
+ status: 200,
296
+ statusText: 'OK',
297
+ json: async () => ({
298
+ id: 'msg-123',
299
+ type: 'message',
300
+ role: 'assistant',
301
+ content: [
302
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '' } },
303
+ ],
304
+ model: 'qwen3.5-plus',
305
+ stop_reason: 'end_turn',
306
+ stop_sequence: null,
307
+ usage: { input_tokens: 100, output_tokens: 50 },
308
+ }),
309
+ });
310
+
311
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
312
+
313
+ expect(plan.status).toBe('failed');
314
+ expect(plan.error).toContain('No text content');
315
+ });
316
+
317
+ it('should return error plan when JSON cannot be extracted', async () => {
318
+ mockFetch.mockResolvedValueOnce({
319
+ ok: true,
320
+ status: 200,
321
+ statusText: 'OK',
322
+ json: async () => ({
323
+ id: 'msg-123',
324
+ type: 'message',
325
+ role: 'assistant',
326
+ content: [{ type: 'text', text: 'Plain text without JSON' }],
327
+ model: 'qwen3.5-plus',
328
+ stop_reason: 'end_turn',
329
+ stop_sequence: null,
330
+ usage: { input_tokens: 100, output_tokens: 50 },
331
+ }),
332
+ });
333
+
334
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
335
+
336
+ expect(plan.status).toBe('failed');
337
+ expect(plan.error).toContain('Could not extract JSON');
338
+ });
339
+
340
+ it('should return error plan when JSON is malformed', async () => {
341
+ mockFetch.mockResolvedValueOnce({
342
+ ok: true,
343
+ status: 200,
344
+ statusText: 'OK',
345
+ json: async () => ({
346
+ id: 'msg-123',
347
+ type: 'message',
348
+ role: 'assistant',
349
+ content: [{ type: 'text', text: '{"invalid json' }],
350
+ model: 'qwen3.5-plus',
351
+ stop_reason: 'end_turn',
352
+ stop_sequence: null,
353
+ usage: { input_tokens: 100, output_tokens: 50 },
354
+ }),
355
+ });
356
+
357
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
358
+
359
+ expect(plan.status).toBe('failed');
360
+ expect(plan.error).toContain('Failed to parse');
361
+ });
362
+
363
+ it('should return error plan when response JSON is invalid', async () => {
364
+ mockFetch.mockResolvedValueOnce({
365
+ ok: true,
366
+ status: 200,
367
+ statusText: 'OK',
368
+ json: async () => ({
369
+ id: 'msg-123',
370
+ type: 'message',
371
+ role: 'assistant',
372
+ content: [{ type: 'text', text: '{"not_a_valid_plan": true}' }],
373
+ model: 'qwen3.5-plus',
374
+ stop_reason: 'end_turn',
375
+ stop_sequence: null,
376
+ usage: { input_tokens: 100, output_tokens: 50 },
377
+ }),
378
+ } as unknown as globalThis.Response);
379
+
380
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
381
+
382
+ expect(plan.status).toBe('pending');
383
+ expect(plan.actions).toEqual([]);
384
+ expect(plan.reasoning).toBe('No reasoning provided');
385
+ });
386
+ });
387
+
388
+ describe('JSON extraction', () => {
389
+ it('should extract JSON from markdown code blocks', async () => {
390
+ const actionPlan = {
391
+ reasoning: 'Test',
392
+ expectedOutcome: 'Test outcome',
393
+ actions: [{ type: 'click', elementId: 'btn-1', description: 'Click' }],
394
+ };
395
+
396
+ mockFetch.mockResolvedValueOnce({
397
+ ok: true,
398
+ status: 200,
399
+ statusText: 'OK',
400
+ json: async () => ({
401
+ id: 'msg-123',
402
+ type: 'message',
403
+ role: 'assistant',
404
+ content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(actionPlan)}\n\`\`\`` }],
405
+ model: 'qwen3.5-plus',
406
+ stop_reason: 'end_turn',
407
+ stop_sequence: null,
408
+ usage: { input_tokens: 100, output_tokens: 50 },
409
+ }),
410
+ });
411
+
412
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
413
+
414
+ expect(plan.reasoning).toBe('Test');
415
+ expect(plan.actions).toHaveLength(1);
416
+ });
417
+
418
+ it('should extract JSON without language specifier', async () => {
419
+ const actionPlan = {
420
+ reasoning: 'Test',
421
+ expectedOutcome: 'Test outcome',
422
+ actions: [{ type: 'click', elementId: 'btn-1', description: 'Click' }],
423
+ };
424
+
425
+ mockFetch.mockResolvedValueOnce({
426
+ ok: true,
427
+ status: 200,
428
+ statusText: 'OK',
429
+ json: async () => ({
430
+ id: 'msg-123',
431
+ type: 'message',
432
+ role: 'assistant',
433
+ content: [{ type: 'text', text: `\`\`\`\n${JSON.stringify(actionPlan)}\n\`\`\`` }],
434
+ model: 'qwen3.5-plus',
435
+ stop_reason: 'end_turn',
436
+ stop_sequence: null,
437
+ usage: { input_tokens: 100, output_tokens: 50 },
438
+ }),
439
+ });
440
+
441
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
442
+
443
+ expect(plan.reasoning).toBe('Test');
444
+ });
445
+
446
+ it('should extract JSON from raw text starting with {', async () => {
447
+ const actionPlan = {
448
+ reasoning: 'Test',
449
+ expectedOutcome: 'Test outcome',
450
+ actions: [],
451
+ };
452
+
453
+ mockFetch.mockResolvedValueOnce({
454
+ ok: true,
455
+ status: 200,
456
+ statusText: 'OK',
457
+ json: async () => ({
458
+ id: 'msg-123',
459
+ type: 'message',
460
+ role: 'assistant',
461
+ content: [{ type: 'text', text: JSON.stringify(actionPlan) }],
462
+ model: 'qwen3.5-plus',
463
+ stop_reason: 'end_turn',
464
+ stop_sequence: null,
465
+ usage: { input_tokens: 100, output_tokens: 50 },
466
+ }),
467
+ });
468
+
469
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
470
+
471
+ expect(plan.reasoning).toBe('Test');
472
+ });
473
+ });
474
+
475
+ describe('action transformation', () => {
476
+ it('should transform click actions correctly', async () => {
477
+ const mockResponse = createMockResponse({
478
+ actions: [{ type: 'click', elementId: 'btn-1', description: 'Click button' }],
479
+ });
480
+
481
+ mockFetch.mockResolvedValueOnce({
482
+ ok: true,
483
+ status: 200,
484
+ statusText: 'OK',
485
+ json: async () => mockResponse,
486
+ } as unknown as globalThis.Response);
487
+
488
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
489
+
490
+ expect(plan.actions[0]).toEqual({
491
+ type: 'click',
492
+ elementId: 'btn-1',
493
+ description: 'Click button',
494
+ });
495
+ });
496
+
497
+ it('should transform type actions correctly', async () => {
498
+ const mockResponse = createMockResponse({
499
+ actions: [
500
+ { type: 'type', elementId: 'input-1', text: 'Hello', description: 'Type greeting' },
501
+ ],
502
+ });
503
+
504
+ mockFetch.mockResolvedValueOnce({
505
+ ok: true,
506
+ status: 200,
507
+ statusText: 'OK',
508
+ json: async () => mockResponse,
509
+ } as unknown as globalThis.Response);
510
+
511
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
512
+
513
+ expect(plan.actions[0]).toEqual({
514
+ type: 'type',
515
+ elementId: 'input-1',
516
+ text: 'Hello',
517
+ description: 'Type greeting',
518
+ });
519
+ });
520
+
521
+ it('should transform wait actions correctly', async () => {
522
+ const mockResponse = createMockResponse({
523
+ actions: [{ type: 'wait', durationMs: 2000, description: 'Wait for load' }],
524
+ });
525
+
526
+ mockFetch.mockResolvedValueOnce({
527
+ ok: true,
528
+ status: 200,
529
+ statusText: 'OK',
530
+ json: async () => mockResponse,
531
+ } as unknown as globalThis.Response);
532
+
533
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
534
+
535
+ expect(plan.actions[0]).toEqual({
536
+ type: 'wait',
537
+ durationMs: 2000,
538
+ description: 'Wait for load',
539
+ });
540
+ });
541
+
542
+ it('should transform navigate actions correctly', async () => {
543
+ const mockResponse = createMockResponse({
544
+ actions: [{ type: 'navigate', url: 'https://linkedin.com', description: 'Go to home' }],
545
+ });
546
+
547
+ mockFetch.mockResolvedValueOnce({
548
+ ok: true,
549
+ status: 200,
550
+ statusText: 'OK',
551
+ json: async () => mockResponse,
552
+ } as unknown as globalThis.Response);
553
+
554
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
555
+
556
+ expect(plan.actions[0]).toEqual({
557
+ type: 'navigate',
558
+ url: 'https://linkedin.com',
559
+ description: 'Go to home',
560
+ });
561
+ });
562
+
563
+ it('should use default duration for wait actions without durationMs', async () => {
564
+ const mockResponse = createMockResponse({
565
+ actions: [{ type: 'wait', description: 'Wait' }],
566
+ });
567
+
568
+ mockFetch.mockResolvedValueOnce({
569
+ ok: true,
570
+ status: 200,
571
+ statusText: 'OK',
572
+ json: async () => mockResponse,
573
+ } as unknown as globalThis.Response);
574
+
575
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
576
+
577
+ expect(plan.actions[0]).toEqual({
578
+ type: 'wait',
579
+ durationMs: 1000,
580
+ description: 'Wait',
581
+ });
582
+ });
583
+
584
+ it('should skip actions with missing required fields', async () => {
585
+ const mockResponse = createMockResponse({
586
+ actions: [
587
+ { type: 'click', description: 'Missing elementId' },
588
+ { type: 'type', elementId: 'input-1', description: 'Missing text' },
589
+ { type: 'navigate', description: 'Missing url' },
590
+ { type: 'click', elementId: 'btn-1', description: 'Valid click' },
591
+ ],
592
+ });
593
+
594
+ mockFetch.mockResolvedValueOnce({
595
+ ok: true,
596
+ status: 200,
597
+ statusText: 'OK',
598
+ json: async () => mockResponse,
599
+ } as unknown as globalThis.Response);
600
+
601
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
602
+
603
+ expect(plan.actions).toHaveLength(1);
604
+ expect(plan.actions[0].type).toBe('click');
605
+ expect((plan.actions[0] as { elementId: string }).elementId).toBe('btn-1');
606
+ });
607
+
608
+ it('should skip unknown action types', async () => {
609
+ const mockResponse = createMockResponse({
610
+ actions: [
611
+ { type: 'unknown_action', description: 'Unknown' },
612
+ { type: 'click', elementId: 'btn-1', description: 'Valid click' },
613
+ ],
614
+ });
615
+
616
+ mockFetch.mockResolvedValueOnce({
617
+ ok: true,
618
+ status: 200,
619
+ statusText: 'OK',
620
+ json: async () => mockResponse,
621
+ } as unknown as globalThis.Response);
622
+
623
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
624
+
625
+ expect(plan.actions).toHaveLength(1);
626
+ expect(plan.actions[0].type).toBe('click');
627
+ });
628
+ });
629
+
630
+ describe('status validation', () => {
631
+ it('should accept valid status values', async () => {
632
+ const statuses = ['pending', 'in_progress', 'completed', 'failed', 'cancelled'];
633
+
634
+ for (const status of statuses) {
635
+ mockFetch.mockResolvedValueOnce({
636
+ ok: true,
637
+ status: 200,
638
+ statusText: 'OK',
639
+ json: async () =>
640
+ createMockResponse({
641
+ status: status as ActionPlan['status'],
642
+ }),
643
+ });
644
+
645
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
646
+ expect(plan.status).toBe(status);
647
+ }
648
+ });
649
+
650
+ it('should default to pending for invalid status values', async () => {
651
+ mockFetch.mockResolvedValueOnce({
652
+ ok: true,
653
+ status: 200,
654
+ statusText: 'OK',
655
+ json: async () =>
656
+ createMockResponse({
657
+ status: 'invalid_status' as ActionPlan['status'],
658
+ }),
659
+ });
660
+
661
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
662
+
663
+ expect(plan.status).toBe('pending');
664
+ });
665
+
666
+ it('should default to pending when status is undefined', async () => {
667
+ mockFetch.mockResolvedValueOnce({
668
+ ok: true,
669
+ status: 200,
670
+ statusText: 'OK',
671
+ json: async () =>
672
+ createMockResponse({
673
+ status: undefined,
674
+ }),
675
+ });
676
+
677
+ const plan = await client.generateActionPlan(mockDOM, mockContext);
678
+
679
+ expect(plan.status).toBe('pending');
680
+ });
681
+ });
682
+
683
+ describe('system prompt', () => {
684
+ it('should include system prompt explaining agent role', async () => {
685
+ const mockResponse = createMockResponse({});
686
+
687
+ mockFetch.mockResolvedValueOnce({
688
+ ok: true,
689
+ status: 200,
690
+ statusText: 'OK',
691
+ json: async () => mockResponse,
692
+ } as unknown as globalThis.Response);
693
+
694
+ await client.generateActionPlan(mockDOM, mockContext);
695
+
696
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string);
697
+ const systemMessage = body.messages[0].content;
698
+
699
+ expect(systemMessage).toContain('web automation agent');
700
+ expect(systemMessage).toContain('JSON');
701
+ expect(systemMessage).toContain('click');
702
+ expect(systemMessage).toContain('type');
703
+ expect(systemMessage).toContain('wait');
704
+ expect(systemMessage).toContain('navigate');
705
+ });
706
+ });
707
+ });