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