/ src / browser / page.test.ts
page.test.ts
  1  import { beforeEach, describe, expect, it, vi } from 'vitest';
  2  
  3  const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({
  4    sendCommandMock: vi.fn(),
  5    sendCommandFullMock: vi.fn(),
  6  }));
  7  const { warnMock } = vi.hoisted(() => ({
  8    warnMock: vi.fn(),
  9  }));
 10  
 11  vi.mock('./daemon-client.js', () => ({
 12    sendCommand: sendCommandMock,
 13    sendCommandFull: sendCommandFullMock,
 14  }));
 15  vi.mock('../logger.js', () => ({
 16    log: {
 17      warn: warnMock,
 18    },
 19  }));
 20  
 21  import { Page } from './page.js';
 22  
 23  describe('Page.getCurrentUrl', () => {
 24    beforeEach(() => {
 25      sendCommandMock.mockReset();
 26      sendCommandFullMock.mockReset();
 27      warnMock.mockReset();
 28    });
 29  
 30    it('reads the real browser URL when no local navigation cache exists', async () => {
 31      sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live');
 32  
 33      const page = new Page('site:notebooklm');
 34      const url = await page.getCurrentUrl();
 35  
 36      expect(url).toBe('https://notebooklm.google.com/notebook/nb-live');
 37      expect(sendCommandMock).toHaveBeenCalledTimes(1);
 38      expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
 39        workspace: 'site:notebooklm',
 40      }));
 41    });
 42  
 43    it('caches the discovered browser URL for later reads', async () => {
 44      sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live');
 45  
 46      const page = new Page('site:notebooklm');
 47      expect(await page.getCurrentUrl()).toBe('https://notebooklm.google.com/notebook/nb-live');
 48      expect(await page.getCurrentUrl()).toBe('https://notebooklm.google.com/notebook/nb-live');
 49  
 50      expect(sendCommandMock).toHaveBeenCalledTimes(1);
 51    });
 52  });
 53  
 54  describe('Page.evaluate', () => {
 55    beforeEach(() => {
 56      sendCommandMock.mockReset();
 57      sendCommandFullMock.mockReset();
 58      warnMock.mockReset();
 59    });
 60  
 61    it('retries once when the inspected target navigated during exec', async () => {
 62      sendCommandMock
 63        .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))
 64        .mockResolvedValueOnce(42);
 65  
 66      const page = new Page('site:notebooklm');
 67      const value = await page.evaluate('21 + 21');
 68  
 69      expect(value).toBe(42);
 70      expect(sendCommandMock).toHaveBeenCalledTimes(2);
 71    });
 72  });
 73  
 74  describe('Page network capture compatibility', () => {
 75    beforeEach(() => {
 76      sendCommandMock.mockReset();
 77      sendCommandFullMock.mockReset();
 78      warnMock.mockReset();
 79    });
 80  
 81    it('treats unknown network-capture-start as unsupported and memoizes it', async () => {
 82      sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'));
 83  
 84      const page = new Page('site:notebooklm');
 85  
 86      await expect(page.startNetworkCapture()).resolves.toBe(false);
 87      await expect(page.startNetworkCapture()).resolves.toBe(false);
 88  
 89      expect(sendCommandMock).toHaveBeenCalledTimes(1);
 90      expect(warnMock).toHaveBeenCalledTimes(1);
 91      expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('does not support network capture'));
 92      expect(sendCommandMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({
 93        workspace: 'site:notebooklm',
 94      }));
 95    });
 96  
 97    it('returns an empty capture when network-capture-read is unsupported', async () => {
 98      sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
 99  
100      const page = new Page('site:notebooklm');
101  
102      await expect(page.readNetworkCapture()).resolves.toEqual([]);
103      await expect(page.readNetworkCapture()).resolves.toEqual([]);
104  
105      expect(sendCommandMock).toHaveBeenCalledTimes(1);
106      expect(warnMock).toHaveBeenCalledTimes(1);
107      expect(sendCommandMock).toHaveBeenCalledWith('network-capture-read', expect.objectContaining({
108        workspace: 'site:notebooklm',
109      }));
110    });
111  
112    it('rethrows unrelated network capture failures', async () => {
113      sendCommandMock.mockRejectedValueOnce(new Error('Extension disconnected'));
114  
115      const page = new Page('site:notebooklm');
116  
117      await expect(page.startNetworkCapture()).rejects.toThrow('Extension disconnected');
118      expect(sendCommandMock).toHaveBeenCalledTimes(1);
119      expect(warnMock).not.toHaveBeenCalled();
120    });
121  
122    it('warns only once even if both start and read hit the compatibility fallback', async () => {
123      sendCommandMock
124        .mockRejectedValueOnce(new Error('Unknown action: network-capture-start'))
125        .mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
126  
127      const page = new Page('site:notebooklm');
128  
129      await expect(page.startNetworkCapture()).resolves.toBe(false);
130      await expect(page.readNetworkCapture()).resolves.toEqual([]);
131  
132      expect(warnMock).toHaveBeenCalledTimes(1);
133    });
134  });
135  
136  describe('Page active target tracking', () => {
137    beforeEach(() => {
138      sendCommandMock.mockReset();
139      sendCommandFullMock.mockReset();
140      warnMock.mockReset();
141    });
142  
143    it('tracks only one active page identity at a time', async () => {
144      sendCommandFullMock
145        .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' })
146        .mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' });
147      sendCommandMock.mockResolvedValue('ok');
148  
149      const page = new Page('browser:default');
150  
151      await page.goto('https://first.example', { waitUntil: 'none' });
152      expect(page.getActivePage()).toBe('page-1');
153  
154      await page.selectTab(1);
155      expect(page.getActivePage()).toBe('page-2');
156  
157      await page.evaluate('1 + 1');
158  
159      expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
160        workspace: 'browser:default',
161        page: 'page-2',
162      }));
163    });
164  
165    it('allows the caller to bind a specific active page identity explicitly', async () => {
166      sendCommandMock.mockResolvedValue('bound');
167  
168      const page = new Page('browser:default');
169      page.setActivePage?.('page-explicit');
170  
171      await page.evaluate('1 + 1');
172  
173      expect(sendCommandMock).toHaveBeenCalledWith('exec', expect.objectContaining({
174        workspace: 'browser:default',
175        page: 'page-explicit',
176      }));
177    });
178  
179    it('creates a new tab without changing the current active page binding', async () => {
180      sendCommandFullMock
181        .mockResolvedValueOnce({ data: { url: 'https://first.example' }, page: 'page-1' })
182        .mockResolvedValueOnce({
183          data: { url: 'https://second.example' },
184          page: 'page-2',
185        });
186      sendCommandMock.mockResolvedValue('ok');
187  
188      const page = new Page('browser:default');
189      await page.goto('https://first.example', { waitUntil: 'none' });
190  
191      const created = await page.newTab?.('https://second.example');
192  
193      expect(created).toBe('page-2');
194      expect(page.getActivePage()).toBe('page-1');
195      await page.evaluate('1 + 1');
196      expect(sendCommandMock).toHaveBeenLastCalledWith('exec', expect.objectContaining({
197        workspace: 'browser:default',
198        page: 'page-1',
199      }));
200    });
201  
202    it('allows the caller to adopt a new tab explicitly after creation', async () => {
203      sendCommandFullMock.mockResolvedValueOnce({
204        data: { url: 'https://second.example' },
205        page: 'page-2',
206      });
207  
208      const page = new Page('browser:default');
209      const created = await page.newTab?.('https://second.example');
210  
211      expect(created).toBe('page-2');
212      expect(page.getActivePage()).toBeUndefined();
213  
214      page.setActivePage?.(created);
215      expect(page.getActivePage()).toBe('page-2');
216      expect(sendCommandFullMock).toHaveBeenCalledWith('tabs', expect.objectContaining({
217        op: 'new',
218        url: 'https://second.example',
219        workspace: 'browser:default',
220      }));
221    });
222  
223    it('closes a tab by explicit page identity', async () => {
224      sendCommandMock.mockResolvedValueOnce({ closed: 'page-2' });
225  
226      const page = new Page('browser:default');
227      await page.closeTab?.('page-2');
228  
229      expect(sendCommandMock).toHaveBeenCalledWith('tabs', expect.objectContaining({
230        op: 'close',
231        workspace: 'browser:default',
232        page: 'page-2',
233      }));
234    });
235  
236    it('clears the active page binding when closing the selected tab by numeric index', async () => {
237      sendCommandFullMock.mockResolvedValueOnce({ data: { selected: true }, page: 'page-2' });
238      sendCommandMock
239        .mockResolvedValueOnce({ closed: 'page-2' })
240        .mockResolvedValueOnce('ok');
241  
242      const page = new Page('browser:default');
243  
244      await page.selectTab(1);
245      expect(page.getActivePage()).toBe('page-2');
246  
247      await page.closeTab?.(1);
248      expect(page.getActivePage()).toBeUndefined();
249  
250      await page.evaluate('1 + 1');
251  
252      const evalCall = sendCommandMock.mock.calls.at(-1);
253      expect(evalCall?.[0]).toBe('exec');
254      expect(evalCall?.[1]).toEqual(expect.objectContaining({
255        workspace: 'browser:default',
256      }));
257      expect(evalCall?.[1]).not.toHaveProperty('page');
258    });
259  });