@monoes/monobrowse 1.0.5 → 1.0.7

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 (250) hide show
  1. package/README.md +68 -0
  2. package/dist/src/__tests__/adapters.test.d.ts +2 -0
  3. package/dist/src/__tests__/adapters.test.d.ts.map +1 -0
  4. package/dist/src/__tests__/adapters.test.js +104 -0
  5. package/dist/src/__tests__/adapters.test.js.map +1 -0
  6. package/dist/src/__tests__/batch-eval-parsing.test.d.ts +0 -0
  7. package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +0 -0
  8. package/dist/src/__tests__/batch-eval-parsing.test.js +0 -0
  9. package/dist/src/__tests__/batch-eval-parsing.test.js.map +0 -0
  10. package/dist/src/__tests__/box-model.test.d.ts +2 -0
  11. package/dist/src/__tests__/box-model.test.d.ts.map +1 -0
  12. package/dist/src/__tests__/box-model.test.js +36 -0
  13. package/dist/src/__tests__/box-model.test.js.map +1 -0
  14. package/dist/src/__tests__/browser-launch.test.d.ts +0 -0
  15. package/dist/src/__tests__/browser-launch.test.d.ts.map +0 -0
  16. package/dist/src/__tests__/browser-launch.test.js +0 -0
  17. package/dist/src/__tests__/browser-launch.test.js.map +0 -0
  18. package/dist/src/__tests__/cdp-client.test.d.ts +2 -0
  19. package/dist/src/__tests__/cdp-client.test.d.ts.map +1 -0
  20. package/dist/src/__tests__/cdp-client.test.js +326 -0
  21. package/dist/src/__tests__/cdp-client.test.js.map +1 -0
  22. package/dist/src/__tests__/close-browser.test.d.ts +2 -0
  23. package/dist/src/__tests__/close-browser.test.d.ts.map +1 -0
  24. package/dist/src/__tests__/close-browser.test.js +128 -0
  25. package/dist/src/__tests__/close-browser.test.js.map +1 -0
  26. package/dist/src/__tests__/console-log.test.d.ts +2 -0
  27. package/dist/src/__tests__/console-log.test.d.ts.map +1 -0
  28. package/dist/src/__tests__/console-log.test.js +195 -0
  29. package/dist/src/__tests__/console-log.test.js.map +1 -0
  30. package/dist/src/__tests__/find.test.d.ts +2 -0
  31. package/dist/src/__tests__/find.test.d.ts.map +1 -0
  32. package/dist/src/__tests__/find.test.js +299 -0
  33. package/dist/src/__tests__/find.test.js.map +1 -0
  34. package/dist/src/__tests__/frame-command.test.d.ts +2 -0
  35. package/dist/src/__tests__/frame-command.test.d.ts.map +1 -0
  36. package/dist/src/__tests__/frame-command.test.js +85 -0
  37. package/dist/src/__tests__/frame-command.test.js.map +1 -0
  38. package/dist/src/__tests__/output.test.d.ts +0 -0
  39. package/dist/src/__tests__/output.test.d.ts.map +0 -0
  40. package/dist/src/__tests__/output.test.js +0 -0
  41. package/dist/src/__tests__/output.test.js.map +0 -0
  42. package/dist/src/__tests__/ref-cache.test.d.ts +0 -0
  43. package/dist/src/__tests__/ref-cache.test.d.ts.map +0 -0
  44. package/dist/src/__tests__/ref-cache.test.js +43 -2
  45. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  46. package/dist/src/__tests__/session-state.test.d.ts +2 -0
  47. package/dist/src/__tests__/session-state.test.d.ts.map +1 -0
  48. package/dist/src/__tests__/session-state.test.js +203 -0
  49. package/dist/src/__tests__/session-state.test.js.map +1 -0
  50. package/dist/src/__tests__/snapshot.test.d.ts +2 -0
  51. package/dist/src/__tests__/snapshot.test.d.ts.map +1 -0
  52. package/dist/src/__tests__/snapshot.test.js +262 -0
  53. package/dist/src/__tests__/snapshot.test.js.map +1 -0
  54. package/dist/src/__tests__/storage-eval.test.d.ts +2 -0
  55. package/dist/src/__tests__/storage-eval.test.d.ts.map +1 -0
  56. package/dist/src/__tests__/storage-eval.test.js +133 -0
  57. package/dist/src/__tests__/storage-eval.test.js.map +1 -0
  58. package/dist/src/__tests__/tabs.test.d.ts +2 -0
  59. package/dist/src/__tests__/tabs.test.d.ts.map +1 -0
  60. package/dist/src/__tests__/tabs.test.js +161 -0
  61. package/dist/src/__tests__/tabs.test.js.map +1 -0
  62. package/dist/src/browser/action-builder/analyzer.d.ts +0 -0
  63. package/dist/src/browser/action-builder/analyzer.d.ts.map +0 -0
  64. package/dist/src/browser/action-builder/analyzer.js +0 -0
  65. package/dist/src/browser/action-builder/analyzer.js.map +0 -0
  66. package/dist/src/browser/action-builder/types.d.ts +0 -0
  67. package/dist/src/browser/action-builder/types.d.ts.map +0 -0
  68. package/dist/src/browser/action-builder/types.js +0 -0
  69. package/dist/src/browser/action-builder/types.js.map +0 -0
  70. package/dist/src/browser/actions.d.ts +0 -0
  71. package/dist/src/browser/actions.d.ts.map +0 -0
  72. package/dist/src/browser/actions.js +0 -0
  73. package/dist/src/browser/actions.js.map +0 -0
  74. package/dist/src/browser/adapters/gemini.d.ts +0 -0
  75. package/dist/src/browser/adapters/gemini.d.ts.map +0 -0
  76. package/dist/src/browser/adapters/gemini.js +0 -0
  77. package/dist/src/browser/adapters/gemini.js.map +0 -0
  78. package/dist/src/browser/adapters/google.d.ts +0 -0
  79. package/dist/src/browser/adapters/google.d.ts.map +0 -0
  80. package/dist/src/browser/adapters/google.js +0 -0
  81. package/dist/src/browser/adapters/google.js.map +0 -0
  82. package/dist/src/browser/adapters/index.d.ts +0 -0
  83. package/dist/src/browser/adapters/index.d.ts.map +0 -0
  84. package/dist/src/browser/adapters/index.js +0 -0
  85. package/dist/src/browser/adapters/index.js.map +0 -0
  86. package/dist/src/browser/adapters/instagram.d.ts +0 -0
  87. package/dist/src/browser/adapters/instagram.d.ts.map +0 -0
  88. package/dist/src/browser/adapters/instagram.js +0 -0
  89. package/dist/src/browser/adapters/instagram.js.map +0 -0
  90. package/dist/src/browser/adapters/linkedin.d.ts +0 -0
  91. package/dist/src/browser/adapters/linkedin.d.ts.map +0 -0
  92. package/dist/src/browser/adapters/linkedin.js +0 -0
  93. package/dist/src/browser/adapters/linkedin.js.map +0 -0
  94. package/dist/src/browser/adapters/microsoft.d.ts +0 -0
  95. package/dist/src/browser/adapters/microsoft.d.ts.map +0 -0
  96. package/dist/src/browser/adapters/microsoft.js +0 -0
  97. package/dist/src/browser/adapters/microsoft.js.map +0 -0
  98. package/dist/src/browser/adapters/x.d.ts +0 -0
  99. package/dist/src/browser/adapters/x.d.ts.map +0 -0
  100. package/dist/src/browser/adapters/x.js +0 -0
  101. package/dist/src/browser/adapters/x.js.map +0 -0
  102. package/dist/src/browser/batch.d.ts +0 -0
  103. package/dist/src/browser/batch.d.ts.map +0 -0
  104. package/dist/src/browser/batch.js +0 -0
  105. package/dist/src/browser/batch.js.map +0 -0
  106. package/dist/src/browser/browser.d.ts +4 -0
  107. package/dist/src/browser/browser.d.ts.map +1 -1
  108. package/dist/src/browser/browser.js +55 -7
  109. package/dist/src/browser/browser.js.map +1 -1
  110. package/dist/src/browser/cdp.d.ts +4 -1
  111. package/dist/src/browser/cdp.d.ts.map +1 -1
  112. package/dist/src/browser/cdp.js +45 -16
  113. package/dist/src/browser/cdp.js.map +1 -1
  114. package/dist/src/browser/console-log.d.ts +0 -0
  115. package/dist/src/browser/console-log.d.ts.map +0 -0
  116. package/dist/src/browser/console-log.js +0 -0
  117. package/dist/src/browser/console-log.js.map +0 -0
  118. package/dist/src/browser/dashboard/api-types.d.ts +0 -0
  119. package/dist/src/browser/dashboard/api-types.d.ts.map +0 -0
  120. package/dist/src/browser/dashboard/api-types.js +0 -0
  121. package/dist/src/browser/dashboard/api-types.js.map +0 -0
  122. package/dist/src/browser/dashboard/server.d.ts +0 -0
  123. package/dist/src/browser/dashboard/server.d.ts.map +0 -0
  124. package/dist/src/browser/dashboard/server.js +0 -0
  125. package/dist/src/browser/dashboard/server.js.map +0 -0
  126. package/dist/src/browser/dialog.d.ts +0 -0
  127. package/dist/src/browser/dialog.d.ts.map +0 -0
  128. package/dist/src/browser/dialog.js +0 -0
  129. package/dist/src/browser/dialog.js.map +0 -0
  130. package/dist/src/browser/emulation.d.ts +0 -0
  131. package/dist/src/browser/emulation.d.ts.map +0 -0
  132. package/dist/src/browser/emulation.js +0 -0
  133. package/dist/src/browser/emulation.js.map +0 -0
  134. package/dist/src/browser/find.d.ts +0 -0
  135. package/dist/src/browser/find.d.ts.map +0 -0
  136. package/dist/src/browser/find.js +0 -0
  137. package/dist/src/browser/find.js.map +0 -0
  138. package/dist/src/browser/har.d.ts +0 -0
  139. package/dist/src/browser/har.d.ts.map +0 -0
  140. package/dist/src/browser/har.js +0 -0
  141. package/dist/src/browser/har.js.map +0 -0
  142. package/dist/src/browser/index.d.ts +0 -0
  143. package/dist/src/browser/index.d.ts.map +0 -0
  144. package/dist/src/browser/index.js +0 -0
  145. package/dist/src/browser/index.js.map +0 -0
  146. package/dist/src/browser/network.d.ts +0 -0
  147. package/dist/src/browser/network.d.ts.map +0 -0
  148. package/dist/src/browser/network.js +0 -0
  149. package/dist/src/browser/network.js.map +0 -0
  150. package/dist/src/browser/pdf.d.ts +0 -0
  151. package/dist/src/browser/pdf.d.ts.map +0 -0
  152. package/dist/src/browser/pdf.js +0 -0
  153. package/dist/src/browser/pdf.js.map +0 -0
  154. package/dist/src/browser/profiler.d.ts +0 -0
  155. package/dist/src/browser/profiler.d.ts.map +0 -0
  156. package/dist/src/browser/profiler.js +0 -0
  157. package/dist/src/browser/profiler.js.map +0 -0
  158. package/dist/src/browser/record.d.ts +0 -0
  159. package/dist/src/browser/record.d.ts.map +0 -0
  160. package/dist/src/browser/record.js +0 -0
  161. package/dist/src/browser/record.js.map +0 -0
  162. package/dist/src/browser/ref-cache.d.ts +6 -1
  163. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  164. package/dist/src/browser/ref-cache.js +21 -3
  165. package/dist/src/browser/ref-cache.js.map +1 -1
  166. package/dist/src/browser/screenshot.d.ts +0 -0
  167. package/dist/src/browser/screenshot.d.ts.map +0 -0
  168. package/dist/src/browser/screenshot.js +0 -0
  169. package/dist/src/browser/screenshot.js.map +0 -0
  170. package/dist/src/browser/session.d.ts +0 -0
  171. package/dist/src/browser/session.d.ts.map +0 -0
  172. package/dist/src/browser/session.js +0 -0
  173. package/dist/src/browser/session.js.map +0 -0
  174. package/dist/src/browser/snapshot.d.ts +11 -0
  175. package/dist/src/browser/snapshot.d.ts.map +1 -1
  176. package/dist/src/browser/snapshot.js +11 -0
  177. package/dist/src/browser/snapshot.js.map +1 -1
  178. package/dist/src/browser/storage.d.ts +0 -0
  179. package/dist/src/browser/storage.d.ts.map +0 -0
  180. package/dist/src/browser/storage.js +0 -0
  181. package/dist/src/browser/storage.js.map +0 -0
  182. package/dist/src/browser/tabs.d.ts +5 -1
  183. package/dist/src/browser/tabs.d.ts.map +1 -1
  184. package/dist/src/browser/tabs.js +4 -3
  185. package/dist/src/browser/tabs.js.map +1 -1
  186. package/dist/src/browser/trace.d.ts +0 -0
  187. package/dist/src/browser/trace.d.ts.map +0 -0
  188. package/dist/src/browser/trace.js +0 -0
  189. package/dist/src/browser/trace.js.map +0 -0
  190. package/dist/src/browser/types.d.ts +4 -0
  191. package/dist/src/browser/types.d.ts.map +1 -1
  192. package/dist/src/browser/types.js +0 -0
  193. package/dist/src/browser/types.js.map +1 -1
  194. package/dist/src/browser/vitals.d.ts +0 -0
  195. package/dist/src/browser/vitals.d.ts.map +0 -0
  196. package/dist/src/browser/vitals.js +0 -0
  197. package/dist/src/browser/vitals.js.map +0 -0
  198. package/dist/src/browser/wait.d.ts +0 -0
  199. package/dist/src/browser/wait.d.ts.map +0 -0
  200. package/dist/src/browser/wait.js +0 -0
  201. package/dist/src/browser/wait.js.map +0 -0
  202. package/dist/src/cli/action.d.ts +0 -0
  203. package/dist/src/cli/action.d.ts.map +0 -0
  204. package/dist/src/cli/action.js +0 -0
  205. package/dist/src/cli/action.js.map +0 -0
  206. package/dist/src/cli/commands.d.ts +21 -0
  207. package/dist/src/cli/commands.d.ts.map +1 -1
  208. package/dist/src/cli/commands.js +123 -7
  209. package/dist/src/cli/commands.js.map +1 -1
  210. package/dist/src/cli/output.d.ts +0 -0
  211. package/dist/src/cli/output.d.ts.map +0 -0
  212. package/dist/src/cli/output.js +0 -0
  213. package/dist/src/cli/output.js.map +0 -0
  214. package/dist/src/cli/platform.d.ts +0 -0
  215. package/dist/src/cli/platform.d.ts.map +0 -0
  216. package/dist/src/cli/platform.js +0 -0
  217. package/dist/src/cli/platform.js.map +0 -0
  218. package/dist/src/cli/types.d.ts +0 -0
  219. package/dist/src/cli/types.d.ts.map +0 -0
  220. package/dist/src/cli/types.js +0 -0
  221. package/dist/src/cli/types.js.map +0 -0
  222. package/dist/src/cli.d.ts +0 -0
  223. package/dist/src/cli.d.ts.map +0 -0
  224. package/dist/src/cli.js +0 -0
  225. package/dist/src/cli.js.map +0 -0
  226. package/dist/src/index.d.ts +0 -0
  227. package/dist/src/index.d.ts.map +0 -0
  228. package/dist/src/index.js +0 -0
  229. package/dist/src/index.js.map +0 -0
  230. package/dist/tsconfig.tsbuildinfo +1 -1
  231. package/package.json +9 -5
  232. package/src/__tests__/adapters.test.ts +125 -0
  233. package/src/__tests__/box-model.test.ts +39 -0
  234. package/src/__tests__/cdp-client.test.ts +375 -0
  235. package/src/__tests__/close-browser.test.ts +141 -0
  236. package/src/__tests__/console-log.test.ts +250 -0
  237. package/src/__tests__/find.test.ts +354 -0
  238. package/src/__tests__/frame-command.test.ts +98 -0
  239. package/src/__tests__/ref-cache.test.ts +47 -2
  240. package/src/__tests__/session-state.test.ts +251 -0
  241. package/src/__tests__/snapshot.test.ts +331 -0
  242. package/src/__tests__/storage-eval.test.ts +171 -0
  243. package/src/__tests__/tabs.test.ts +183 -0
  244. package/src/browser/browser.ts +59 -7
  245. package/src/browser/cdp.ts +52 -15
  246. package/src/browser/ref-cache.ts +29 -6
  247. package/src/browser/snapshot.ts +11 -0
  248. package/src/browser/tabs.ts +12 -4
  249. package/src/browser/types.ts +4 -0
  250. package/src/cli/commands.ts +119 -7
