@monoes/monobrowse 1.0.6 → 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 (248) 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 +0 -0
  11. package/dist/src/__tests__/box-model.test.d.ts.map +0 -0
  12. package/dist/src/__tests__/box-model.test.js +0 -0
  13. package/dist/src/__tests__/box-model.test.js.map +0 -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 +0 -0
  175. package/dist/src/browser/snapshot.d.ts.map +0 -0
  176. package/dist/src/browser/snapshot.js +0 -0
  177. package/dist/src/browser/snapshot.js.map +0 -0
  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 +0 -0
  207. package/dist/src/cli/commands.d.ts.map +1 -1
  208. package/dist/src/cli/commands.js +96 -4
  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__/cdp-client.test.ts +375 -0
  234. package/src/__tests__/close-browser.test.ts +141 -0
  235. package/src/__tests__/console-log.test.ts +250 -0
  236. package/src/__tests__/find.test.ts +354 -0
  237. package/src/__tests__/frame-command.test.ts +98 -0
  238. package/src/__tests__/ref-cache.test.ts +47 -2
  239. package/src/__tests__/session-state.test.ts +251 -0
  240. package/src/__tests__/snapshot.test.ts +331 -0
  241. package/src/__tests__/storage-eval.test.ts +171 -0
  242. package/src/__tests__/tabs.test.ts +183 -0
  243. package/src/browser/browser.ts +59 -7
  244. package/src/browser/cdp.ts +52 -15
  245. package/src/browser/ref-cache.ts +29 -6
  246. package/src/browser/tabs.ts +12 -4
  247. package/src/browser/types.ts +4 -0
  248. package/src/cli/commands.ts +90 -4
@@ -0,0 +1,98 @@
1
+ /**
2
+ * frame command (#97) — switching to an iframe must retain the OOPIF
3
+ * sessionId returned by switchToFrame so subsequent commands act on the
4
+ * frame, and `frame main` must detach the frame session and restore the
5
+ * page session. The browser engine is mocked; no real Chrome.
6
+ */
7
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
8
+ import type { Command, CommandContext } from '../cli/types.js';
9
+
10
+ const mocks = vi.hoisted(() => {
11
+ const send = vi.fn(async (_method: string, _params?: unknown, _sid?: string) => ({}));
12
+ const client = { send, isConnected: () => true, close: vi.fn() };
13
+ return {
14
+ send,
15
+ client,
16
+ browser: {
17
+ launchBrowser: vi.fn(async () => 9222),
18
+ connectToTarget: vi.fn(async () => ({
19
+ client,
20
+ sessionId: 'S-PAGE',
21
+ target: { id: 'T-PAGE', url: 'https://x.test' },
22
+ })),
23
+ loadActivePortInfo: vi.fn(async () => null),
24
+ loadRefCache: vi.fn(async () => null),
25
+ switchToFrame: vi.fn(async (): Promise<{ url: string | null; sessionId: string | null }> => ({ url: 'https://f.test/frame', sessionId: 'S-FRAME' })),
26
+ enableSessionDomains: vi.fn(async () => {}),
27
+ teardownRouteInterception: vi.fn(),
28
+ stopRequestCapture: vi.fn(),
29
+ teardownDialogHandling: vi.fn(),
30
+ teardownConsoleCapture: vi.fn(),
31
+ },
32
+ };
33
+ });
34
+
35
+ vi.mock('../index.js', () => mocks.browser);
36
+
37
+ async function loadFrameCommand(): Promise<Command> {
38
+ const mod = (await import('../cli/commands.js')).default;
39
+ const cmd = mod.subcommands!.find((c) => c.name === 'frame');
40
+ if (!cmd?.action) throw new Error('frame command not found');
41
+ return cmd;
42
+ }
43
+
44
+ const ctx = (args: string[]): CommandContext => ({
45
+ args,
46
+ flags: { _: [] },
47
+ cwd: process.cwd(),
48
+ interactive: false,
49
+ });
50
+
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ mocks.browser.loadActivePortInfo.mockResolvedValue(null);
54
+ mocks.browser.loadRefCache.mockResolvedValue(null);
55
+ mocks.browser.switchToFrame.mockResolvedValue({ url: 'https://f.test/frame', sessionId: 'S-FRAME' });
56
+ mocks.browser.connectToTarget.mockResolvedValue({
57
+ client: mocks.client,
58
+ sessionId: 'S-PAGE',
59
+ target: { id: 'T-PAGE', url: 'https://x.test' },
60
+ });
61
+ });
62
+
63
+ describe('browse frame command (#97)', () => {
64
+ it('stores the OOPIF sessionId, enables its domains, and `frame main` detaches it and restores the page session', async () => {
65
+ vi.resetModules();
66
+ const frame = await loadFrameCommand();
67
+
68
+ // Enter the frame — engine reports the OOPIF session
69
+ await frame.action!(ctx(['iframe']));
70
+ expect(mocks.browser.switchToFrame).toHaveBeenCalledWith(mocks.client, 'S-PAGE', 'iframe');
71
+ expect(mocks.browser.enableSessionDomains).toHaveBeenCalledWith(mocks.client, 'S-FRAME');
72
+
73
+ // Back to main — must detach the FRAME session (proves _sessionId was updated)
74
+ await frame.action!(ctx(['main']));
75
+ const detach = mocks.send.mock.calls.find(
76
+ ([m]) => m === 'Target.detachFromTarget'
77
+ );
78
+ expect(detach).toBeDefined();
79
+ expect(detach![1]).toEqual({ sessionId: 'S-FRAME' });
80
+
81
+ // Entering a frame again must start from the restored page session
82
+ await frame.action!(ctx(['iframe']));
83
+ expect(mocks.browser.switchToFrame).toHaveBeenLastCalledWith(mocks.client, 'S-PAGE', 'iframe');
84
+ });
85
+
86
+ it('keeps the page session when the frame has no OOPIF target', async () => {
87
+ vi.resetModules();
88
+ mocks.browser.switchToFrame.mockResolvedValue({ url: 'https://f.test/frame', sessionId: null });
89
+ const frame = await loadFrameCommand();
90
+
91
+ await frame.action!(ctx(['iframe']));
92
+ expect(mocks.browser.enableSessionDomains).not.toHaveBeenCalled();
93
+
94
+ // `frame main` with no stored parent session must not detach anything
95
+ await frame.action!(ctx(['main']));
96
+ expect(mocks.send.mock.calls.some(([m]) => m === 'Target.detachFromTarget')).toBe(false);
97
+ });
98
+ });
@@ -138,13 +138,13 @@ describe('active-port persistence', () => {
138
138
  const mod = await importRefCache();
139
139
  await mod.saveActivePort(9333);
140
140
  expect(await mod.loadActivePort()).toBe(9333);
141
- expect(await mod.loadActivePortInfo()).toEqual({ port: 9333, launched: true });
141
+ expect(await mod.loadActivePortInfo()).toEqual({ port: 9333, launched: true, savedAt: expect.any(Number) });
142
142
  });
