@monoes/monobrowse 1.0.2 → 1.0.4

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 (277) hide show
  1. package/dist/src/__tests__/batch-eval-parsing.test.d.ts +2 -0
  2. package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/batch-eval-parsing.test.js +47 -0
  4. package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -0
  5. package/dist/src/__tests__/output.test.d.ts +2 -0
  6. package/dist/src/__tests__/output.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/output.test.js +54 -0
  8. package/dist/src/__tests__/output.test.js.map +1 -0
  9. package/dist/src/__tests__/ref-cache.test.d.ts +2 -0
  10. package/dist/src/__tests__/ref-cache.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/ref-cache.test.js +145 -0
  12. package/dist/src/__tests__/ref-cache.test.js.map +1 -0
  13. package/dist/src/browser/action-builder/analyzer.d.ts +0 -0
  14. package/dist/src/browser/action-builder/analyzer.d.ts.map +0 -0
  15. package/dist/src/browser/action-builder/analyzer.js +0 -0
  16. package/dist/src/browser/action-builder/analyzer.js.map +1 -1
  17. package/dist/src/browser/action-builder/types.d.ts +0 -0
  18. package/dist/src/browser/action-builder/types.d.ts.map +0 -0
  19. package/dist/src/browser/action-builder/types.js +0 -0
  20. package/dist/src/browser/action-builder/types.js.map +0 -0
  21. package/dist/src/browser/actions.d.ts +1 -1
  22. package/dist/src/browser/actions.d.ts.map +1 -1
  23. package/dist/src/browser/actions.js +19 -2
  24. package/dist/src/browser/actions.js.map +1 -1
  25. package/dist/src/browser/adapters/gemini.d.ts +0 -0
  26. package/dist/src/browser/adapters/gemini.d.ts.map +0 -0
  27. package/dist/src/browser/adapters/gemini.js +0 -0
  28. package/dist/src/browser/adapters/gemini.js.map +0 -0
  29. package/dist/src/browser/adapters/google.d.ts +0 -0
  30. package/dist/src/browser/adapters/google.d.ts.map +0 -0
  31. package/dist/src/browser/adapters/google.js +0 -0
  32. package/dist/src/browser/adapters/google.js.map +0 -0
  33. package/dist/src/browser/adapters/index.d.ts +0 -0
  34. package/dist/src/browser/adapters/index.d.ts.map +0 -0
  35. package/dist/src/browser/adapters/index.js +0 -0
  36. package/dist/src/browser/adapters/index.js.map +0 -0
  37. package/dist/src/browser/adapters/instagram.d.ts +0 -0
  38. package/dist/src/browser/adapters/instagram.d.ts.map +0 -0
  39. package/dist/src/browser/adapters/instagram.js +0 -0
  40. package/dist/src/browser/adapters/instagram.js.map +0 -0
  41. package/dist/src/browser/adapters/linkedin.d.ts +0 -0
  42. package/dist/src/browser/adapters/linkedin.d.ts.map +0 -0
  43. package/dist/src/browser/adapters/linkedin.js +0 -0
  44. package/dist/src/browser/adapters/linkedin.js.map +0 -0
  45. package/dist/src/browser/adapters/microsoft.d.ts +0 -0
  46. package/dist/src/browser/adapters/microsoft.d.ts.map +0 -0
  47. package/dist/src/browser/adapters/microsoft.js +0 -0
  48. package/dist/src/browser/adapters/microsoft.js.map +0 -0
  49. package/dist/src/browser/adapters/x.d.ts +0 -0
  50. package/dist/src/browser/adapters/x.d.ts.map +0 -0
  51. package/dist/src/browser/adapters/x.js +0 -0
  52. package/dist/src/browser/adapters/x.js.map +0 -0
  53. package/dist/src/browser/batch.d.ts +0 -0
  54. package/dist/src/browser/batch.d.ts.map +0 -0
  55. package/dist/src/browser/batch.js +0 -0
  56. package/dist/src/browser/batch.js.map +0 -0
  57. package/dist/src/browser/browser.d.ts +10 -0
  58. package/dist/src/browser/browser.d.ts.map +1 -1
  59. package/dist/src/browser/browser.js +111 -3
  60. package/dist/src/browser/browser.js.map +1 -1
  61. package/dist/src/browser/cdp.d.ts +0 -0
  62. package/dist/src/browser/cdp.d.ts.map +1 -1
  63. package/dist/src/browser/cdp.js +1 -1
  64. package/dist/src/browser/cdp.js.map +1 -1
  65. package/dist/src/browser/console-log.d.ts +0 -0
  66. package/dist/src/browser/console-log.d.ts.map +0 -0
  67. package/dist/src/browser/console-log.js +0 -0
  68. package/dist/src/browser/console-log.js.map +0 -0
  69. package/dist/src/browser/dashboard/api-types.d.ts +0 -0
  70. package/dist/src/browser/dashboard/api-types.d.ts.map +0 -0
  71. package/dist/src/browser/dashboard/api-types.js +0 -0
  72. package/dist/src/browser/dashboard/api-types.js.map +0 -0
  73. package/dist/src/browser/dashboard/server.d.ts +0 -0
  74. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  75. package/dist/src/browser/dashboard/server.js +37 -2
  76. package/dist/src/browser/dashboard/server.js.map +1 -1
  77. package/dist/src/browser/dashboard/ui.html +1 -1
  78. package/dist/src/browser/dialog.d.ts +0 -0
  79. package/dist/src/browser/dialog.d.ts.map +0 -0
  80. package/dist/src/browser/dialog.js +0 -0
  81. package/dist/src/browser/dialog.js.map +0 -0
  82. package/dist/src/browser/emulation.d.ts +0 -0
  83. package/dist/src/browser/emulation.d.ts.map +0 -0
  84. package/dist/src/browser/emulation.js +0 -0
  85. package/dist/src/browser/emulation.js.map +0 -0
  86. package/dist/src/browser/find.d.ts +0 -0
  87. package/dist/src/browser/find.d.ts.map +0 -0
  88. package/dist/src/browser/find.js +0 -0
  89. package/dist/src/browser/find.js.map +1 -1
  90. package/dist/src/browser/har.d.ts +0 -0
  91. package/dist/src/browser/har.d.ts.map +0 -0
  92. package/dist/src/browser/har.js +0 -0
  93. package/dist/src/browser/har.js.map +0 -0
  94. package/dist/src/browser/index.d.ts +1 -0
  95. package/dist/src/browser/index.d.ts.map +1 -1
  96. package/dist/src/browser/index.js +1 -0
  97. package/dist/src/browser/index.js.map +1 -1
  98. package/dist/src/browser/network.d.ts +7 -0
  99. package/dist/src/browser/network.d.ts.map +1 -1
  100. package/dist/src/browser/network.js +10 -0
  101. package/dist/src/browser/network.js.map +1 -1
  102. package/dist/src/browser/pdf.d.ts +0 -0
  103. package/dist/src/browser/pdf.d.ts.map +0 -0
  104. package/dist/src/browser/pdf.js +0 -0
  105. package/dist/src/browser/pdf.js.map +1 -1
  106. package/dist/src/browser/profiler.d.ts +0 -0
  107. package/dist/src/browser/profiler.d.ts.map +0 -0
  108. package/dist/src/browser/profiler.js +0 -0
  109. package/dist/src/browser/profiler.js.map +1 -1
  110. package/dist/src/browser/record.d.ts +3 -0
  111. package/dist/src/browser/record.d.ts.map +1 -1
  112. package/dist/src/browser/record.js +37 -5
  113. package/dist/src/browser/record.js.map +1 -1
  114. package/dist/src/browser/ref-cache.d.ts +41 -0
  115. package/dist/src/browser/ref-cache.d.ts.map +1 -0
  116. package/dist/src/browser/ref-cache.js +120 -0
  117. package/dist/src/browser/ref-cache.js.map +1 -0
  118. package/dist/src/browser/screenshot.d.ts +0 -0
  119. package/dist/src/browser/screenshot.d.ts.map +0 -0
  120. package/dist/src/browser/screenshot.js +0 -0
  121. package/dist/src/browser/screenshot.js.map +1 -1
  122. package/dist/src/browser/session.d.ts +0 -0
  123. package/dist/src/browser/session.d.ts.map +1 -1
  124. package/dist/src/browser/session.js +12 -7
  125. package/dist/src/browser/session.js.map +1 -1
  126. package/dist/src/browser/snapshot.d.ts +0 -0
  127. package/dist/src/browser/snapshot.d.ts.map +0 -0
  128. package/dist/src/browser/snapshot.js +0 -0
  129. package/dist/src/browser/snapshot.js.map +1 -1
  130. package/dist/src/browser/storage.d.ts +0 -0
  131. package/dist/src/browser/storage.d.ts.map +0 -0
  132. package/dist/src/browser/storage.js +0 -0
  133. package/dist/src/browser/storage.js.map +0 -0
  134. package/dist/src/browser/tabs.d.ts +1 -1
  135. package/dist/src/browser/tabs.d.ts.map +1 -1
  136. package/dist/src/browser/tabs.js +20 -4
  137. package/dist/src/browser/tabs.js.map +1 -1
  138. package/dist/src/browser/trace.d.ts +0 -0
  139. package/dist/src/browser/trace.d.ts.map +0 -0
  140. package/dist/src/browser/trace.js +0 -0
  141. package/dist/src/browser/trace.js.map +1 -1
  142. package/dist/src/browser/types.d.ts +0 -0
  143. package/dist/src/browser/types.d.ts.map +0 -0
  144. package/dist/src/browser/types.js +0 -0
  145. package/dist/src/browser/types.js.map +0 -0
  146. package/dist/src/browser/vitals.d.ts +0 -0
  147. package/dist/src/browser/vitals.d.ts.map +0 -0
  148. package/dist/src/browser/vitals.js +0 -0
  149. package/dist/src/browser/vitals.js.map +0 -0
  150. package/dist/src/browser/wait.d.ts +0 -0
  151. package/dist/src/browser/wait.d.ts.map +0 -0
  152. package/dist/src/browser/wait.js +0 -0
  153. package/dist/src/browser/wait.js.map +0 -0
  154. package/dist/src/cli/action.d.ts +0 -0
  155. package/dist/src/cli/action.d.ts.map +1 -1
  156. package/dist/src/cli/action.js +87 -2
  157. package/dist/src/cli/action.js.map +1 -1
  158. package/dist/src/cli/commands.d.ts +22 -0
  159. package/dist/src/cli/commands.d.ts.map +1 -1
  160. package/dist/src/cli/commands.js +276 -28
  161. package/dist/src/cli/commands.js.map +1 -1
  162. package/dist/src/cli/output.d.ts +0 -0
  163. package/dist/src/cli/output.d.ts.map +1 -1
  164. package/dist/src/cli/output.js +0 -0
  165. package/dist/src/cli/output.js.map +0 -0
  166. package/dist/src/cli/platform.d.ts +0 -0
  167. package/dist/src/cli/platform.d.ts.map +0 -0
  168. package/dist/src/cli/platform.js +0 -0
  169. package/dist/src/cli/platform.js.map +0 -0
  170. package/dist/src/cli/types.d.ts +0 -0
  171. package/dist/src/cli/types.d.ts.map +0 -0
  172. package/dist/src/cli/types.js +0 -0
  173. package/dist/src/cli/types.js.map +0 -0
  174. package/dist/src/cli.d.ts +0 -0
  175. package/dist/src/cli.d.ts.map +0 -0
  176. package/dist/src/cli.js.map +1 -1
  177. package/dist/src/index.d.ts +0 -0
  178. package/dist/src/index.d.ts.map +0 -0
  179. package/dist/src/index.js +0 -0
  180. package/dist/src/index.js.map +0 -0
  181. package/dist/tsconfig.tsbuildinfo +1 -1
  182. package/package.json +2 -2
  183. package/src/__tests__/batch-eval-parsing.test.ts +52 -0
  184. package/src/__tests__/output.test.ts +62 -0
  185. package/src/__tests__/ref-cache.test.ts +175 -0
  186. package/src/browser/action-builder/analyzer.ts +0 -0
  187. package/src/browser/action-builder/types.ts +0 -0
  188. package/src/browser/actions.ts +22 -2
  189. package/src/browser/adapters/gemini.ts +0 -0
  190. package/src/browser/adapters/google.ts +0 -0
  191. package/src/browser/adapters/index.ts +0 -0
  192. package/src/browser/adapters/instagram.ts +0 -0
  193. package/src/browser/adapters/linkedin.ts +0 -0
  194. package/src/browser/adapters/microsoft.ts +0 -0
  195. package/src/browser/adapters/x.ts +0 -0
  196. package/src/browser/batch.ts +0 -0
  197. package/src/browser/browser.ts +115 -2
  198. package/src/browser/cdp.ts +1 -1
  199. package/src/browser/console-log.ts +0 -0
  200. package/src/browser/dashboard/api-types.ts +0 -0
  201. package/src/browser/dashboard/server.ts +37 -2
  202. package/src/browser/dashboard/ui.html +1 -1
  203. package/src/browser/dialog.ts +0 -0
  204. package/src/browser/emulation.ts +0 -0
  205. package/src/browser/find.ts +0 -0
  206. package/src/browser/har.ts +0 -0
  207. package/src/browser/index.ts +1 -0
  208. package/src/browser/network.ts +11 -0
  209. package/src/browser/pdf.ts +0 -0
  210. package/src/browser/profiler.ts +0 -0
  211. package/src/browser/record.ts +43 -6
  212. package/src/browser/ref-cache.ts +141 -0
  213. package/src/browser/screenshot.ts +0 -0
  214. package/src/browser/session.ts +13 -7
  215. package/src/browser/snapshot.ts +0 -0
  216. package/src/browser/storage.ts +0 -0
  217. package/src/browser/tabs.ts +36 -7
  218. package/src/browser/trace.ts +0 -0
  219. package/src/browser/types.ts +0 -0
  220. package/src/browser/vitals.ts +0 -0
  221. package/src/browser/wait.ts +0 -0
  222. package/src/cli/action.ts +85 -3
  223. package/src/cli/commands.ts +272 -28
  224. package/src/cli/output.ts +0 -0
  225. package/src/cli/platform.ts +0 -0
  226. package/src/cli/types.ts +0 -0
  227. package/src/cli.ts +0 -0
  228. package/src/index.ts +0 -0
  229. package/dist/package.json +0 -71
  230. package/dist/src/browser/playbook/browser-handlers.d.ts +0 -3
  231. package/dist/src/browser/playbook/browser-handlers.d.ts.map +0 -1
  232. package/dist/src/browser/playbook/browser-handlers.js +0 -377
  233. package/dist/src/browser/playbook/browser-handlers.js.map +0 -1
  234. package/dist/src/browser/playbook/builtin-handlers.d.ts +0 -3
  235. package/dist/src/browser/playbook/builtin-handlers.d.ts.map +0 -1
  236. package/dist/src/browser/playbook/builtin-handlers.js +0 -364
  237. package/dist/src/browser/playbook/builtin-handlers.js.map +0 -1
  238. package/dist/src/browser/playbook/index.d.ts +0 -3
  239. package/dist/src/browser/playbook/index.d.ts.map +0 -1
  240. package/dist/src/browser/playbook/index.js +0 -3
  241. package/dist/src/browser/playbook/index.js.map +0 -1
  242. package/dist/src/browser/playbook/store.d.ts +0 -8
  243. package/dist/src/browser/playbook/store.d.ts.map +0 -1
  244. package/dist/src/browser/playbook/store.js +0 -87
  245. package/dist/src/browser/playbook/store.js.map +0 -1
  246. package/dist/src/browser/workflow/builtin-handlers.d.ts +0 -3
  247. package/dist/src/browser/workflow/builtin-handlers.d.ts.map +0 -1
  248. package/dist/src/browser/workflow/builtin-handlers.js +0 -343
  249. package/dist/src/browser/workflow/builtin-handlers.js.map +0 -1
  250. package/dist/src/browser/workflow/engine.d.ts +0 -10
  251. package/dist/src/browser/workflow/engine.d.ts.map +0 -1
  252. package/dist/src/browser/workflow/engine.js +0 -174
  253. package/dist/src/browser/workflow/engine.js.map +0 -1
  254. package/dist/src/browser/workflow/expression.d.ts +0 -4
  255. package/dist/src/browser/workflow/expression.d.ts.map +0 -1
  256. package/dist/src/browser/workflow/expression.js +0 -89
  257. package/dist/src/browser/workflow/expression.js.map +0 -1
  258. package/dist/src/browser/workflow/index.d.ts +0 -6
  259. package/dist/src/browser/workflow/index.d.ts.map +0 -1
  260. package/dist/src/browser/workflow/index.js +0 -6
  261. package/dist/src/browser/workflow/index.js.map +0 -1
  262. package/dist/src/browser/workflow/store.d.ts +0 -8
  263. package/dist/src/browser/workflow/store.d.ts.map +0 -1
  264. package/dist/src/browser/workflow/store.js +0 -87
  265. package/dist/src/browser/workflow/store.js.map +0 -1
  266. package/dist/src/browser/workflow/types.d.ts +0 -62
  267. package/dist/src/browser/workflow/types.d.ts.map +0 -1
  268. package/dist/src/browser/workflow/types.js +0 -2
  269. package/dist/src/browser/workflow/types.js.map +0 -1
  270. package/dist/src/cli/playbook.d.ts +0 -3
  271. package/dist/src/cli/playbook.d.ts.map +0 -1
  272. package/dist/src/cli/playbook.js +0 -265
  273. package/dist/src/cli/playbook.js.map +0 -1
  274. package/dist/src/cli/workflow.d.ts +0 -2
  275. package/dist/src/cli/workflow.d.ts.map +0 -1
  276. package/dist/src/cli/workflow.js +0 -3
  277. package/dist/src/cli/workflow.js.map +0 -1
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseBatchCommandLine } from '../cli/commands.js';
3
+
4
+ describe('parseBatchCommandLine — eval expressions inside a batch command string', () => {
5
+ it('does not strip string-literal quotes from a raw JS expression passed to eval', () => {
6
+ // Regression: tokenizeBatchCommand's shell-style quote-stripping (correct for
7
+ // space-separated args like `fill @e1 "some value"`) previously ran on eval's
8
+ // expression too, silently deleting the expression's OWN string-literal quotes
9
+ // and turning `document.querySelector('a')` into `document.querySelector(a)` —
10
+ // a reference to an undefined identifier instead of a string literal.
11
+ const input = "eval document.querySelector('meta[name=mm-token]').content";
12
+ const { subName, subArgs } = parseBatchCommandLine(input);
13
+ expect(subName).toBe('eval');
14
+ expect(subArgs).toEqual(["document.querySelector('meta[name=mm-token]').content"]);
15
+ });
16
+
17
+ it('preserves double-quoted string literals in the expression too', () => {
18
+ const input = 'eval document.querySelector("body").tagName';
19
+ const { subName, subArgs } = parseBatchCommandLine(input);
20
+ expect(subName).toBe('eval');
21
+ expect(subArgs).toEqual(['document.querySelector("body").tagName']);
22
+ });
23
+
24
+ it('preserves an expression with multiple quoted segments and internal spaces', () => {
25
+ const input = "eval [...document.querySelectorAll('li')].map(x => x.textContent.trim())";
26
+ const { subName, subArgs } = parseBatchCommandLine(input);
27
+ expect(subName).toBe('eval');
28
+ expect(subArgs).toEqual(["[...document.querySelectorAll('li')].map(x => x.textContent.trim())"]);
29
+ });
30
+
31
+ it('still recognizes --json and --max-output flags placed before the expression', () => {
32
+ const input = "eval --json --max-output 100 document.querySelector('a').href";
33
+ const { subName, subArgs, flags } = parseBatchCommandLine(input);
34
+ expect(subName).toBe('eval');
35
+ expect(flags).toMatchObject({ json: true, 'max-output': 100 });
36
+ expect(subArgs).toEqual(["document.querySelector('a').href"]);
37
+ });
38
+
39
+ it('leaves non-eval commands tokenized exactly as before (no behavior change)', () => {
40
+ const input = 'fill @e1 "some value with spaces"';
41
+ const { subName, subArgs } = parseBatchCommandLine(input);
42
+ expect(subName).toBe('fill');
43
+ expect(subArgs).toEqual(['@e1', 'some value with spaces']);
44
+ });
45
+
46
+ it('leaves a simple multi-token command tokenized exactly as before', () => {
47
+ const input = 'open https://example.com --port 9333';
48
+ const { subName, subArgs } = parseBatchCommandLine(input);
49
+ expect(subName).toBe('open');
50
+ expect(subArgs).toEqual(['https://example.com', '--port', '9333']);
51
+ });
52
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ // The module exports a singleton `output` which reads process.stdout.isTTY at
4
+ // construction time. We need to control that, so we reset modules per test.
5
+
6
+ async function importOutput(isTTY: boolean) {
7
+ vi.resetModules();
8
+ // Stub isTTY before the module loads
9
+ Object.defineProperty(process.stdout, 'isTTY', { value: isTTY, configurable: true });
10
+ return import('../cli/output.js');
11
+ }
12
+
13
+ describe('OutputFormatter', () => {
14
+ beforeEach(() => {
15
+ vi.spyOn(console, 'log').mockImplementation(() => {});
16
+ vi.spyOn(console, 'error').mockImplementation(() => {});
17
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
18
+ });
19
+
20
+ it('printSuccess calls console.log with the message', async () => {
21
+ const { output } = await importOutput(false);
22
+ output.printSuccess('done');
23
+ expect(console.log).toHaveBeenCalledOnce();
24
+ expect((console.log as any).mock.calls[0][0]).toContain('done');
25
+ });
26
+
27
+ it('printError calls console.error with the message and optional details', async () => {
28
+ const { output } = await importOutput(false);
29
+ output.printError('fail', 'reason');
30
+ expect(console.error).toHaveBeenCalledTimes(2);
31
+ expect((console.error as any).mock.calls[0][0]).toContain('fail');
32
+ expect((console.error as any).mock.calls[1][0]).toContain('reason');
33
+ });
34
+
35
+ it('printWarning calls console.warn', async () => {
36
+ const { output } = await importOutput(false);
37
+ output.printWarning('careful');
38
+ expect(console.warn).toHaveBeenCalledOnce();
39
+ expect((console.warn as any).mock.calls[0][0]).toContain('careful');
40
+ });
41
+
42
+ it('printInfo calls console.log with info prefix', async () => {
43
+ const { output } = await importOutput(false);
44
+ output.printInfo('note');
45
+ expect(console.log).toHaveBeenCalledOnce();
46
+ expect((console.log as any).mock.calls[0][0]).toContain('note');
47
+ });
48
+
49
+ it('with color disabled (non-TTY), output contains no ANSI escape codes', async () => {
50
+ const { output } = await importOutput(false);
51
+ output.printSuccess('plain');
52
+ const logged = (console.log as any).mock.calls[0][0] as string;
53
+ expect(logged).not.toContain('\x1b[');
54
+ });
55
+
56
+ it('with color enabled (TTY), output contains ANSI escape codes', async () => {
57
+ const { output } = await importOutput(true);
58
+ output.printSuccess('colored');
59
+ const logged = (console.log as any).mock.calls[0][0] as string;
60
+ expect(logged).toContain('\x1b[');
61
+ });
62
+ });
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtemp, readFile, rm, stat } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+
6
+ // We need to mock process.cwd() before importing ref-cache so CACHE_DIR
7
+ // resolves to our temp directory.
8
+ let tempDir: string;
9
+
10
+ beforeEach(async () => {
11
+ tempDir = await mkdtemp(join(tmpdir(), 'monobrowse-test-'));
12
+ vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
13
+ });
14
+
15
+ afterEach(async () => {
16
+ vi.restoreAllMocks();
17
+ await rm(tempDir, { recursive: true, force: true });
18
+ });
19
+
20
+ // Dynamic import so each test picks up the mocked cwd.
21
+ // We must bust the module cache between tests.
22
+ async function importRefCache() {
23
+ // Vitest module cache means re-import returns the same module.
24
+ // Since CACHE_DIR is set at module-load time, we need resetModules.
25
+ vi.resetModules();
26
+ return import('../browser/ref-cache.js');
27
+ }
28
+
29
+ function makeRefs(count = 2): Map<string, { ref: string; role: string; name: string; nodeId: number }> {
30
+ const map = new Map();
31
+ for (let i = 1; i <= count; i++) {
32
+ map.set(`ref${i}`, { ref: `ref${i}`, role: 'button', name: `Button ${i}`, nodeId: i });
33
+ }
34
+ return map;
35
+ }
36
+
37
+ describe('ref-cache', () => {
38
+ it('round-trip: save then load returns equivalent data', async () => {
39
+ const mod = await importRefCache();
40
+ const refs = makeRefs(2);
41
+
42
+ await mod.saveRefCache('target-1', 'https://example.com', refs as any);
43
+ const loaded = await mod.loadRefCache('target-1');
44
+
45
+ expect(loaded).not.toBeNull();
46
+ expect(loaded!.url).toBe('https://example.com');
47
+ expect(loaded!.refs.size).toBe(2);
48
+ expect(loaded!.refs.get('ref1')!.role).toBe('button');
49
+ expect(loaded!.refs.get('ref2')!.name).toBe('Button 2');
50
+ expect(typeof loaded!.savedAt).toBe('number');
51
+ expect(typeof loaded!.ageMs).toBe('number');
52
+ });
53
+
54
+ it('targetId mismatch returns null', async () => {
55
+ const mod = await importRefCache();
56
+ const refs = makeRefs(1);
57
+
58
+ await mod.saveRefCache('target-A', 'https://a.com', refs as any);
59
+ const loaded = await mod.loadRefCache('target-B');
60
+
61
+ expect(loaded).toBeNull();
62
+ });
63
+
64
+ it('corrupt JSON file returns null', async () => {
65
+ const mod = await importRefCache();
66
+ // First save a valid cache to create the directory structure
67
+ await mod.saveRefCache('t', 'https://x.com', makeRefs(1) as any);
68
+
69
+ // Now overwrite the file with invalid JSON
70
+ const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot.json');
71
+ const { writeFile: wf } = await import('fs/promises');
72
+ await wf(cacheFile, '{{{not valid json!!!');
73
+
74
+ const loaded = await mod.loadRefCache('t');
75
+ expect(loaded).toBeNull();
76
+ });
77
+
78
+ it('clearRefCache removes the cache file', async () => {
79
+ const mod = await importRefCache();
80
+ await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
81
+
82
+ const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot.json');
83
+ // Verify file exists
84
+ const before = await stat(cacheFile).catch(() => null);
85
+ expect(before).not.toBeNull();
86
+
87
+ await mod.clearRefCache();
88
+
89
+ const after = await stat(cacheFile).catch(() => null);
90
+ expect(after).toBeNull();
91
+ });
92
+
93
+ it('load after clear returns null', async () => {
94
+ const mod = await importRefCache();
95
+ await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
96
+ await mod.clearRefCache();
97
+
98
+ const loaded = await mod.loadRefCache('t1');
99
+ expect(loaded).toBeNull();
100
+ });
101
+
102
+ it('freshly saved cache is not stale', async () => {
103
+ const mod = await importRefCache();
104
+ await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
105
+
106
+ const loaded = await mod.loadRefCache('t1');
107
+ expect(loaded).not.toBeNull();
108
+ expect(loaded!.stale).toBe(false);
109
+ expect(loaded!.ageMs).toBeLessThan(mod.REF_CACHE_STALE_MS);
110
+ });
111
+
112
+ it('cache older than REF_CACHE_STALE_MS is flagged stale', async () => {
113
+ const mod = await importRefCache();
114
+ await mod.saveRefCache('t1', 'https://x.com', makeRefs(1) as any);
115
+
116
+ // Manually backdate the savedAt timestamp
117
+ const cacheFile = join(tempDir, '.monomind', 'monobrowse', 'ax-snapshot.json');
118
+ const raw = JSON.parse(await readFile(cacheFile, 'utf8'));
119
+ raw.savedAt = Date.now() - mod.REF_CACHE_STALE_MS - 5000;
120
+ const { writeFile: wf } = await import('fs/promises');
121
+ await wf(cacheFile, JSON.stringify(raw));
122
+
123
+ const loaded = await mod.loadRefCache('t1');
124
+ expect(loaded).not.toBeNull();
125
+ expect(loaded!.stale).toBe(true);
126
+ expect(loaded!.ageMs).toBeGreaterThan(mod.REF_CACHE_STALE_MS);
127
+ });
128
+
129
+ it('loading with no cache file returns null (no crash)', async () => {
130
+ const mod = await importRefCache();
131
+ const loaded = await mod.loadRefCache('nonexistent');
132
+ expect(loaded).toBeNull();
133
+ });
134
+ });
135
+
136
+ describe('active-port persistence', () => {
137
+ it('round-trip: save then load returns the port; launched defaults true', async () => {
138
+ const mod = await importRefCache();
139
+ await mod.saveActivePort(9333);
140
+ expect(await mod.loadActivePort()).toBe(9333);
141
+ expect(await mod.loadActivePortInfo()).toEqual({ port: 9333, launched: true });
142
+ });
143
+
144
+ it('connect provenance: launched:false survives the round-trip', async () => {
145
+ const mod = await importRefCache();
146
+ await mod.saveActivePort(9229, { launched: false });
147
+ expect(await mod.loadActivePortInfo()).toEqual({ port: 9229, launched: false });
148
+ });
149
+
150
+ it('clear removes the file; load returns null afterwards', async () => {
151
+ const mod = await importRefCache();
152
+ await mod.saveActivePort(9333);
153
+ await mod.clearActivePort();
154
+ expect(await mod.loadActivePort()).toBeNull();
155
+ expect(await mod.loadActivePortInfo()).toBeNull();
156
+ });
157
+
158
+ it('clear with no file resolves without throwing', async () => {
159
+ const mod = await importRefCache();
160
+ await mod.clearActivePort();
161
+ await expect(mod.clearActivePort()).resolves.toBeUndefined();
162
+ });
163
+
164
+ it('rejects out-of-range or non-integer persisted ports', async () => {
165
+ const mod = await importRefCache();
166
+ const { writeFile, mkdir } = await import('fs/promises');
167
+ const dir = join(process.cwd(), '.monomind', 'monobrowse');
168
+ await mkdir(dir, { recursive: true });
169
+ for (const bad of [80, 70000, 1.5, '9222', null]) {
170
+ await writeFile(join(dir, 'active-port.json'), JSON.stringify({ port: bad }));
171
+ expect(await mod.loadActivePort()).toBeNull();
172
+ expect(await mod.loadActivePortInfo()).toBeNull();
173
+ }
174
+ });
175
+ });
File without changes
File without changes
@@ -496,12 +496,22 @@ export async function removeInitScript(client: CdpClient, sessionId: string, ide
496
496
  await client.send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, sessionId);
497
497
  }
