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 });