143
143
 
144
144
  it('connect provenance: launched:false survives the round-trip', async () => {
145
145
  const mod = await importRefCache();
146
146
  await mod.saveActivePort(9229, { launched: false });
147
- expect(await mod.loadActivePortInfo()).toEqual({ port: 9229, launched: false });
147
+ expect(await mod.loadActivePortInfo()).toEqual({ port: 9229, launched: false, savedAt: expect.any(Number) });
148
148
  });
149
149
 
150
150
  it('clear removes the file; load returns null afterwards', async () => {
@@ -161,6 +161,51 @@ describe('active-port persistence', () => {
161
161
  await expect(mod.clearActivePort()).resolves.toBeUndefined();
162
162
  });
163
163
 
164
+ it('#115: pid and userDataDir persist and round-trip so a later process can kill an orphaned launch', async () => {
165
+ const mod = await importRefCache();
166
+ await mod.saveActivePort(9333, { pid: 54321, userDataDir: '/tmp/monomind-browser-9333' });
167
+ expect(await mod.loadActivePortInfo()).toEqual({
168
+ port: 9333,
169
+ launched: true,
170
+ pid: 54321,
171
+ userDataDir: '/tmp/monomind-browser-9333',
172
+ savedAt: expect.any(Number),
173
+ });
174
+ });
175
+
176
+ it('#115: an attached (launched:false) session with no pid argument persists none', async () => {
177
+ const mod = await importRefCache();
178
+ await mod.saveActivePort(9229, { launched: false });
179
+ const info = await mod.loadActivePortInfo();
180
+ expect(info!.launched).toBe(false);
181
+ expect(info!.pid).toBeUndefined();
182
+ });
183
+
184
+ it('#124-review: saveActivePort does NOT strip pid when launched:false — callers must not pass one for an attach', async () => {
185
+ // The property above (attach sessions have no pid) is caller discipline,
186
+ // not something this function enforces — the one production caller
187
+ // (commands.ts's `connect` action) simply never passes a pid alongside
188
+ // launched:false. This test documents that explicitly so it can't be
189
+ // mistaken for an invariant the code itself guarantees.
190
+ const mod = await importRefCache();
191
+ await mod.saveActivePort(9229, { launched: false, pid: 4242 });
192
+ const info = await mod.loadActivePortInfo();
193
+ expect(info!.launched).toBe(false);
194
+ expect(info!.pid).toBe(4242);
195
+ });
196
+
197
+ it('#115: a non-numeric or non-positive persisted pid is dropped, not trusted', async () => {
198
+ const mod = await importRefCache();
199
+ const { writeFile, mkdir } = await import('fs/promises');
200
+ const dir = join(process.cwd(), '.monomind', 'monobrowse');
201
+ await mkdir(dir, { recursive: true });
202
+ for (const badPid of ['54321', -1, 0, 1.5, null]) {
203
+ await writeFile(join(dir, 'active-port.json'), JSON.stringify({ port: 9333, launched: true, pid: badPid }));
204
+ const info = await mod.loadActivePortInfo();
205
+ expect(info!.pid).toBeUndefined();
206
+ }
207
+ });
208
+
164
209
  it('rejects out-of-range or non-integer persisted ports', async () => {
165
210
  const mod = await importRefCache();
166
211
  const { writeFile, mkdir } = await import('fs/promises');
@@ -0,0 +1,251 @@
1
+ /**
2
+ * session.ts — the path-safety guards and the state-file round trip.
3
+ *
4
+ * Session files hold cookies and localStorage, so the traversal guards and the
5
+ * 0600/0700 permission bits are security-relevant. All of it is filesystem +
6
+ * validation logic: the CdpClient is a stub that answers the handful of CDP
7
+ * commands the round trip issues.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
10
+ import { mkdtemp, rm, readFile, stat, mkdir } from 'fs/promises';
11
+ import { join } from 'path';
12
+ import { tmpdir } from 'os';
13
+ import type { CdpClient } from '../browser/cdp.js';
14
+ import {
15
+ saveStateFile,
16
+ loadStateFile,
17
+ saveSession,
18
+ loadSession,
19
+ listSessions,
20
+ } from '../browser/session.js';
21
+
22
+ const COOKIES = [{ name: 'sid', value: 'abc', domain: 'x.test', path: '/' }];
23
+
24
+ /** Records every CDP command and answers the ones session.ts depends on. */
25
+ function stubClient(): { client: CdpClient; calls: Array<{ method: string; params: unknown; sid?: string }> } {
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
+ if (method === 'Network.getCookies') return { cookies: COOKIES };
31
+ if (method === 'Runtime.evaluate') {
32
+ const expr = (params as { expression: string }).expression;
33
+ if (expr.includes('Object.entries(localStorage)')) return { result: { value: '{"k":"v"}' } };
34
+ if (expr.includes('Object.entries(sessionStorage)')) return { result: { value: '{"s":"1"}' } };
35
+ }
36
+ return {};
37
+ }),
38
+ } as unknown as CdpClient;
39
+ return { client, calls };
40
+ }
41
+
42
+ let dir: string;
43
+
44
+ beforeEach(async () => {
45
+ dir = await mkdtemp(join(tmpdir(), 'monobrowse-session-'));
46
+ });
47
+
48
+ afterEach(async () => {
49
+ vi.restoreAllMocks();
50
+ await rm(dir, { recursive: true, force: true });
51
+ });
52
+
53
+ describe('validateSessionName (via saveSession/loadSession)', () => {
54
+ const { client } = stubClient();
55
+
56
+ // Rejection happens before any filesystem or CDP work, so these never touch
57
+ // the real ~/.monomind directory.
58
+ const bad: Array<[string, string]> = [
59
+ ['empty', ''],
60
+ ['forward slash', 'a/b'],
61
+ ['backslash', 'a\\b'],
62
+ ['NUL byte', 'a\x00b'],
63
+ ['dot', '.'],
64
+ ['dot-dot', '..'],
65
+ ['dot-dot prefix', '..evil'],
66
+ ['traversal', '../../etc/passwd'],
67
+ ];
68
+
69
+ for (const [label, name] of bad) {
70
+ it(`rejects ${label}`, async () => {
71
+ await expect(saveSession(client, 'S1', 'T1', name, 'https://x.test', 'T')).rejects.toThrow(
72
+ /Invalid session name/
73
+ );
74
+ await expect(loadSession(client, 'S1', name)).rejects.toThrow(/Invalid session name/);
75
+ });
76
+ }
77
+
78
+ it('accepts an ordinary name (fails later, on the missing file, not on validation)', async () => {
79
+ await expect(loadSession(client, 'S1', 'no-such-session-xyz')).rejects.toThrow(
80
+ 'Session not found: no-such-session-xyz'
81
+ );
82
+ });
83
+
84
+ it('does not issue a single CDP command for a rejected name', async () => {
85
+ const { client: c, calls } = stubClient();
86
+ await expect(saveSession(c, 'S1', 'T1', '../x', 'https://x.test', 'T')).rejects.toThrow();
87
+ expect(calls).toEqual([]);
88
+ });
89
+ });
90
+
91
+ describe('validateFilePath (via saveStateFile/loadStateFile)', () => {
92
+ const { client } = stubClient();
93
+
94
+ const bad: Array<[string, string]> = [
95
+ ['empty', ''],
96
+ ['NUL byte', '/tmp/a\x00b.json'],
97
+ ['embedded ..', '/tmp/a/../../etc/passwd'],
98
+ ['leading ../', '../secret.json'],
99
+ ['trailing /..', '/tmp/state/..'],
100
+ ['backslash', 'C:\\Windows\\state.json'],
101
+ ];
102
+
103
+ for (const [label, filePath] of bad) {
104
+ it(`rejects ${label}`, async () => {
105
+ await expect(
106
+ saveStateFile(client, 'S1', 'T1', filePath, 'https://x.test', 'T')
107
+ ).rejects.toThrow(/Invalid file path/);
108
+ await expect(loadStateFile(client, 'S1', filePath)).rejects.toThrow(/Invalid file path/);
109
+ });
110
+ }
111
+
112
+ it('allows a plain absolute path', async () => {
113
+ const p = join(dir, 'state.json');
114
+ await expect(saveStateFile(client, 'S1', 'T1', p, 'https://x.test', 'T')).resolves.toBeUndefined();
115
+ });
116
+ });
117
+
118
+ describe('state file round trip', () => {
119
+ it('captures cookies plus local/session storage and writes owner-only (0600)', async () => {
120
+ const { client, calls } = stubClient();
121
+ const p = join(dir, 'nested', 'state.json');
122
+ await saveStateFile(client, 'S1', 'T1', p, 'https://x.test', 'Page Title');
123
+
124
+ const state = JSON.parse(await readFile(p, 'utf8'));
125
+ expect(state).toEqual({
126
+ targetId: 'T1',
127
+ sessionId: 'S1',
128
+ url: 'https://x.test',
129
+ title: 'Page Title',
130
+ cookies: COOKIES,
131
+ localStorage: { k: 'v' },
132
+ sessionStorage: { s: '1' },
133
+ });
134
+
135
+ expect((await stat(p)).mode & 0o777).toBe(0o600);
136
+ // Parent directory created owner-only too.
137
+ expect((await stat(join(dir, 'nested'))).mode & 0o777).toBe(0o700);
138
+
139
+ // Cookies were scoped to the page URL, not fetched globally.
140
+ expect(calls.find((c) => c.method === 'Network.getCookies')?.params).toEqual({
141
+ urls: ['https://x.test'],
142
+ });
143
+ });
144
+
145
+ it('replays cookies and both storages back into the page on load', async () => {
146
+ const saver = stubClient();
147
+ const p = join(dir, 'state.json');
148
+ await saveStateFile(saver.client, 'S1', 'T1', p, 'https://x.test', 'T');
149
+
150
+ const loader = stubClient();
151
+ const state = await loadStateFile(loader.client, 'S2', p);
152
+ expect(state.cookies).toEqual(COOKIES);
153
+
154
+ const setCookies = loader.calls.find((c) => c.method === 'Network.setCookies');
155
+ expect(setCookies).toMatchObject({ params: { cookies: COOKIES }, sid: 'S2' });
156
+
157
+ const scripts = loader.calls
158
+ .filter((c) => c.method === 'Runtime.evaluate')
159
+ .map((c) => (c.params as { expression: string }).expression);
160
+ expect(scripts).toContain('localStorage.setItem("k", "v")');
161
+ expect(scripts).toContain('sessionStorage.setItem("s", "1")');
162
+ });
163
+
164
+ it('rejects a state file whose cookies field is not an array', async () => {
165
+ const { client } = stubClient();
166
+ const p = join(dir, 'bad.json');
167
+ const { writeFile } = await import('fs/promises');
168
+ await writeFile(p, JSON.stringify({ cookies: { name: 'sid' } }));
169
+ await expect(loadStateFile(client, 'S1', p)).rejects.toThrow(
170
+ 'Invalid state file: cookies is not an array'
171
+ );
172
+ });
173
+
174
+ it('rejects a state file whose localStorage is an array rather than an object', async () => {
175
+ const { client } = stubClient();
176
+ const p = join(dir, 'bad2.json');
177
+ const { writeFile } = await import('fs/promises');
178
+ await writeFile(p, JSON.stringify({ cookies: [], localStorage: ['nope'] }));
179
+ await expect(loadStateFile(client, 'S1', p)).rejects.toThrow(
180
+ 'Invalid state file: localStorage is not a plain object'
181
+ );
182
+ });
183
+
184
+ it('rejects a state file whose sessionStorage is an array', async () => {
185
+ const { client } = stubClient();
186
+ const p = join(dir, 'bad3.json');
187
+ const { writeFile } = await import('fs/promises');
188
+ await writeFile(p, JSON.stringify({ cookies: [], sessionStorage: ['nope'] }));
189
+ await expect(loadStateFile(client, 'S1', p)).rejects.toThrow(
190
+ 'Invalid state file: sessionStorage is not a plain object'
191
+ );
192
+ });
193
+
194
+ it('accepts a state file with neither storage map present', async () => {
195
+ const { client, calls } = stubClient();
196
+ const p = join(dir, 'minimal.json');
197
+ const { writeFile } = await import('fs/promises');
198
+ await writeFile(p, JSON.stringify({ targetId: 'T', sessionId: 'S', url: '', title: '', cookies: [] }));
199
+ await expect(loadStateFile(client, 'S1', p)).resolves.toMatchObject({ cookies: [] });
200
+ expect(calls.filter((c) => c.method === 'Runtime.evaluate')).toEqual([]);
201
+ });
202
+ });
203
+
204
+ describe('listSessions', () => {
205
+ // SESSION_DIR is computed from homedir() at module load, so each case
206
+ // re-imports session.js with 'os' mocked to point at a temp home.
207
+ async function withHome(home: string): Promise<typeof import('../browser/session.js')> {
208
+ vi.resetModules();
209
+ const actualOs = await vi.importActual<typeof import('os')>('os');
210
+ vi.doMock('os', () => ({ ...actualOs, default: actualOs, homedir: () => home }));
211
+ return import('../browser/session.js');
212
+ }
213
+
214
+ afterEach(() => {
215
+ vi.doUnmock('os');
216
+ vi.resetModules();
217
+ });
218
+
219
+ it('returns [] when the session directory does not exist', async () => {
220
+ const mod = await withHome(join(dir, 'no-such-home'));
221
+ await expect(mod.listSessions()).resolves.toEqual([]);
222
+ });
223
+
224
+ it('lists only .json files, with the extension stripped', async () => {
225
+ const sessionDir = join(dir, '.monomind', 'browser-sessions');
226
+ await mkdir(sessionDir, { recursive: true });
227
+ const { writeFile } = await import('fs/promises');
228
+ await writeFile(join(sessionDir, 'alpha.json'), '{}');
229
+ await writeFile(join(sessionDir, 'beta.json'), '{}');
230
+ await writeFile(join(sessionDir, 'notes.txt'), 'ignore me');
231
+
232
+ const mod = await withHome(dir);
233
+ await expect(mod.listSessions()).resolves.toEqual(['alpha', 'beta']);
234
+ });
235
+
236
+ it('saveSession writes into the session dir and loadSession reads it back', async () => {
237
+ const mod = await withHome(dir);
238
+ const saver = stubClient();
239
+ const filePath = await mod.saveSession(saver.client, 'S1', 'T1', 'my-session', 'https://x.test', 'T');
240
+ expect(filePath).toBe(join(dir, '.monomind', 'browser-sessions', 'my-session.json'));
241
+ expect((await stat(filePath)).mode & 0o777).toBe(0o600);
242
+ expect((await stat(join(dir, '.monomind', 'browser-sessions'))).mode & 0o777).toBe(0o700);
243
+
244
+ const loader = stubClient();
245
+ await expect(mod.loadSession(loader.client, 'S2', 'my-session')).resolves.toMatchObject({
246
+ cookies: COOKIES,
247
+ url: 'https://x.test',
248
+ });
249
+ await expect(mod.listSessions()).resolves.toEqual(['my-session']);
250
+ });
251
+ });