/ extension / src / background.test.ts
background.test.ts
  1  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
  2  
  3  type Listener<T extends (...args: any[]) => void> = { addListener: (fn: T) => void };
  4  
  5  type MockTab = {
  6    id: number;
  7    windowId: number;
  8    url?: string;
  9    title?: string;
 10    active?: boolean;
 11    status?: string;
 12  };
 13  
 14  class MockWebSocket {
 15    static OPEN = 1;
 16    static CONNECTING = 0;
 17    readyState = MockWebSocket.CONNECTING;
 18    onopen: (() => void) | null = null;
 19    onmessage: ((event: { data: string }) => void) | null = null;
 20    onclose: (() => void) | null = null;
 21    onerror: (() => void) | null = null;
 22  
 23    constructor(_url: string) {}
 24    send(_data: string): void {}
 25    close(): void {
 26      this.onclose?.();
 27    }
 28  }
 29  
 30  function createChromeMock() {
 31    let nextTabId = 10;
 32    const tabs: MockTab[] = [
 33      { id: 1, windowId: 1, url: 'https://automation.example', title: 'automation', active: true, status: 'complete' },
 34      { id: 2, windowId: 2, url: 'https://user.example', title: 'user', active: true, status: 'complete' },
 35      { id: 3, windowId: 1, url: 'chrome://extensions', title: 'chrome', active: false, status: 'complete' },
 36    ];
 37  
 38    const query = vi.fn(async (queryInfo: { windowId?: number; active?: boolean } = {}) => {
 39      return tabs.filter((tab) => {
 40        if (queryInfo.windowId !== undefined && tab.windowId !== queryInfo.windowId) return false;
 41        if (queryInfo.active !== undefined && !!tab.active !== queryInfo.active) return false;
 42        return true;
 43      });
 44    });
 45    const create = vi.fn(async ({ windowId, url, active }: { windowId?: number; url?: string; active?: boolean }) => {
 46      const tab: MockTab = {
 47        id: nextTabId++,
 48        windowId: windowId ?? 999,
 49        url,
 50        title: url ?? 'blank',
 51        active: !!active,
 52        status: 'complete',
 53      };
 54      tabs.push(tab);
 55      return tab;
 56    });
 57    const update = vi.fn(async (tabId: number, updates: { active?: boolean; url?: string }) => {
 58      const tab = tabs.find((entry) => entry.id === tabId);
 59      if (!tab) throw new Error(`Unknown tab ${tabId}`);
 60      if (updates.active !== undefined) tab.active = updates.active;
 61      if (updates.url !== undefined) tab.url = updates.url;
 62      return tab;
 63    });
 64  
 65    const chrome = {
 66      tabs: {
 67        query,
 68        create,
 69        update,
 70        remove: vi.fn(async (_tabId: number) => {}),
 71        get: vi.fn(async (tabId: number) => {
 72          const tab = tabs.find((entry) => entry.id === tabId);
 73          if (!tab) throw new Error(`Unknown tab ${tabId}`);
 74          return tab;
 75        }),
 76        move: vi.fn(async (tabId: number, moveProps: { windowId: number; index: number }) => {
 77          const tab = tabs.find((entry) => entry.id === tabId);
 78          if (!tab) throw new Error(`Unknown tab ${tabId}`);
 79          tab.windowId = moveProps.windowId;
 80          return tab;
 81        }),
 82        onUpdated: { addListener: vi.fn(), removeListener: vi.fn() } as Listener<(id: number, info: chrome.tabs.TabChangeInfo) => void>,
 83        onRemoved: { addListener: vi.fn() } as Listener<(tabId: number) => void>,
 84      },
 85      debugger: {
 86        getTargets: vi.fn(async () => tabs.map(t => ({
 87          type: 'page',
 88          id: `target-${t.id}`,
 89          tabId: t.id,
 90          url: t.url ?? '',
 91          title: t.title ?? '',
 92          attached: false,
 93        }))),
 94        attach: vi.fn(),
 95        detach: vi.fn(),
 96        sendCommand: vi.fn(),
 97        onDetach: { addListener: vi.fn() } as Listener<(source: { tabId?: number }) => void>,
 98        onEvent: { addListener: vi.fn() } as Listener<(source: any, method: string, params: any) => void>,
 99      },
100      windows: {
101        get: vi.fn(async (windowId: number) => ({ id: windowId })),
102        create: vi.fn(async ({ url, focused, width, height, type }: any) => ({ id: 1, url, focused, width, height, type })),
103        remove: vi.fn(async (_windowId: number) => {}),
104        onRemoved: { addListener: vi.fn() } as Listener<(windowId: number) => void>,
105      },
106      alarms: {
107        create: vi.fn(),
108        onAlarm: { addListener: vi.fn() } as Listener<(alarm: { name: string }) => void>,
109      },
110      runtime: {
111        onInstalled: { addListener: vi.fn() } as Listener<() => void>,
112        onStartup: { addListener: vi.fn() } as Listener<() => void>,
113        onMessage: { addListener: vi.fn() } as Listener<(msg: unknown, sender: unknown, sendResponse: (value: unknown) => void) => void>,
114        getManifest: vi.fn(() => ({ version: 'test-version' })),
115      },
116      cookies: {
117        getAll: vi.fn(async () => []),
118      },
119    };
120  
121    return { chrome, tabs, query, create, update };
122  }
123  
124  describe('background tab isolation', () => {
125    beforeEach(() => {
126      vi.resetModules();
127      vi.useRealTimers();
128      vi.stubGlobal('WebSocket', MockWebSocket);
129    });
130  
131    afterEach(() => {
132      vi.useRealTimers();
133      vi.unstubAllGlobals();
134    });
135  
136    it('lists only automation-window web tabs', async () => {
137      const { chrome } = createChromeMock();
138      vi.stubGlobal('chrome', chrome);
139  
140      const mod = await import('./background');
141      mod.__test__.setAutomationWindowId('site:twitter', 1);
142  
143      const result = await mod.__test__.handleTabs({ id: '1', action: 'tabs', op: 'list', workspace: 'site:twitter' }, 'site:twitter');
144  
145      expect(result.ok).toBe(true);
146      expect(result.data).toEqual([
147        {
148          index: 0,
149          page: 'target-1',
150          url: 'https://automation.example',
151          title: 'automation',
152          active: true,
153        },
154      ]);
155    });
156  
157    it('lists cross-origin frames in the same order exposed by snapshot [F#] markers', async () => {
158      const { chrome } = createChromeMock();
159      chrome.debugger.sendCommand = vi.fn(async (_target: unknown, method: string) => {
160        if (method === 'Runtime.enable') return {};
161        if (method === 'Runtime.evaluate') return { result: { value: 1 } };
162        if (method === 'Page.getFrameTree') {
163          return {
164            frameTree: {
165              frame: { id: 'root', url: 'https://main.example/' },
166              childFrames: [
167                {
168                  frame: { id: 'same-origin-parent', url: 'https://main.example/embed' },
169                  childFrames: [
170                    {
171                      frame: { id: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' },
172                      childFrames: [
173                        {
174                          frame: { id: 'hidden-descendant', url: 'https://x.example/inner' },
175                        },
176                      ],
177                    },
178                  ],
179                },
180                {
181                  frame: { id: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
182                },
183              ],
184            },
185          };
186        }
187        return {};
188      });
189      vi.stubGlobal('chrome', chrome);
190  
191      const mod = await import('./background');
192      mod.__test__.setAutomationWindowId('site:twitter', 1);
193  
194      const result = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', workspace: 'site:twitter' });
195  
196      expect(result.ok).toBe(true);
197      expect(result.data).toEqual([
198        { index: 0, frameId: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' },
199        { index: 1, frameId: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
200      ]);
201    });
202  
203    it('routes exec frameIndex through the same cross-origin frame ordering as handleFrames', async () => {
204      const { chrome } = createChromeMock();
205      vi.stubGlobal('chrome', chrome);
206  
207      const evaluateInFrame = vi.fn(async () => 'frame-result');
208      vi.doMock('./cdp', () => ({
209        registerListeners: vi.fn(),
210        registerFrameTracking: vi.fn(),
211        hasActiveNetworkCapture: vi.fn(() => false),
212        detach: vi.fn(async () => {}),
213        evaluateAsync: vi.fn(async () => 'main-result'),
214        evaluateInFrame,
215        getFrameTree: vi.fn(async () => ({
216          frameTree: {
217            frame: { id: 'root', url: 'https://main.example/' },
218            childFrames: [
219              {
220                frame: { id: 'same-origin-parent', url: 'https://main.example/embed' },
221                childFrames: [
222                  { frame: { id: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' } },
223                ],
224              },
225              {
226                frame: { id: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
227              },
228            ],
229          },
230        })),
231        screenshot: vi.fn(),
232        setFileInputFiles: vi.fn(),
233        insertText: vi.fn(),
234        startNetworkCapture: vi.fn(),
235        readNetworkCapture: vi.fn(async () => []),
236        ensureAttached: vi.fn(),
237      }));
238  
239      const mod = await import('./background');
240      mod.__test__.setAutomationWindowId('site:twitter', 1);
241  
242      const listResult = await mod.__test__.handleCommand({ id: 'frames', action: 'frames', workspace: 'site:twitter' });
243      const execResult = await mod.__test__.handleCommand({
244        id: 'exec-in-frame',
245        action: 'exec',
246        code: 'document.title',
247        frameIndex: 0,
248        workspace: 'site:twitter',
249      });
250  
251      expect(listResult.ok).toBe(true);
252      expect(listResult.data).toEqual([
253        { index: 0, frameId: 'cross-origin-nested', url: 'https://x.example/widget', name: 'nested-x' },
254        { index: 1, frameId: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' },
255      ]);
256      expect(execResult.ok).toBe(true);
257      expect(evaluateInFrame).toHaveBeenCalledWith(1, 'document.title', 'cross-origin-nested', false);
258    });
259  
260    it('creates new tabs inside the automation window', async () => {
261      const { chrome, create } = createChromeMock();
262      vi.stubGlobal('chrome', chrome);
263  
264      const mod = await import('./background');
265      mod.__test__.setAutomationWindowId('site:twitter', 1);
266  
267      const result = await mod.__test__.handleTabs({ id: '2', action: 'tabs', op: 'new', url: 'https://new.example', workspace: 'site:twitter' }, 'site:twitter');
268  
269      expect(result.ok).toBe(true);
270      expect(create).toHaveBeenCalledWith({ windowId: 1, url: 'https://new.example', active: true });
271    });
272  
273    it('closes a tab by page identity', async () => {
274      const { chrome } = createChromeMock();
275      vi.stubGlobal('chrome', chrome);
276  
277      const mod = await import('./background');
278      mod.__test__.setAutomationWindowId('site:twitter', 1);
279  
280      const result = await mod.__test__.handleTabs(
281        { id: 'close-by-page', action: 'tabs', op: 'close', workspace: 'site:twitter', page: 'target-1' },
282        'site:twitter',
283      );
284  
285      expect(result).toEqual({
286        id: 'close-by-page',
287        ok: true,
288        data: { closed: 'target-1' },
289      });
290      expect(chrome.tabs.remove).toHaveBeenCalledWith(1);
291    });
292  
293    it('treats normalized same-url navigate as already complete', async () => {
294      const { chrome, tabs, update } = createChromeMock();
295      tabs[0].url = 'https://www.bilibili.com/';
296      tabs[0].title = 'bilibili';
297      tabs[0].status = 'complete';
298      vi.stubGlobal('chrome', chrome);
299  
300      const mod = await import('./background');
301      mod.__test__.setAutomationWindowId('site:bilibili', 1);
302  
303      const result = await mod.__test__.handleNavigate(
304        { id: 'same-url', action: 'navigate', url: 'https://www.bilibili.com', workspace: 'site:bilibili' },
305        'site:bilibili',
306      );
307  
308      expect(result).toEqual({
309        id: 'same-url',
310        ok: true,
311        page: 'target-1',
312        data: {
313          title: 'bilibili',
314          url: 'https://www.bilibili.com/',
315          timedOut: false,
316        },
317      });
318      expect(update).not.toHaveBeenCalled();
319    });
320  
321    it('keeps the debugger attached during navigation when network capture is active', async () => {
322      const { chrome, tabs } = createChromeMock();
323      const onUpdatedListeners: Array<(id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => void> = [];
324      chrome.tabs.onUpdated.addListener = vi.fn((fn) => { onUpdatedListeners.push(fn); });
325      chrome.tabs.onUpdated.removeListener = vi.fn((fn) => {
326        const idx = onUpdatedListeners.indexOf(fn);
327        if (idx >= 0) onUpdatedListeners.splice(idx, 1);
328      });
329      chrome.tabs.update = vi.fn(async (tabId: number, updates: { active?: boolean; url?: string }) => {
330        const tab = tabs.find((entry) => entry.id === tabId);
331        if (!tab) throw new Error(`Unknown tab ${tabId}`);
332        if (updates.active !== undefined) tab.active = updates.active;
333        if (updates.url !== undefined) tab.url = updates.url;
334        tab.status = 'complete';
335        for (const listener of [...onUpdatedListeners]) {
336          listener(tabId, { status: 'complete', url: tab.url }, tab as chrome.tabs.Tab);
337        }
338        return tab;
339      });
340      vi.stubGlobal('chrome', chrome);
341  
342      const detachMock = vi.fn(async () => {});
343      vi.doMock('./cdp', () => ({
344        registerListeners: vi.fn(),
345        hasActiveNetworkCapture: vi.fn(() => true),
346        detach: detachMock,
347      }));
348  
349      const mod = await import('./background');
350      mod.__test__.setAutomationWindowId('site:eos', 1);
351  
352      const result = await mod.__test__.handleNavigate(
353        { id: 'capture-nav', action: 'navigate', url: 'https://eos.douyin.com/livesite/live/current', workspace: 'site:eos' },
354        'site:eos',
355      );
356  
357      expect(result.ok).toBe(true);
358      expect(detachMock).not.toHaveBeenCalled();
359    });
360  
361    it('keeps hash routes distinct when comparing target URLs', async () => {
362      const { chrome } = createChromeMock();
363      vi.stubGlobal('chrome', chrome);
364  
365      const mod = await import('./background');
366  
367      expect(mod.__test__.isTargetUrl('https://example.com/', 'https://example.com')).toBe(true);
368      expect(mod.__test__.isTargetUrl('https://example.com/#feed', 'https://example.com/#settings')).toBe(false);
369      expect(mod.__test__.isTargetUrl('https://example.com/app/', 'https://example.com/app')).toBe(false);
370    });
371  
372    it('reports sessions per workspace', async () => {
373      const { chrome } = createChromeMock();
374      vi.stubGlobal('chrome', chrome);
375  
376      const mod = await import('./background');
377      mod.__test__.setAutomationWindowId('site:twitter', 1);
378      mod.__test__.setAutomationWindowId('site:zhihu', 2);
379  
380      const result = await mod.__test__.handleSessions({ id: '3', action: 'sessions' });
381      expect(result.ok).toBe(true);
382      expect(result.data).toEqual(expect.arrayContaining([
383        expect.objectContaining({ workspace: 'site:twitter', windowId: 1 }),
384        expect.objectContaining({ workspace: 'site:zhihu', windowId: 2 }),
385      ]));
386    });
387  
388    it('can execute concurrently on two pages in the same workspace', async () => {
389      const { chrome, tabs } = createChromeMock();
390      tabs.push({
391        id: 4,
392        windowId: 1,
393        url: 'https://automation-2.example',
394        title: 'automation-2',
395        active: false,
396        status: 'complete',
397      });
398      vi.stubGlobal('chrome', chrome);
399  
400      let inFlight = 0;
401      let maxInFlight = 0;
402      vi.doMock('./cdp', () => ({
403        registerListeners: vi.fn(),
404        evaluateAsync: vi.fn(async (tabId: number, code: string) => {
405          inFlight++;
406          maxInFlight = Math.max(maxInFlight, inFlight);
407          await new Promise(resolve => setTimeout(resolve, 30));
408          inFlight--;
409          return { tabId, code };
410        }),
411      }));
412  
413      const mod = await import('./background');
414      mod.__test__.setAutomationWindowId('site:parallel', 1);
415  
416      const [first, second] = await Promise.all([
417        mod.__test__.handleExec({ id: 'p1', action: 'exec', workspace: 'site:parallel', page: 'target-1', code: 'window.__task = 1' }, 'site:parallel'),
418        mod.__test__.handleExec({ id: 'p2', action: 'exec', workspace: 'site:parallel', page: 'target-4', code: 'window.__task = 2' }, 'site:parallel'),
419      ]);
420  
421      expect(first).toEqual(expect.objectContaining({
422        ok: true,
423        page: 'target-1',
424        data: { tabId: 1, code: 'window.__task = 1' },
425      }));
426      expect(second).toEqual(expect.objectContaining({
427        ok: true,
428        page: 'target-4',
429        data: { tabId: 4, code: 'window.__task = 2' },
430      }));
431      expect(maxInFlight).toBe(2);
432    });
433  
434    it('can execute concurrently across two workspaces/windows', async () => {
435      const { chrome } = createChromeMock();
436      vi.stubGlobal('chrome', chrome);
437  
438      let inFlight = 0;
439      let maxInFlight = 0;
440      vi.doMock('./cdp', () => ({
441        registerListeners: vi.fn(),
442        evaluateAsync: vi.fn(async (tabId: number, code: string) => {
443          inFlight++;
444          maxInFlight = Math.max(maxInFlight, inFlight);
445          await new Promise(resolve => setTimeout(resolve, 30));
446          inFlight--;
447          return { tabId, code };
448        }),
449      }));
450  
451      const mod = await import('./background');
452      mod.__test__.setAutomationWindowId('site:twitter', 1);
453      mod.__test__.setAutomationWindowId('site:zhihu', 2);
454  
455      const [first, second] = await Promise.all([
456        mod.__test__.handleExec({ id: 'w1', action: 'exec', workspace: 'site:twitter', code: 'window.__window = 1' }, 'site:twitter'),
457        mod.__test__.handleExec({ id: 'w2', action: 'exec', workspace: 'site:zhihu', code: 'window.__window = 2' }, 'site:zhihu'),
458      ]);
459  
460      expect(first).toEqual(expect.objectContaining({
461        ok: true,
462        page: 'target-1',
463        data: { tabId: 1, code: 'window.__window = 1' },
464      }));
465      expect(second).toEqual(expect.objectContaining({
466        ok: true,
467        page: 'target-2',
468        data: { tabId: 2, code: 'window.__window = 2' },
469      }));
470      expect(maxInFlight).toBe(2);
471    });
472  
473    it('keeps site:notebooklm inside its owned automation window instead of rebinding to a user tab', async () => {
474      const { chrome, tabs } = createChromeMock();
475      tabs[0].url = 'https://notebooklm.google.com/';
476      tabs[0].title = 'NotebookLM Home';
477      tabs[1].url = 'https://notebooklm.google.com/notebook/nb-live';
478      tabs[1].title = 'Live Notebook';
479      vi.stubGlobal('chrome', chrome);
480  
481      const mod = await import('./background');
482      mod.__test__.setAutomationWindowId('site:notebooklm', 1);
483  
484      const tabId = await mod.__test__.resolveTabId(undefined, 'site:notebooklm');
485  
486      expect(tabId).toBe(1);
487      expect(mod.__test__.getSession('site:notebooklm')).toEqual(expect.objectContaining({
488        windowId: 1,
489      }));
490    });
491  
492    it('moves drifted tab back to automation window instead of creating a new one', async () => {
493      const { chrome, tabs } = createChromeMock();
494      // Tab 1 belongs to automation window 1 but drifted to window 2
495      tabs[0].windowId = 2;
496      tabs[0].url = 'https://twitter.com/home';
497      vi.stubGlobal('chrome', chrome);
498  
499      const mod = await import('./background');
500      mod.__test__.setAutomationWindowId('site:twitter', 1);
501  
502      const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
503  
504      // Should have moved tab 1 back to window 1 and reused it
505      expect(chrome.tabs.move).toHaveBeenCalledWith(1, { windowId: 1, index: -1 });
506      expect(tabId).toBe(1);
507    });
508  
509    it('falls through to re-resolve when drifted tab move fails', async () => {
510      const { chrome, tabs } = createChromeMock();
511      tabs[0].windowId = 2;
512      tabs[0].url = 'https://twitter.com/home';
513      // Make move fail
514      chrome.tabs.move = vi.fn(async () => { throw new Error('Cannot move tab'); });
515      vi.stubGlobal('chrome', chrome);
516  
517      const mod = await import('./background');
518      mod.__test__.setAutomationWindowId('site:twitter', 1);
519  
520      // Should still resolve (by finding/creating a tab in the correct window)
521      const tabId = await mod.__test__.resolveTabId(1, 'site:twitter');
522      expect(typeof tabId).toBe('number');
523    });
524  
525    it('idle timeout closes the automation window for site:notebooklm', async () => {
526      const { chrome, tabs } = createChromeMock();
527      tabs[0].url = 'https://notebooklm.google.com/';
528      tabs[0].title = 'NotebookLM Home';
529      tabs[0].active = true;
530  
531      vi.useFakeTimers();
532      vi.stubGlobal('chrome', chrome);
533  
534      const mod = await import('./background');
535      mod.__test__.setAutomationWindowId('site:notebooklm', 1);
536  
537      mod.__test__.resetWindowIdleTimer('site:notebooklm');
538      await vi.advanceTimersByTimeAsync(30001);
539  
540      expect(chrome.windows.remove).toHaveBeenCalledWith(1);
541      expect(mod.__test__.getSession('site:notebooklm')).toBeNull();
542    });
543  
544    it('uses 10-minute timeout for browser:* workspaces', async () => {
545      const { chrome } = createChromeMock();
546      vi.useFakeTimers();
547      vi.stubGlobal('chrome', chrome);
548  
549      const mod = await import('./background');
550      mod.__test__.setAutomationWindowId('browser:default', 1);
551  
552      mod.__test__.resetWindowIdleTimer('browser:default');
553      // After 30s (adapter timeout), session should still be alive
554      await vi.advanceTimersByTimeAsync(30001);
555      expect(mod.__test__.getSession('browser:default')).not.toBeNull();
556  
557      // After 10 min total, session should be cleaned up
558      await vi.advanceTimersByTimeAsync(600000 - 30001);
559      expect(chrome.windows.remove).toHaveBeenCalledWith(1);
560      expect(mod.__test__.getSession('browser:default')).toBeNull();
561    });
562  
563    it('clears workspaceTimeoutOverrides on idle expiry', async () => {
564      const { chrome } = createChromeMock();
565      vi.useFakeTimers();
566      vi.stubGlobal('chrome', chrome);
567  
568      const mod = await import('./background');
569      mod.__test__.setAutomationWindowId('browser:test', 1);
570  
571      // Set a custom timeout override
572      mod.__test__.workspaceTimeoutOverrides.set('browser:test', 120_000);
573      expect(mod.__test__.getIdleTimeout('browser:test')).toBe(120_000);
574  
575      // Trigger idle timer with the custom timeout
576      mod.__test__.resetWindowIdleTimer('browser:test');
577      await vi.advanceTimersByTimeAsync(120001);
578  
579      // Override should be cleaned up
580      expect(mod.__test__.workspaceTimeoutOverrides.has('browser:test')).toBe(false);
581      expect(mod.__test__.getSession('browser:test')).toBeNull();
582      // Should fall back to default interactive timeout
583      expect(mod.__test__.getIdleTimeout('browser:test')).toBe(600_000);
584    });
585  
586    it('clears workspaceTimeoutOverrides on explicit close', async () => {
587      const { chrome } = createChromeMock();
588      vi.stubGlobal('chrome', chrome);
589  
590      const mod = await import('./background');
591      mod.__test__.setAutomationWindowId('browser:close-test', 1);
592      mod.__test__.workspaceTimeoutOverrides.set('browser:close-test', 300_000);
593  
594      const result = await mod.__test__.handleCommand({
595        id: 'close-1',
596        action: 'close-window',
597        workspace: 'browser:close-test',
598      });
599  
600      expect(result.ok).toBe(true);
601      expect(mod.__test__.workspaceTimeoutOverrides.has('browser:close-test')).toBe(false);
602    });
603  
604    it('applies idleTimeout from command to workspace override', async () => {
605      const { chrome } = createChromeMock();
606      vi.stubGlobal('chrome', chrome);
607  
608      const mod = await import('./background');
609      mod.__test__.setAutomationWindowId('browser:custom', 1);
610  
611      // Default for browser:* is 10 min
612      expect(mod.__test__.getIdleTimeout('browser:custom')).toBe(600_000);
613  
614      // Send a command with custom idleTimeout (in seconds)
615      await mod.__test__.handleCommand({
616        id: 'custom-1',
617        action: 'sessions',
618        workspace: 'browser:custom',
619        idleTimeout: 120,
620      });
621  
622      // Override should now be 120s = 120000ms
623      expect(mod.__test__.getIdleTimeout('browser:custom')).toBe(120_000);
624    });
625  
626    it('clears workspaceTimeoutOverrides when user manually closes automation window', async () => {
627      const { chrome } = createChromeMock();
628      vi.stubGlobal('chrome', chrome);
629  
630      const mod = await import('./background');
631  
632      // Set up a session with window ID 42 and a custom timeout override
633      mod.__test__.setAutomationWindowId('browser:manual', 42);
634      mod.__test__.workspaceTimeoutOverrides.set('browser:manual', 180_000);
635      expect(mod.__test__.getIdleTimeout('browser:manual')).toBe(180_000);
636  
637      // Simulate user closing the window — invoke the onRemoved listener
638      const onRemovedListener = chrome.windows.onRemoved.addListener.mock.calls[0][0];
639      await onRemovedListener(42);
640  
641      // Session and override should both be cleaned up
642      expect(mod.__test__.getSession('browser:manual')).toBeNull();
643      expect(mod.__test__.workspaceTimeoutOverrides.has('browser:manual')).toBe(false);
644      // Should fall back to default interactive timeout
645      expect(mod.__test__.getIdleTimeout('browser:manual')).toBe(600_000);
646    });
647  });