browser-tabs.test.ts
1 import { afterEach, describe, expect, it } from 'vitest'; 2 import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; 3 import * as fs from 'node:fs'; 4 import * as os from 'node:os'; 5 import * as path from 'node:path'; 6 import { parseJsonOutput, runCli } from './helpers.js'; 7 8 type FakeTab = { 9 page: string; 10 url: string; 11 title: string; 12 active: boolean; 13 }; 14 15 type FakeDaemon = { 16 port: number; 17 close: () => Promise<void>; 18 maxInFlightExec: () => number; 19 }; 20 21 async function readBody(req: IncomingMessage): Promise<string> { 22 return await new Promise((resolve, reject) => { 23 const chunks: Buffer[] = []; 24 req.on('data', (chunk: Buffer) => chunks.push(chunk)); 25 req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); 26 req.on('error', reject); 27 }); 28 } 29 30 function json(res: ServerResponse, status: number, payload: unknown): void { 31 res.writeHead(status, { 'Content-Type': 'application/json' }); 32 res.end(JSON.stringify(payload)); 33 } 34 35 async function startFakeDaemon(): Promise<FakeDaemon> { 36 const tabs = new Map<string, FakeTab>([ 37 ['tab-1', { page: 'tab-1', url: 'https://one.example/', title: 'tab-one', active: true }], 38 ['tab-2', { page: 'tab-2', url: 'https://two.example/', title: 'tab-two', active: false }], 39 ]); 40 let nextId = 3; 41 let inFlightExec = 0; 42 let maxInFlightExec = 0; 43 44 const server = createServer(async (req, res) => { 45 const pathname = req.url?.split('?')[0] ?? '/'; 46 47 if (req.method === 'GET' && pathname === '/status') { 48 const port = typeof server.address() === 'object' && server.address() ? server.address().port : 0; 49 json(res, 200, { 50 ok: true, 51 pid: process.pid, 52 uptime: 1, 53 daemonVersion: 'test', 54 extensionConnected: true, 55 extensionVersion: 'test', 56 pending: 0, 57 memoryMB: 1, 58 port, 59 }); 60 return; 61 } 62 63 if (req.method !== 'POST' || pathname !== '/command') { 64 json(res, 404, { ok: false, error: 'Not found' }); 65 return; 66 } 67 68 const body = JSON.parse(await readBody(req)) as { 69 id: string; 70 action: string; 71 op?: string; 72 page?: string; 73 index?: number; 74 url?: string; 75 code?: string; 76 }; 77 78 const listTabs = () => [...tabs.values()].map((tab, index) => ({ index, ...tab })); 79 const tabByIndex = (index?: number) => index === undefined ? undefined : listTabs()[index]; 80 81 switch (body.action) { 82 case 'tabs': { 83 switch (body.op) { 84 case 'list': 85 json(res, 200, { id: body.id, ok: true, data: listTabs() }); 86 return; 87 case 'new': { 88 const page = `tab-${nextId++}`; 89 const url = body.url ?? 'about:blank'; 90 tabs.set(page, { 91 page, 92 url, 93 title: page, 94 active: true, 95 }); 96 json(res, 200, { id: body.id, ok: true, page, data: { url } }); 97 return; 98 } 99 case 'close': { 100 const targetPage = typeof body.page === 'string' ? body.page : tabByIndex(body.index)?.page; 101 if (!targetPage || !tabs.has(targetPage)) { 102 json(res, 200, { id: body.id, ok: false, error: 'Tab not found' }); 103 return; 104 } 105 tabs.delete(targetPage); 106 json(res, 200, { id: body.id, ok: true, data: { closed: targetPage } }); 107 return; 108 } 109 case 'select': { 110 const targetPage = typeof body.page === 'string' ? body.page : tabByIndex(body.index)?.page; 111 if (!targetPage || !tabs.has(targetPage)) { 112 json(res, 200, { id: body.id, ok: false, error: 'Tab not found' }); 113 return; 114 } 115 json(res, 200, { id: body.id, ok: true, page: targetPage, data: { selected: true } }); 116 return; 117 } 118 default: 119 json(res, 200, { id: body.id, ok: false, error: `Unknown tabs op: ${body.op}` }); 120 return; 121 } 122 } 123 case 'navigate': { 124 const targetPage = typeof body.page === 'string' && tabs.has(body.page) ? body.page : 'tab-1'; 125 const target = tabs.get(targetPage)!; 126 const url = body.url ?? target.url; 127 target.url = url; 128 target.title = url; 129 json(res, 200, { 130 id: body.id, 131 ok: true, 132 page: targetPage, 133 data: { title: target.title, url: target.url, timedOut: false }, 134 }); 135 return; 136 } 137 case 'exec': { 138 const targetPage = typeof body.page === 'string' ? body.page : 'tab-1'; 139 const target = tabs.get(targetPage); 140 if (!target) { 141 json(res, 200, { id: body.id, ok: false, error: `Unknown page: ${targetPage}` }); 142 return; 143 } 144 145 inFlightExec++; 146 maxInFlightExec = Math.max(maxInFlightExec, inFlightExec); 147 try { 148 if ((body.code ?? '').includes('__delay')) { 149 await new Promise(resolve => setTimeout(resolve, 200)); 150 } 151 json(res, 200, { 152 id: body.id, 153 ok: true, 154 page: targetPage, 155 data: { 156 page: targetPage, 157 title: target.title, 158 url: target.url, 159 }, 160 }); 161 } finally { 162 inFlightExec--; 163 } 164 return; 165 } 166 default: 167 json(res, 200, { id: body.id, ok: false, error: `Unknown action: ${body.action}` }); 168 } 169 }); 170 171 await new Promise<void>((resolve) => { 172 server.listen(0, '127.0.0.1', () => resolve()); 173 }); 174 175 const address = server.address(); 176 if (!address || typeof address !== 'object') { 177 throw new Error('Failed to bind fake daemon port'); 178 } 179 180 return { 181 port: address.port, 182 close: async () => { 183 await new Promise<void>((resolve, reject) => { 184 server.close((err) => err ? reject(err) : resolve()); 185 }); 186 }, 187 maxInFlightExec: () => maxInFlightExec, 188 }; 189 } 190 191 describe('browser tab CLI e2e', () => { 192 const daemons: FakeDaemon[] = []; 193 const cacheDirs: string[] = []; 194 195 afterEach(async () => { 196 while (daemons.length > 0) { 197 await daemons.pop()!.close(); 198 } 199 while (cacheDirs.length > 0) { 200 fs.rmSync(cacheDirs.pop()!, { recursive: true, force: true }); 201 } 202 }); 203 204 it('lists, creates, and closes tabs through the built CLI', async () => { 205 const daemon = await startFakeDaemon(); 206 daemons.push(daemon); 207 const env = { OPENCLI_DAEMON_PORT: String(daemon.port) }; 208 209 const listed = await runCli(['browser', 'tab', 'list'], { env }); 210 expect(listed.code).toBe(0); 211 const listData = parseJsonOutput(listed.stdout); 212 expect(listData).toEqual(expect.arrayContaining([ 213 expect.objectContaining({ page: 'tab-1', title: 'tab-one' }), 214 expect.objectContaining({ page: 'tab-2', title: 'tab-two' }), 215 ])); 216 217 const created = await runCli(['browser', 'tab', 'new', 'https://three.example/'], { env }); 218 expect(created.code).toBe(0); 219 const createdData = parseJsonOutput(created.stdout); 220 expect(createdData).toEqual(expect.objectContaining({ 221 page: 'tab-3', 222 url: 'https://three.example/', 223 })); 224 225 const closed = await runCli(['browser', 'tab', 'close', 'tab-3'], { env }); 226 expect(closed.code).toBe(0); 227 const closedData = parseJsonOutput(closed.stdout); 228 expect(closedData).toEqual({ closed: 'tab-3' }); 229 230 const relisted = await runCli(['browser', 'tab', 'list'], { env }); 231 expect(relisted.code).toBe(0); 232 const relistedData = parseJsonOutput(relisted.stdout); 233 expect(relistedData).toHaveLength(2); 234 expect(relistedData.some((tab: { page: string }) => tab.page === 'tab-3')).toBe(false); 235 }, 30_000); 236 237 it('routes concurrent browser commands to their requested tabs', async () => { 238 const daemon = await startFakeDaemon(); 239 daemons.push(daemon); 240 const env = { OPENCLI_DAEMON_PORT: String(daemon.port) }; 241 242 const [left, right] = await Promise.all([ 243 runCli(['browser', 'eval', '--tab', 'tab-1', 'window.__delay = "left"'], { env, timeout: 30_000 }), 244 runCli(['browser', 'eval', '--tab', 'tab-2', 'window.__delay = "right"'], { env, timeout: 30_000 }), 245 ]); 246 247 expect(left.code).toBe(0); 248 expect(right.code).toBe(0); 249 250 const leftData = parseJsonOutput(left.stdout); 251 const rightData = parseJsonOutput(right.stdout); 252 253 expect(leftData).toEqual(expect.objectContaining({ page: 'tab-1', title: 'tab-one' })); 254 expect(rightData).toEqual(expect.objectContaining({ page: 'tab-2', title: 'tab-two' })); 255 expect(daemon.maxInFlightExec()).toBe(2); 256 }, 30_000); 257 258 it('keeps untargeted browser commands on the default tab after creating a new tab', async () => { 259 const daemon = await startFakeDaemon(); 260 daemons.push(daemon); 261 const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tabs-')); 262 cacheDirs.push(cacheDir); 263 const env = { 264 OPENCLI_DAEMON_PORT: String(daemon.port), 265 OPENCLI_CACHE_DIR: cacheDir, 266 }; 267 268 const created = await runCli(['browser', 'tab', 'new', 'https://three.example/'], { env }); 269 expect(created.code).toBe(0); 270 expect(parseJsonOutput(created.stdout)).toEqual(expect.objectContaining({ page: 'tab-3' })); 271 272 const untargeted = await runCli(['browser', 'eval', 'document.title'], { env }); 273 expect(untargeted.code).toBe(0); 274 expect(parseJsonOutput(untargeted.stdout)).toEqual(expect.objectContaining({ page: 'tab-1', title: 'tab-one' })); 275 }, 30_000); 276 277 it('uses an explicitly selected tab as the default target for later untargeted commands', async () => { 278 const daemon = await startFakeDaemon(); 279 daemons.push(daemon); 280 const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-browser-tabs-')); 281 cacheDirs.push(cacheDir); 282 const env = { 283 OPENCLI_DAEMON_PORT: String(daemon.port), 284 OPENCLI_CACHE_DIR: cacheDir, 285 }; 286 287 const selected = await runCli(['browser', 'tab', 'select', 'tab-2'], { env }); 288 expect(selected.code).toBe(0); 289 expect(parseJsonOutput(selected.stdout)).toEqual({ selected: 'tab-2' }); 290 291 const untargeted = await runCli(['browser', 'eval', 'document.title'], { env }); 292 expect(untargeted.code).toBe(0); 293 expect(parseJsonOutput(untargeted.stdout)).toEqual(expect.objectContaining({ page: 'tab-2', title: 'tab-two' })); 294 295 const closed = await runCli(['browser', 'tab', 'close', 'tab-2'], { env }); 296 expect(closed.code).toBe(0); 297 expect(parseJsonOutput(closed.stdout)).toEqual({ closed: 'tab-2' }); 298 299 const fallback = await runCli(['browser', 'eval', 'document.title'], { env }); 300 expect(fallback.code).toBe(0); 301 expect(parseJsonOutput(fallback.stdout)).toEqual(expect.objectContaining({ page: 'tab-1', title: 'tab-one' })); 302 }, 30_000); 303 });