498
498
 
499
+ // Default cap on how long an evaluateJs() call waits for Runtime.evaluate to
500
+ // resolve. Because this always passes `awaitPromise: true`, an expression
501
+ // like `new Promise(() => {})` never settles — the underlying CdpClient.send()
502
+ // has no timeout of its own (the CDP_RESPONSE_SIZE_LIMIT in cdp.ts only guards
503
+ // the HTTP /json/* endpoints, not WebSocket command responses), so without
504
+ // this the caller (e.g. the `eval` CLI command) would hang forever with
505
+ // nothing to kill it.
506
+ const DEFAULT_EVAL_TIMEOUT_MS = 30_000;
507
+
499
508
  export async function evaluateJs(
500
509
  client: CdpClient,
501
510
  sessionId: string,
502
- expression: string
511
+ expression: string,
512
+ timeoutMs: number = DEFAULT_EVAL_TIMEOUT_MS
503
513
  ): Promise<unknown> {
504
- const result = await client.send<{
514
+ const evalPromise = client.send<{
505
515
  result: { value?: unknown; type: string; description?: string };
506
516
  exceptionDetails?: { text: string; exception?: { description?: string } };
507
517
  }>('Runtime.evaluate', {
@@ -510,6 +520,16 @@ export async function evaluateJs(
510
520
  awaitPromise: true,
511
521
  }, sessionId);
512
522
 
523
+ const result = await (timeoutMs > 0
524
+ ? Promise.race([
525
+ evalPromise,
526
+ new Promise<never>((_, reject) => {
527
+ const t = setTimeout(() => reject(new Error(`JS evaluation timed out after ${timeoutMs}ms`)), timeoutMs);
528
+ t.unref?.();
529
+ }),
530
+ ])
531
+ : evalPromise);
532
+
513
533
  if (result.exceptionDetails) {
514
534
  throw new Error(`JS evaluation error: ${result.exceptionDetails.exception?.description ?? result.exceptionDetails.text}`);
515
535
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -2,6 +2,7 @@ import { spawn, execSync } from 'child_process';
2
2
  import { existsSync } from 'fs';
3
3
  import { tmpdir } from 'os';
4
4
  import { join } from 'path';
5
+ import { connect } from 'net';
5
6
  import { CdpClient, fetchTargets, fetchNewTarget } from './cdp.js';
6
7
  import type { BrowserConfig, CdpTarget } from './types.js';
7
8
  import { CHROME_EXECUTABLES } from './types.js';
@@ -11,6 +12,14 @@ import { setupDialogAutoHandling } from './dialog.js';
11
12
  const DEFAULT_PORT = 9222;
12
13
  const LAUNCH_TIMEOUT = 10_000;
13
14
  const POLL_INTERVAL = 200;
15
+ const BROWSER_CLOSE_TIMEOUT_MS = 3000;
16
+
17
+ // Tracks the PID of Chrome instances *we* spawned, keyed by CDP port, so
18
+ // closeBrowser() has a kill fallback when the graceful `Browser.close` CDP
19
+ // command fails or times out (e.g. a hung renderer). Ports we merely attached
20
+ // to (already-running browser) are never recorded here — we only ever kill
21
+ // processes we launched ourselves.
22
+ const launchedPids = new Map<number, number>();
14
23
 
15
24
  function findChrome(executablePath?: string): string {
16
25
  if (executablePath) {
@@ -42,6 +51,38 @@ export async function isPortOpen(port: number): Promise<boolean> {
42
51
  }
43
52
  }
44
53
 
54
+ /**
55
+ * Confirm the CDP endpoint on `port` actually identifies as Chrome/Chromium
56
+ * via `/json/version`'s `Browser` field, rather than assuming any CDP-speaking
57
+ * responder is "ours". Reduces (does not eliminate) the risk of silently
58
+ * attaching to an unrelated real browser that happens to be listening there.
59
+ */
60
+ async function isChromeIdentity(port: number): Promise<boolean> {
61
+ try {
62
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
63
+ if (!res.ok) return false;
64
+ const info = (await res.json()) as { Browser?: string };
65
+ return typeof info.Browser === 'string' && /chrom(e|ium)/i.test(info.Browser);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /** Raw TCP connect probe — distinguishes "nothing listening" from "something listening that isn't CDP". */
72
+ function isTcpPortOpen(port: number, timeoutMs = 1000): Promise<boolean> {
73
+ return new Promise((resolve) => {
74
+ const socket = connect({ host: '127.0.0.1', port, timeout: timeoutMs });
75
+ const done = (result: boolean) => {
76
+ socket.removeAllListeners();
77
+ socket.destroy();
78
+ resolve(result);
79
+ };
80
+ socket.once('connect', () => done(true));
81
+ socket.once('timeout', () => done(false));
82
+ socket.once('error', () => done(false));
83
+ });
84
+ }
85
+
45
86
  export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
46
87
  const rawPort = config.port ?? DEFAULT_PORT;
47
88
  // Validate port is in a safe range for localhost CDP debugging
@@ -51,7 +92,16 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
51
92
  const port = rawPort;
52
93
 
53
94
  if (await isPortOpen(port)) {
54
- return port;
95
+ // Something CDP-speaking is already there — verify it's actually Chrome/Chromium
96
+ // before attaching, so we don't silently take over an unrelated real browser
97
+ // (e.g. the user's own personal Chrome) that happens to be on this port.
98
+ if (await isChromeIdentity(port)) {
99
+ return port;
100
+ }
101
+ throw new Error(
102
+ `Port ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
103
+ `Refusing to attach — pass a different port or free port ${port}.`
104
+ );
55
105
  }
56
106
 
57
107
  const chromePath = findChrome(config.executablePath);
@@ -89,11 +139,28 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
89
139
  stdio: 'ignore',
90
140
  });
91
141
  child.unref();
142
+ if (child.pid) launchedPids.set(port, child.pid);
92
143
 
93
144
  const deadline = Date.now() + LAUNCH_TIMEOUT;
94
145
  while (Date.now() < deadline) {
95
146
  await sleep(POLL_INTERVAL);
96
- if (await isPortOpen(port)) return port;
147
+ if (await isPortOpen(port)) {
148
+ if (await isChromeIdentity(port)) return port;
149
+ throw new Error(
150
+ `Port ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
151
+ `Refusing to attach — pass a different port or free port ${port}.`
152
+ );
153
+ }
154
+ }
155
+
156
+ // Timed out waiting for our Chrome to come up on the port. Distinguish
157
+ // "nothing is listening" (real launch failure) from "something non-CDP is
158
+ // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
159
+ if (await isTcpPortOpen(port)) {
160
+ throw new Error(
161
+ `Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${LAUNCH_TIMEOUT}ms). ` +
162
+ `Free the port or pass a different one.`
163
+ );
97
164
  }
98
165
 
99
166
  throw new Error(`Chrome failed to start on port ${port} within ${LAUNCH_TIMEOUT}ms`);
@@ -140,6 +207,52 @@ export async function connectToTarget(port: number, targetId?: string): Promise<
140
207
  return { client, target, sessionId };
141
208
  }
142
209
 
210
+ /**
211
+ * Cleanly terminate a Chrome/Chromium instance we launched on `port`.
212
+ * Sends the `Browser.close` CDP command (the correct graceful shutdown —
213
+ * closes all tabs and exits the process cleanly) over `client`'s connection.
214
+ * If that command fails, times out, or the connection is already gone,
215
+ * falls back to killing the tracked PID directly so a headed/interactive
216
+ * browser window (e.g. one spawned for a login/CAPTCHA flow) never lingers
217
+ * as a visible, authenticated, still-debuggable orphan process.
218
+ */
219
+ export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
220
+ let gracefullyClosed = false;
221
+ try {
222
+ await Promise.race([
223
+ client.send('Browser.close', {}),
224
+ new Promise<never>((_, reject) => {
225
+ const t = setTimeout(() => reject(new Error('Browser.close timed out')), BROWSER_CLOSE_TIMEOUT_MS);
226
+ t.unref?.();
227
+ }),
228
+ ]);
229
+ gracefullyClosed = true;
230
+ } catch {
231
+ // fall through to PID-kill fallback below
232
+ }
233
+
234
+ const pid = launchedPids.get(port);
235
+ if (pid === undefined) return; // not a process we launched — nothing to kill
236
+ launchedPids.delete(port);
237
+
238
+ if (!gracefullyClosed) {
239
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
240
+ return;
241
+ }
242
+
243
+ // Browser.close was acknowledged — give the process a moment to exit on
244
+ // its own, then verify and force-kill as a safety net in case it hung.
245
+ const t = setTimeout(() => {
246
+ try {
247
+ process.kill(pid, 0); // throws if the process is already gone
248
+ process.kill(pid, 'SIGKILL');
249
+ } catch {
250
+ /* already exited — expected path */
251
+ }
252
+ }, 1000);
253
+ t.unref?.();
254
+ }
255
+
143
256
  export async function openUrl(client: CdpClient, sessionId: string, url: string): Promise<void> {
144
257
  // Cap to 2 MB to prevent OOM in CDP message serializer (e.g. data: URI attacks)
145
258
  if (url.length > 2_097_152) throw new Error('URL exceeds 2 MB limit');
@@ -151,7 +151,7 @@ export async function fetchTargets(port: number): Promise<CdpTarget[]> {
151
151
 
152
152
  export async function fetchNewTarget(port: number, url: string): Promise<CdpTarget> {
153
153
  // Chrome v92+ requires PUT for /json/new; GET returns 405. URL must be encoded.
154
- const res = await fetch(`http://127.0.0.1:${port}/json/new?${url}`, { method: 'PUT' });
154
+ const res = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
155
155
  if (!res.ok) throw new Error(`Failed to create target: ${res.statusText}`);
156
156
  return readCdpJson(res) as Promise<CdpTarget>;
157
157
  }
File without changes
File without changes
@@ -1,10 +1,11 @@
1
1
  import { createServer, IncomingMessage, ServerResponse } from 'node:http';
2
- import { readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { homedir } from 'node:os';
7
7
  import { createRequire } from 'node:module';
8
+ import { randomBytes } from 'node:crypto';
8
9
 
9
10
  export interface StepEvent {
10
11
  type: string;
@@ -57,6 +58,21 @@ let instance: DashboardServer | null = null;
57
58
  export function startDashboard(port = DEFAULT_PORT): DashboardServer {
58
59
  if (instance) return instance;
59
60
 
61
+ // ── Security: per-process auth credential for mutating (non-GET) requests ──
62
+ // Generated once per server start and written to a well-known location so
63
+ // trusted local callers (CLI) can read it and pass it back via an auth
64
+ // header or query param on non-GET routes. Mirrors the pattern used by the
65
+ // main CLI dashboard (packages/@monomind/cli/src/ui/server.ts).
66
+ const dashboardAuthValue = randomBytes(24).toString('hex');
67
+ try {
68
+ const authFileDir = join(homedir(), '.monomind');
69
+ mkdirSync(authFileDir, { recursive: true });
70
+ writeFileSync(join(authFileDir, 'monobrowse-dashboard-token'), dashboardAuthValue, { mode: 0o600 });
71
+ } catch {
72
+ // best-effort: if we can't persist the token, mutating routes still
73
+ // enforce it in-memory — trusted callers just won't be able to read it.
74
+ }
75
+
60
76
  const runHistory: RunRecord[] = [];
61
77
  const stopRequests = new Set<string>();
62
78
  /** Map from client (WebSocket or SSE response) to the subscribed project dir,
@@ -79,6 +95,13 @@ export function startDashboard(port = DEFAULT_PORT): DashboardServer {
79
95
  } catch {
80
96
  uiHtml = `<!DOCTYPE html><html><head><title>monobrowse dashboard</title></head><body style="background:#0f0f1a;color:#ccc;font-family:system-ui;padding:20px"><h1>monobrowse dashboard</h1><p>Dashboard UI not found. Run the build to include ui.html.</p><script>let _retryDelay=1000;function connectSSE(){const es=new EventSource('/events');es.onmessage=e=>{console.log(JSON.parse(e.data));_retryDelay=1000;};es.onerror=()=>{es.close();setTimeout(connectSSE,Math.min(_retryDelay*=2,30000));};};connectSSE();</script></body></html>`;
81
97
  }
98
+ // Hand the per-process auth token to the served page so its own JS (e.g. the
99
+ // stop button) can pass it back on mutating requests. Injected once at
100
+ // startup — the loopback-only page is the sole reader.
101
+ const tokenScript = `<script>window.__MONOBROWSE_TOKEN__=${JSON.stringify(dashboardAuthValue)};</script>`;
102
+ uiHtml = uiHtml.includes('</head>')
103
+ ? uiHtml.replace('</head>', `${tokenScript}</head>`)
104
+ : tokenScript + uiHtml;
82
105
 
83
106
  const server = createServer(async (req, res) => {
84
107
  const url = req.url ?? '/';
@@ -100,8 +123,20 @@ export function startDashboard(port = DEFAULT_PORT): DashboardServer {
100
123
  return;
101
124
  }
102
125
 
126
+ // ── Security: require a matching auth token on all mutating routes ──────
127
+ // Binding to loopback only stops remote attackers, not a malicious page
128
+ // open in the user's own browser (DNS rebinding / same-origin fetch to
129
+ // 127.0.0.1). Require the per-process token via header or query param on
130
+ // every non-GET/HEAD route; GET/SSE reads stay open on the loopback baseline.
103
131
  if (url.startsWith('/stop/') && req.method === 'POST') {
104
- const runId = url.slice(6);
132
+ const parsedAuth = new URL(url, 'http://localhost');
133
+ const suppliedAuth = req.headers['x-monobrowse-token'] || parsedAuth.searchParams.get('token') || '';
134
+ if (suppliedAuth !== dashboardAuthValue) {
135
+ res.writeHead(401, { 'Content-Type': 'application/json' });
136
+ res.end(JSON.stringify({ error: 'Unauthorized: missing or invalid auth token' }));
137
+ return;
138
+ }
139
+ const runId = url.slice(6).split('?')[0];
105
140
  stopRequests.add(runId);
106
141
  res.writeHead(200, { 'Content-Type': 'application/json' });
107
142
  res.end(JSON.stringify({ ok: true, runId }));
@@ -678,7 +678,7 @@ function renderCards() {
678
678
  '</div>';
679
679
  }).join('');
680
680
  }
681
- function stopRun(id) { fetch('/stop/' + id, { method: 'POST' }).catch(function() {}); }
681
+ function stopRun(id) { fetch('/stop/' + id, { method: 'POST', headers: { 'x-monobrowse-token': (window.__MONOBROWSE_TOKEN__ || '') } }).catch(function() {}); }
682
682
 
683
683
  // ═══════════════════════════════════════════════════════════════════
684
684
  // NODE PALETTE DEFINITIONS
File without changes
File without changes
File without changes
File without changes
@@ -20,3 +20,4 @@ export * from './trace.js';
20
20
  export * from './profiler.js';
21
21
  export * from './vitals.js';
22
22
  export * from './har.js';
23
+ export * from './ref-cache.js';
@@ -6,6 +6,17 @@ export async function getCookies(client: CdpClient, sessionId: string): Promise<
6
6
  return result.cookies ?? [];
7
7
  }
8
8
 
9
+ /**
10
+ * Get cookies scoped to specific page URLs via `Network.getCookies`, instead of
11
+ * `Network.getAllCookies` which returns cookies for every origin in the browser
12
+ * profile. Used for session save/export so we don't leak session tokens for
13
+ * unrelated domains the user happens to be logged into.
14
+ */
15
+ export async function getCookiesForUrls(client: CdpClient, sessionId: string, urls: string[]): Promise<CdpCookie[]> {
16
+ const result = await client.send<{ cookies: CdpCookie[] }>('Network.getCookies', { urls }, sessionId);
17
+ return result.cookies ?? [];
18
+ }
19
+
9
20
  export async function setCookies(client: CdpClient, sessionId: string, cookies: CdpCookie[]): Promise<void> {
10
21
  await client.send('Network.setCookies', { cookies }, sessionId);
11
22
  }
File without changes
File without changes