@@ -0,0 +1,171 @@
1
+ /**
2
+ * storage.ts builds localStorage/sessionStorage expressions by string
3
+ * concatenation, so the JSON.stringify escaping is the only thing standing
4
+ * between a user-supplied key and script injection into the page. That plus
5
+ * evaluateJs's error/timeout contract is what this file pins down.
6
+ * Fake client throughout — no browser.
7
+ */
8
+ import { describe, it, expect, vi } from 'vitest';
9
+ import type { CdpClient } from '../browser/cdp.js';
10
+ import { evaluateJs } from '../browser/actions.js';
11
+ import {
12
+ getLocalStorageKey,
13
+ setLocalStorageKey,
14
+ removeLocalStorageKey,
15
+ clearLocalStorage,
16
+ getAllLocalStorage,
17
+ getSessionStorageKey,
18
+ setSessionStorageKey,
19
+ removeSessionStorageKey,
20
+ clearSessionStorage,
21
+ getAllSessionStorage,
22
+ } from '../browser/storage.js';
23
+
24
+ /** Client stub returning a fixed Runtime.evaluate value, recording expressions. */
25
+ function stubClient(value?: unknown, exceptionDetails?: unknown): {
26
+ client: CdpClient;
27
+ exprs: string[];
28
+ sids: Array<string | undefined>;
29
+ } {
30
+ const exprs: string[] = [];
31
+ const sids: Array<string | undefined> = [];
32
+ const client = {
33
+ send: vi.fn(async (_m: string, params: { expression: string }, sid?: string) => {
34
+ exprs.push(params.expression);
35
+ sids.push(sid);
36
+ return exceptionDetails ? { result: {}, exceptionDetails } : { result: { value, type: 'string' } };
37
+ }),
38
+ } as unknown as CdpClient;
39
+ return { client, exprs, sids };
40
+ }
41
+
42
+ describe('evaluateJs', () => {
43
+ it('sends Runtime.evaluate with returnByValue and awaitPromise, scoped to the session', async () => {
44
+ const client = {
45
+ send: vi.fn(async () => ({ result: { value: 42, type: 'number' } })),
46
+ } as unknown as CdpClient;
47
+ await expect(evaluateJs(client, 'S1', '1 + 41')).resolves.toBe(42);
48
+ expect((client.send as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([
49
+ 'Runtime.evaluate',
50
+ { expression: '1 + 41', returnByValue: true, awaitPromise: true },
51
+ 'S1',
52
+ ]);
53
+ });
54
+
55
+ it('surfaces the exception description when the page throws', async () => {
56
+ const { client } = stubClient(undefined, {
57
+ text: 'Uncaught',
58
+ exception: { description: 'ReferenceError: foo is not defined' },
59
+ });
60
+ await expect(evaluateJs(client, 'S1', 'foo')).rejects.toThrow(
61
+ 'JS evaluation error: ReferenceError: foo is not defined'
62
+ );
63
+ });
64
+
65
+ it('falls back to exceptionDetails.text when there is no description', async () => {
66
+ const { client } = stubClient(undefined, { text: 'Uncaught SyntaxError' });
67
+ await expect(evaluateJs(client, 'S1', '{{')).rejects.toThrow(
68
+ 'JS evaluation error: Uncaught SyntaxError'
69
+ );
70
+ });
71
+
72
+ it('returns undefined for an expression with no value', async () => {
73
+ const client = { send: vi.fn(async () => ({ result: { type: 'undefined' } })) } as unknown as CdpClient;
74
+ await expect(evaluateJs(client, 'S1', 'void 0')).resolves.toBeUndefined();
75
+ });
76
+
77
+ it('rejects rather than hanging when the page never settles the promise', async () => {
78
+ // awaitPromise:true means a never-settling expression would hang forever
79
+ // without the race; a 20ms budget proves the timer is armed.
80
+ const client = { send: vi.fn(() => new Promise(() => {})) } as unknown as CdpClient;
81
+ await expect(evaluateJs(client, 'S1', 'new Promise(() => {})', 20)).rejects.toThrow(
82
+ 'JS evaluation timed out after 20ms'
83
+ );
84
+ });
85
+
86
+ it('waits indefinitely when the timeout is disabled with 0', async () => {
87
+ let resolveSend: (v: unknown) => void = () => {};
88
+ const client = {
89
+ send: vi.fn(() => new Promise((r) => { resolveSend = r; })),
90
+ } as unknown as CdpClient;
91
+ const p = evaluateJs(client, 'S1', 'slow()', 0);
92
+ resolveSend({ result: { value: 'done', type: 'string' } });
93
+ await expect(p).resolves.toBe('done');
94
+ });
95
+ });
96
+
97
+ describe('localStorage helpers', () => {
98
+ it('reads a key through a JSON-escaped getItem call', async () => {
99
+ const { client, exprs, sids } = stubClient('v');
100
+ await expect(getLocalStorageKey(client, 'S1', 'token')).resolves.toBe('v');
101
+ expect(exprs[0]).toBe('localStorage.getItem("token")');
102
+ expect(sids[0]).toBe('S1');
103
+ });
104
+
105
+ it('escapes a key containing quotes and backslashes instead of breaking out', async () => {
106
+ const { client, exprs } = stubClient(null);
107
+ await getLocalStorageKey(client, 'S1', 'a"); alert(1); //');
108
+ expect(exprs[0]).toBe('localStorage.getItem("a\\"); alert(1); //")');
109
+ // The payload must survive as *one* string literal: round-tripping the
110
+ // argument back through JSON.parse recovers the key verbatim, which it
111
+ // could not if the quote had terminated the literal early.
112
+ const arg = exprs[0]!.slice('localStorage.getItem('.length, -1);
113
+ expect(JSON.parse(arg)).toBe('a"); alert(1); //');
114
+ });
115
+
116
+ it('escapes newlines in a key so the expression stays one statement', async () => {
117
+ const { client, exprs } = stubClient(null);
118
+ await removeLocalStorageKey(client, 'S1', 'a\nb');
119
+ expect(exprs[0]).toBe('localStorage.removeItem("a\\nb")');
120
+ expect(exprs[0]).not.toContain('\n');
121
+ });
122
+
123
+ it('escapes both key and value on write', async () => {
124
+ const { client, exprs } = stubClient();
125
+ await setLocalStorageKey(client, 'S1', 'k"1', 'v"2');
126
+ expect(exprs[0]).toBe('localStorage.setItem("k\\"1", "v\\"2")');
127
+ });
128
+
129
+ it('clears with a literal expression', async () => {
130
+ const { client, exprs } = stubClient();
131
+ await clearLocalStorage(client, 'S1');
132
+ expect(exprs[0]).toBe('localStorage.clear()');
133
+ });
134
+
135
+ it('getAll parses the JSON dump', async () => {
136
+ const { client } = stubClient('{"a":"1","b":"2"}');
137
+ await expect(getAllLocalStorage(client, 'S1')).resolves.toEqual({ a: '1', b: '2' });
138
+ });
139
+
140
+ it('getAll degrades to {} on unparseable output rather than throwing', async () => {
141
+ const { client } = stubClient('not json');
142
+ await expect(getAllLocalStorage(client, 'S1')).resolves.toEqual({});
143
+ });
144
+
145
+ it('getItem returning null (missing key) is passed through as null', async () => {
146
+ const { client } = stubClient(null);
147
+ await expect(getLocalStorageKey(client, 'S1', 'missing')).resolves.toBeNull();
148
+ });
149
+ });
150
+
151
+ describe('sessionStorage helpers mirror the localStorage ones', () => {
152
+ it('read / write / remove / clear all target sessionStorage', async () => {
153
+ const { client, exprs } = stubClient('v');
154
+ await getSessionStorageKey(client, 'S1', 'k');
155
+ await setSessionStorageKey(client, 'S1', 'k', 'v');
156
+ await removeSessionStorageKey(client, 'S1', 'k');
157
+ await clearSessionStorage(client, 'S1');
158
+ expect(exprs).toEqual([
159
+ 'sessionStorage.getItem("k")',
160
+ 'sessionStorage.setItem("k", "v")',
161
+ 'sessionStorage.removeItem("k")',
162
+ 'sessionStorage.clear()',
163
+ ]);
164
+ expect(exprs.some((e) => e.includes('localStorage'))).toBe(false);
165
+ });
166
+
167
+ it('getAll parses, and degrades to {} on garbage', async () => {
168
+ await expect(getAllSessionStorage(stubClient('{"s":"1"}').client, 'S1')).resolves.toEqual({ s: '1' });
169
+ await expect(getAllSessionStorage(stubClient('<html>').client, 'S1')).resolves.toEqual({});
170
+ });
171
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * tabs.ts — target filtering and the CDP command sequences for
3
+ * close/activate/frame-switch. Fake fetch + fake client; no browser.
4
+ */
5
+ import { describe, it, expect, afterEach, vi } from 'vitest';
6
+ import type { CdpClient } from '../browser/cdp.js';
7
+ import { listTabs, newTab, closeTab, activateTab, switchToFrame } from '../browser/tabs.js';
8
+
9
+ const realFetch = globalThis.fetch;
10
+ afterEach(() => {
11
+ globalThis.fetch = realFetch;
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ function stubFetch(body: unknown, init: ResponseInit = { status: 200 }): ReturnType<typeof vi.fn> {
16
+ const fn = vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), init)));
17
+ globalThis.fetch = fn as unknown as typeof fetch;
18
+ return fn;
19
+ }
20
+
21
+ /** Client stub that records calls and answers from a per-method table. */
22
+ function stubClient(table: Record<string, unknown> = {}): {
23
+ client: CdpClient;
24
+ calls: Array<{ method: string; params: unknown; sid?: string }>;
25
+ } {
26
+ const calls: Array<{ method: string; params: unknown; sid?: string }> = [];
27
+ const client = {
28
+ send: vi.fn(async (method: string, params: unknown, sid?: string) => {
29
+ calls.push({ method, params, sid });
30
+ const entry = table[method];
31
+ if (entry instanceof Error) throw entry;
32
+ return entry ?? {};
33
+ }),
34
+ } as unknown as CdpClient;
35
+ return { client, calls };
36
+ }
37
+
38
+ describe('listTabs', () => {
39
+ it('keeps only page targets, dropping service workers, iframes and extensions', async () => {
40
+ stubFetch([
41
+ { id: 'A', type: 'page', url: 'https://x.test' },
42
+ { id: 'B', type: 'service_worker', url: 'https://x.test/sw.js' },
43
+ { id: 'C', type: 'iframe', url: 'https://ads.test' },
44
+ { id: 'D', type: 'background_page', url: 'chrome-extension://abc' },
45
+ { id: 'E', type: 'page', url: 'about:blank' },
46
+ ]);
47
+ const tabs = await listTabs(9222);
48
+ expect(tabs.map((t) => t.id)).toEqual(['A', 'E']);
49
+ });
50
+
51
+ it('returns [] when Chrome reports no targets at all', async () => {
52
+ stubFetch([]);
53
+ await expect(listTabs(9222)).resolves.toEqual([]);
54
+ });
55
+
56
+ it('propagates a failed /json/list', async () => {
57
+ globalThis.fetch = vi.fn(() =>
58
+ Promise.resolve(new Response('', { status: 502, statusText: 'Bad Gateway' }))
59
+ ) as unknown as typeof fetch;
60
+ await expect(listTabs(9222)).rejects.toThrow('Failed to fetch targets: Bad Gateway');
61
+ });
62
+ });
63
+
64
+ describe('newTab', () => {
65
+ it('defaults to about:blank', async () => {
66
+ const fn = stubFetch({ id: 'T1', type: 'page' });
67
+ await newTab(9222);
68
+ expect(fn.mock.calls[0]![0]).toBe(
69
+ 'http://127.0.0.1:9222/json/new?' + encodeURIComponent('about:blank')
70
+ );
71
+ });
72
+
73
+ it('passes an explicit url through, encoded', async () => {
74
+ const fn = stubFetch({ id: 'T1', type: 'page' });
75
+ await newTab(9222, 'https://x.test/a b?q=1');
76
+ expect(fn.mock.calls[0]![0]).toContain(encodeURIComponent('https://x.test/a b?q=1'));
77
+ });
78
+ });
79
+
80
+ describe('closeTab', () => {
81
+ it('sends Target.closeTarget for the given targetId', async () => {
82
+ const { client, calls } = stubClient();
83
+ await closeTab(client, 'S1', 'T9');
84
+ expect(calls).toEqual([{ method: 'Target.closeTarget', params: { targetId: 'T9' }, sid: undefined }]);
85
+ });
86
+ });
87
+
88
+ describe('activateTab', () => {
89
+ it('detaches the old session, activates, then re-attaches flat and returns the new sessionId', async () => {
90
+ const { client, calls } = stubClient({ 'Target.attachToTarget': { sessionId: 'S-NEW' } });
91
+ await expect(activateTab(client, 'S-OLD', 'T9')).resolves.toBe('S-NEW');
92
+ expect(calls.map((c) => c.method)).toEqual([
93
+ 'Target.detachFromTarget',
94
+ 'Target.activateTarget',
95
+ 'Target.attachToTarget',
96
+ ]);
97
+ expect(calls[0]!.params).toEqual({ sessionId: 'S-OLD' });
98
+ expect(calls[2]!.params).toEqual({ targetId: 'T9', flatten: true });
99
+ });
100
+
101
+ it('skips the detach when there is no previous session', async () => {
102
+ const { client, calls } = stubClient({ 'Target.attachToTarget': { sessionId: 'S-NEW' } });
103
+ await activateTab(client, '', 'T9');
104
+ expect(calls.map((c) => c.method)).toEqual(['Target.activateTarget', 'Target.attachToTarget']);
105
+ });
106
+
107
+ it('tolerates a detach failure on an already-dead session', async () => {
108
+ const calls: string[] = [];
109
+ const client = {
110
+ send: vi.fn(async (method: string) => {
111
+ calls.push(method);
112
+ if (method === 'Target.detachFromTarget') throw new Error('No session with given id');
113
+ if (method === 'Target.attachToTarget') return { sessionId: 'S-NEW' };
114
+ return {};
115
+ }),
116
+ } as unknown as CdpClient;
117
+ await expect(activateTab(client, 'S-STALE', 'T9')).resolves.toBe('S-NEW');
118
+ expect(calls).toContain('Target.activateTarget');
119
+ });
120
+ });
121
+
122
+ describe('switchToFrame', () => {
123
+ const frameTreeWith = (url: string) => ({
124
+ frameTree: { childFrames: [{ frame: { id: 'F1', url, securityOrigin: 'https://f.test' } }] },
125
+ });
126
+
127
+ it('returns null url when the selector does not resolve to an IFRAME', async () => {
128
+ const { client, calls } = stubClient({
129
+ 'Runtime.evaluate': { result: { result: { value: null } } },
130
+ 'Page.getFrameTree': { frameTree: {} },
131
+ });
132
+ await expect(switchToFrame(client, 'S1', 'div.not-a-frame')).resolves.toEqual({ url: null, sessionId: null });
133
+ // The selector is embedded as a JSON string literal, never interpolated raw.
134
+ const expr = (calls[0]!.params as { expression: string }).expression;
135
+ expect(expr).toContain('document.querySelector("div.not-a-frame")');
136
+ });
137
+
138
+ it('JSON-escapes a selector containing quotes rather than breaking the expression', async () => {
139
+ const { client, calls } = stubClient({
140
+ 'Runtime.evaluate': { result: { result: { value: null } } },
141
+ 'Page.getFrameTree': { frameTree: {} },
142
+ });
143
+ await switchToFrame(client, 'S1', 'iframe[title="a\\"b"]');
144
+ const expr = (calls[0]!.params as { expression: string }).expression;
145
+ expect(expr).toContain(JSON.stringify('iframe[title="a\\"b"]'));
146
+ });
147
+
148
+ it('returns the frame src with null sessionId when no OOPIF target matches', async () => {
149
+ const { client, calls } = stubClient({
150
+ 'Runtime.evaluate': { result: { result: { value: 'https://f.test/frame' } } },
151
+ 'Page.getFrameTree': frameTreeWith('https://f.test/frame'),
152
+ 'Target.getTargets': { targetInfos: [{ targetId: 'T1', type: 'page', url: 'https://x.test' }] },
153
+ });
154
+ await expect(switchToFrame(client, 'S1', 'iframe')).resolves.toEqual({ url: 'https://f.test/frame', sessionId: null });
155
+ expect(calls.map((c) => c.method)).not.toContain('Target.attachToTarget');
156
+ });
157
+
158
+ it('attaches flat to a matching OOPIF target and returns its sessionId', async () => {
159
+ const { client, calls } = stubClient({
160
+ 'Runtime.evaluate': { result: { result: { value: 'https://f.test/frame' } } },
161
+ 'Page.getFrameTree': frameTreeWith('https://f.test/frame'),
162
+ 'Target.getTargets': {
163
+ targetInfos: [
164
+ { targetId: 'T-PAGE', type: 'page', url: 'https://f.test/frame' },
165
+ { targetId: 'T-OOPIF', type: 'iframe', url: 'https://f.test/frame' },
166
+ ],
167
+ },
168
+ 'Target.attachToTarget': { sessionId: 'S-FRAME' },
169
+ });
170
+ await expect(switchToFrame(client, 'S1', 'iframe')).resolves.toEqual({ url: 'https://f.test/frame', sessionId: 'S-FRAME' });
171
+ const attach = calls.find((c) => c.method === 'Target.attachToTarget');
172
+ expect(attach!.params).toEqual({ targetId: 'T-OOPIF', flatten: true });
173
+ });
174
+
175
+ it('returns the src with null sessionId when the frame tree has no match', async () => {
176
+ const { client, calls } = stubClient({
177
+ 'Runtime.evaluate': { result: { result: { value: 'https://f.test/frame' } } },
178
+ 'Page.getFrameTree': frameTreeWith('https://other.test/elsewhere'),
179
+ });
180
+ await expect(switchToFrame(client, 'S1', 'iframe')).resolves.toEqual({ url: 'https://f.test/frame', sessionId: null });
181
+ expect(calls.map((c) => c.method)).not.toContain('Target.getTargets');
182
+ });
183
+ });
@@ -8,11 +8,15 @@ import type { BrowserConfig, CdpTarget } from './types.js';
8
8
  import { CHROME_EXECUTABLES } from './types.js';
9
9
  import { enableConsoleCapture, setupConsoleCapture } from './console-log.js';
10
10
  import { setupDialogAutoHandling } from './dialog.js';
11
+ import { loadActivePortInfo } from './ref-cache.js';
11
12
 
12
13
  const DEFAULT_PORT = 9222;
13
14
  const LAUNCH_TIMEOUT = 10_000;
14
15
  const POLL_INTERVAL = 200;
15
16
  const BROWSER_CLOSE_TIMEOUT_MS = 3000;
17
+ // A cross-process persisted PID older than this is not trusted for the
18
+ // SIGKILL fallback in closeBrowser() — see the call site for why.
19
+ const PERSISTED_PID_MAX_AGE_MS = 24 * 60 * 60 * 1000;
16
20
 
17
21
  // Tracks the PID of Chrome instances *we* spawned, keyed by CDP port, so
18
22
  // closeBrowser() has a kill fallback when the graceful `Browser.close` CDP
@@ -20,6 +24,19 @@ const BROWSER_CLOSE_TIMEOUT_MS = 3000;
20
24
  // to (already-running browser) are never recorded here — we only ever kill
21
25
  // processes we launched ourselves.
22
26
  const launchedPids = new Map<number, number>();
27
+ // userDataDir per launched port — informational, persisted alongside the pid
28
+ // so a later process could clean up the temp profile dir too if ever needed.
29
+ const launchedUserDataDirs = new Map<number, string>();
30
+
31
+ /** PID of the Chrome instance *this process* launched on `port`, if any. */
32
+ export function getLaunchedPid(port: number): number | undefined {
33
+ return launchedPids.get(port);
34
+ }
35
+
36
+ /** userDataDir of the Chrome instance *this process* launched on `port`, if any. */
37
+ export function getLaunchedUserDataDir(port: number): string | undefined {
38
+ return launchedUserDataDirs.get(port);
39
+ }
23
40
 
24
41
  function findChrome(executablePath?: string): string {
25
42
  if (executablePath) {
@@ -185,9 +202,13 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
185
202
  stdio: 'ignore',
186
203
  });
187
204
  child.unref();
188
- if (child.pid) launchedPids.set(port, child.pid);
205
+ if (child.pid) {
206
+ launchedPids.set(port, child.pid);
207
+ launchedUserDataDirs.set(port, userDataDir);
208
+ }
189
209
 
190
- const deadline = Date.now() + LAUNCH_TIMEOUT;
210
+ const launchTimeout = config.launchTimeoutMs ?? LAUNCH_TIMEOUT;
211
+ const deadline = Date.now() + launchTimeout;
191
212
  while (Date.now() < deadline) {
192
213
  await sleep(POLL_INTERVAL);
193
214
  if (await isPortOpen(port)) {
@@ -204,12 +225,12 @@ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<nu
204
225
  // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
205
226
  if (await isTcpPortOpen(port)) {
206
227
  throw new Error(
207
- `Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${LAUNCH_TIMEOUT}ms). ` +
228
+ `Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${launchTimeout}ms). ` +
208
229
  `Free the port or pass a different one.`
209
230
  );
210
231
  }
211
232
 
212
- throw new Error(`Chrome failed to start on port ${port} within ${LAUNCH_TIMEOUT}ms`);
233
+ throw new Error(`Chrome failed to start on port ${port} within ${launchTimeout}ms`);
213
234
  }
214
235
 
215
236
  export async function enableSessionDomains(client: CdpClient, sessionId: string): Promise<void> {
@@ -277,12 +298,43 @@ export async function closeBrowser(client: CdpClient, port: number): Promise<voi
277
298
  // fall through to PID-kill fallback below
278
299
  }
279
300
 
280
- const pid = launchedPids.get(port);
281
- if (pid === undefined) return; // not a process we launched — nothing to kill
301
+ let pid = launchedPids.get(port);
302
+ if (pid === undefined) {
303
+ // Fresh process (each CLI invocation is its own node process — see
304
+ // module header) — launchedPids is per-process and empty here even
305
+ // though a prior `open` in a DIFFERENT process launched this Chrome.
306
+ // Fall back to the PID that process persisted via saveActivePort().
307
+ try {
308
+ const persisted = await loadActivePortInfo();
309
+ if (persisted && persisted.port === port && persisted.launched && persisted.pid) {
310
+ // Bound how far we trust a cross-process PID: if the Chrome we
311
+ // launched died outside this flow (OOM-killed, host slept, etc.)
312
+ // without active-port.json ever being cleared, the OS can recycle
313
+ // that PID for an unrelated process. There is no cmdline/start-time
314
+ // check available portably, so a coarse age bound is the practical
315
+ // guard — a PID persisted more than a day ago is far more likely to
316
+ // be stale/reused than to still be the same live Chrome.
317
+ const age = persisted.savedAt !== undefined ? Date.now() - persisted.savedAt : undefined;
318
+ if (age === undefined || age <= PERSISTED_PID_MAX_AGE_MS) {
319
+ pid = persisted.pid;
320
+ }
321
+ }
322
+ } catch {
323
+ // best-effort — fall through to "nothing to kill" below
324
+ }
325
+ }
326
+ if (pid === undefined) return; // not a process we launched, or too stale to trust — nothing to kill
282
327
  launchedPids.delete(port);
283
328
 
329
+ // Liveness-check before every SIGKILL (not just the safety-net path below)
330
+ // — process.kill(pid, 0) throws ESRCH if the PID is no longer running,
331
+ // which also catches the case where the PID has already been recycled by
332
+ // an unrelated process that happens to have exited too.
284
333
  if (!gracefullyClosed) {
285
- try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
334
+ try {
335
+ process.kill(pid, 0);
336
+ process.kill(pid, 'SIGKILL');
337
+ } catch { /* already exited, or never was — nothing to do */ }
286
338
  return;
287
339
  }
288
340
 
@@ -1,13 +1,41 @@
1
1
  import { WebSocket } from 'ws';
2
2
  import type { CdpCommand, CdpResponse, CdpTarget } from './types.js';
3
3
 
4
+ // send() had no timeout of its own — a command whose response never arrives
5
+ // (wedged/crashed renderer, dropped socket that never fires 'close') left a
6
+ // pendingCommands entry forever, hanging whatever awaited it (and, since
7
+ // most CLI actions await a single send(), the whole CLI process). Ad-hoc
8
+ // timeouts were previously patched in at a handful of call sites (e.g.
9
+ // actions.ts's evaluateJs) instead of here, so most commands had no
10
+ // protection at all. This is the default for every send() unless a caller
11
+ // passes 0 (no timeout) or a different value.
12
+ export const DEFAULT_CDP_SEND_TIMEOUT_MS = 30_000;
13
+
4
14
  export class CdpClient {
5
15
  private ws: WebSocket | null = null;
6
- private pendingCommands = new Map<number, { resolve: (r: CdpResponse) => void; reject: (e: Error) => void }>();
16
+ private pendingCommands = new Map<number, { resolve: (r: CdpResponse) => void; reject: (e: Error) => void; timer?: ReturnType<typeof setTimeout> }>();
7
17
  private eventListeners = new Map<string, Set<(params: Record<string, unknown>, sessionId?: string) => void>>();
8
18
  private nextId = 1;
9
19
  private connected = false;
10
20
 
21
+ constructor() {
22
+ // Target.targetCrashed/targetDestroyed fire over the same WS connection
23
+ // as command responses, but a crashed renderer's connection can otherwise
24
+ // sit open with no 'close'/'error' event ever firing — flush pending
25
+ // commands here so send() doesn't have to wait out its own timeout.
26
+ this.on('Target.targetCrashed', () => this.flushPending(new Error('CDP target crashed')));
27
+ this.on('Target.targetDestroyed', () => this.flushPending(new Error('CDP target destroyed')));
28
+ }
29
+
30
+ private flushPending(err: Error): void {
31
+ this.connected = false;
32
+ for (const { reject: r, timer } of this.pendingCommands.values()) {
33
+ if (timer) clearTimeout(timer);
34
+ r(err);
35
+ }
36
+ this.pendingCommands.clear();
37
+ }
38
+
11
39
  async connect(wsUrl: string): Promise<void> {
12
40
  return new Promise((resolve, reject) => {
13
41
  this.ws = new WebSocket(wsUrl);
@@ -22,18 +50,12 @@ export class CdpClient {
22
50
  reject(err);
23
51
  } else {
24
52
  // Post-connect: flush all pending commands — 'close' may not fire on all platforms
25
- this.connected = false;
26
- for (const { reject: r } of this.pendingCommands.values()) r(err);
27
- this.pendingCommands.clear();
53
+ this.flushPending(err instanceof Error ? err : new Error(String(err)));
28
54
  }
29
55
  });
30
56
 
31
57
  this.ws.on('close', () => {
32
- this.connected = false;
33
- for (const { reject: r } of this.pendingCommands.values()) {
34
- r(new Error('CDP connection closed'));
35
- }
36
- this.pendingCommands.clear();
58
+ this.flushPending(new Error('CDP connection closed'));
37
59
  });
38
60
 
39
61
  this.ws.on('message', (data) => {
@@ -62,7 +84,12 @@ export class CdpClient {
62
84
  });
63
85
  }
64
86
 
65
- send<T = Record<string, unknown>>(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<T> {
87
+ send<T = Record<string, unknown>>(
88
+ method: string,
89
+ params?: Record<string, unknown>,
90
+ sessionId?: string,
91
+ timeoutMs: number = DEFAULT_CDP_SEND_TIMEOUT_MS
92
+ ): Promise<T> {
66
93
  return new Promise((resolve, reject) => {
67
94
  if (!this.ws || !this.connected) {
68
95
  reject(new Error('CDP not connected'));
@@ -76,12 +103,24 @@ export class CdpClient {
76
103
  const id = this.nextId++;
77
104
  const cmd: CdpCommand = { id, method, params };
78
105
  if (sessionId) cmd.sessionId = sessionId;
106
+
107
+ let timer: ReturnType<typeof setTimeout> | undefined;
108
+ if (timeoutMs > 0) {
109
+ timer = setTimeout(() => {
110
+ this.pendingCommands.delete(id);
111
+ reject(new Error(`CDP command "${method}" timed out after ${timeoutMs}ms`));
112
+ }, timeoutMs);
113
+ timer.unref?.();
114
+ }
115
+
79
116
  this.pendingCommands.set(id, {
80
- resolve: (r) => resolve((r.result ?? {}) as T),
81
- reject,
117
+ resolve: (r) => { if (timer) clearTimeout(timer); resolve((r.result ?? {}) as T); },
118
+ reject: (e) => { if (timer) clearTimeout(timer); reject(e); },
119
+ timer,
82
120
  });
83
121
  this.ws.send(JSON.stringify(cmd), (err) => {
84
122
  if (err) {
123
+ if (timer) clearTimeout(timer);
85
124
  this.pendingCommands.delete(id);
86
125
  reject(err);
87
126
  }
@@ -120,9 +159,7 @@ export class CdpClient {
120
159
  }
121
160
 
122
161
  close(): void {
123
- this.connected = false;
124
- for (const { reject: r } of this.pendingCommands.values()) r(new Error('CDP connection closed'));
125
- this.pendingCommands.clear();
162
+ this.flushPending(new Error('CDP connection closed'));
126
163
  this.ws?.close();
127
164
  this.ws = null;
128
165
  this.eventListeners.clear();
@@ -97,14 +97,29 @@ export async function clearRefCache(): Promise<void> {
97
97
  * falling back to the hardcoded default port and launching/attaching to a
98
98
  * second, unrelated Chrome instance.
99
99
  */
100
- export async function saveActivePort(port: number, opts?: { launched?: boolean }): Promise<void> {
100
+ export async function saveActivePort(
101
+ port: number,
102
+ opts?: { launched?: boolean; pid?: number; userDataDir?: string }
103
+ ): Promise<void> {
101
104
  try {
102
105
  await mkdir(CACHE_DIR, { recursive: true });
103
106
  // `launched` records provenance: true = monobrowse spawned this Chrome
104
107
  // (safe to Browser.close later); false = attached to a browser someone
105
108
  // else owns (must never be killed). Absent (old files) reads as launched
106
109
  // — matches pre-flag behavior where every persisted port came from open.
107
- await writeFile(PORT_FILE, JSON.stringify({ port, launched: opts?.launched !== false, savedAt: Date.now() }));
110
+ //
111
+ // `pid`/`userDataDir` are persisted so a LATER, fresh CLI process (each
112
+ // command is its own node process — see module header) can still find
113
+ // and kill the Chrome this one launched. Without this, closeBrowser()'s
114
+ // PID-kill fallback can never fire outside the launching process, since
115
+ // the in-memory launchedPids Map (browser.ts) is empty there.
116
+ await writeFile(PORT_FILE, JSON.stringify({
117
+ port,
118
+ launched: opts?.launched !== false,
119
+ pid: opts?.pid,
120
+ userDataDir: opts?.userDataDir,
121
+ savedAt: Date.now(),
122
+ }));
108
123
  } catch {
109
124
  // Best-effort — persistence failure just means the next process falls
110
125
  // back to the hardcoded default port, matching prior behavior.
@@ -126,13 +141,21 @@ export async function loadActivePort(): Promise<number | null> {
126
141
  return (await loadActivePortInfo())?.port ?? null;
127
142
  }
128
143
 
129
- /** Load the persisted active port with its provenance flag. */
130
- export async function loadActivePortInfo(): Promise<{ port: number; launched: boolean } | null> {
144
+ /** Load the persisted active port with its provenance flag, PID, user-data-dir, and save timestamp. */
145
+ export async function loadActivePortInfo(): Promise<
146
+ { port: number; launched: boolean; pid?: number; userDataDir?: string; savedAt?: number } | null
147
+ > {
131
148
  try {
132
149
  const raw = await readFile(PORT_FILE, 'utf8');
133
- const data = JSON.parse(raw) as { port?: unknown; launched?: unknown };
150
+ const data = JSON.parse(raw) as { port?: unknown; launched?: unknown; pid?: unknown; userDataDir?: unknown; savedAt?: unknown };
134
151
  if (typeof data.port === 'number' && Number.isInteger(data.port) && data.port >= 1024 && data.port <= 65535) {
135
- return { port: data.port, launched: data.launched !== false };
152
+ return {
153
+ port: data.port,
154
+ launched: data.launched !== false,
155
+ pid: typeof data.pid === 'number' && Number.isInteger(data.pid) && data.pid > 0 ? data.pid : undefined,
156
+ userDataDir: typeof data.userDataDir === 'string' ? data.userDataDir : undefined,
157
+ savedAt: typeof data.savedAt === 'number' ? data.savedAt : undefined,
158
+ };
136
159
  }
137
160
  return null;
138
161
  } catch {
@@ -217,6 +217,17 @@ export async function getObjectIdForRef(
217
217
  return result.object?.objectId ?? null;
218
218
  }
219
219
 
220
+ /**
221
+ * Returns the element's content box — but `x`/`y` are the CENTER point
222
+ * (averaged across all four corners of CDP's content quad), not the
223
+ * top-left origin a `getBoundingClientRect()`-style box normally implies.
224
+ * This is deliberate: every caller in this file (clickElement, fillElement,
225
+ * hoverElement, dragElement, ...) uses box.x/box.y directly as the
226
+ * interaction target, which should land at the element's center. Do NOT
227
+ * add width/2 or height/2 to these values — that double-offsets away from
228
+ * the element (see issue #15). If you need the true top-left origin, derive
229
+ * it as `x - width / 2, y - height / 2`.
230
+ */
220
231
  export async function getElementBox(
221
232
  client: CdpClient,
222
233
  sessionId: string,