/ tests / e2e / browser-tabs.test.ts
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  });