diff --git a/src/content-scripts/webmcp-polyfill.js b/src/content-scripts/webmcp-polyfill.js index 58a3b54..7b9147a 100644 --- a/src/content-scripts/webmcp-polyfill.js +++ b/src/content-scripts/webmcp-polyfill.js @@ -17,7 +17,6 @@ // Guard: already initialized (either by us or native browser support) if ('modelContext' in navigator) { - console.log('[WebMCP] Native navigator.modelContext detected, skipping polyfill'); // Still set up window.agent alias for backward compat if not present if (!('agent' in window)) { Object.defineProperty(window, 'agent', { diff --git a/src/lib/webmcp/lifecycle.ts b/src/lib/webmcp/lifecycle.ts index 4cc28a5..08343ee 100644 --- a/src/lib/webmcp/lifecycle.ts +++ b/src/lib/webmcp/lifecycle.ts @@ -323,6 +323,43 @@ export class TabManager { log.debug(`[WebMCP Lifecycle] Navigation starting for tab ${details.tabId}`); }); + // Navigation committed - inject polyfill ASAP before any page scripts run. + // onCommitted fires when the server responds and the new document is about to be created, + // before any HTML is parsed. Combined with injectImmediately + files:[], this bypasses + // page CSP and runs at the equivalent of document_start. + // The manifest content_scripts entry (world: MAIN, document_start) is kept as a fallback + // but may lose the race on some pages or be wrapped by CRXJS in dev mode. + chrome.webNavigation.onCommitted.addListener(async (details) => { + if (details.frameId !== 0) return; + + // Skip restricted URLs + const url = details.url; + if ( + url.startsWith('chrome://') || + url.startsWith('chrome-extension://') || + url.startsWith('edge://') || + url.startsWith('about:') + ) { + return; + } + + try { + await chrome.scripting.executeScript({ + target: { tabId: details.tabId, frameIds: [0] }, + world: 'MAIN', + injectImmediately: true, + files: ['content-scripts/webmcp-polyfill.js'], + }); + log.debug(`[WebMCP Lifecycle] Early polyfill injected into tab ${details.tabId}`); + } catch (error) { + // Non-fatal: manifest content_scripts fallback may still work + log.debug( + `[WebMCP Lifecycle] Early polyfill injection failed for tab ${details.tabId}:`, + error + ); + } + }); + // DOM is ready - inject our scripts chrome.webNavigation.onDOMContentLoaded.addListener(async (details) => { if (details.frameId !== 0) return; // Main frame only @@ -396,8 +433,9 @@ export class TabManager { log.debug(`[WebMCP Lifecycle] Injecting scripts into tab ${tabId} (${tab.url})`); - // Polyfill is injected via manifest content_scripts (world: MAIN, run_at: document_start) - // so it's guaranteed to be available before any page scripts run. + // Polyfill is injected early via onCommitted + injectImmediately (see setupNavigationMonitor). + // The manifest content_scripts entry acts as a belt-and-suspenders fallback. + // The polyfill's own guard (`if ('modelContext' in navigator)`) prevents double-init. // 1. Inject the relay content script (isolated world) // CRITICAL: Must be first so it's listening when bridge sends initial snapshot diff --git a/tests/integration/webmcp-basic.test.ts b/tests/integration/webmcp-basic.test.ts index aef0f3c..5dafed1 100644 --- a/tests/integration/webmcp-basic.test.ts +++ b/tests/integration/webmcp-basic.test.ts @@ -23,6 +23,7 @@ describe('WebMCP Basic Integration', () => { }, webNavigation: { onBeforeNavigate: { addListener: vi.fn() }, + onCommitted: { addListener: vi.fn() }, onDOMContentLoaded: { addListener: vi.fn() }, }, tabs: { diff --git a/tests/integration/webmcp-e2e-flow.test.ts b/tests/integration/webmcp-e2e-flow.test.ts index 2704ea8..6d694b0 100644 --- a/tests/integration/webmcp-e2e-flow.test.ts +++ b/tests/integration/webmcp-e2e-flow.test.ts @@ -60,6 +60,9 @@ describe('WebMCP E2E Message Flow', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, diff --git a/tests/integration/webmcp-integration.test.ts b/tests/integration/webmcp-integration.test.ts index e0d3039..aa98d0d 100644 --- a/tests/integration/webmcp-integration.test.ts +++ b/tests/integration/webmcp-integration.test.ts @@ -44,6 +44,9 @@ describe('WebMCP Integration - Sidebar ↔ Tab Communication', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, diff --git a/tests/integration/webmcp-navigation.test.ts b/tests/integration/webmcp-navigation.test.ts index 1a1a36f..5b07ab6 100644 --- a/tests/integration/webmcp-navigation.test.ts +++ b/tests/integration/webmcp-navigation.test.ts @@ -11,6 +11,7 @@ describe('WebMCP Navigation Integration', () => { let lifecycleManager: TabManager; let navigationHandlers: { onBeforeNavigate?: Function; + onCommitted?: Function; onDOMContentLoaded?: Function; }; @@ -58,6 +59,11 @@ describe('WebMCP Navigation Integration', () => { navigationHandlers.onBeforeNavigate = handler; }), }, + onCommitted: { + addListener: vi.fn((handler: Function) => { + navigationHandlers.onCommitted = handler; + }), + }, onDOMContentLoaded: { addListener: vi.fn((handler: Function) => { navigationHandlers.onDOMContentLoaded = handler; diff --git a/tests/integration/webmcp-script-injection-timing.test.ts b/tests/integration/webmcp-script-injection-timing.test.ts index 56f3623..4485579 100644 --- a/tests/integration/webmcp-script-injection-timing.test.ts +++ b/tests/integration/webmcp-script-injection-timing.test.ts @@ -86,6 +86,9 @@ describe('WebMCP Script Injection Timing', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, @@ -153,18 +156,29 @@ describe('WebMCP Script Injection Timing', () => { }); }); - it('should inject polyfill via manifest content_scripts (not programmatically)', async () => { + it('should inject polyfill programmatically via onCommitted', async () => { const tabId = 456; - await lifecycleManager.ensureContentScriptReady(tabId); + // Capture the onCommitted handler + const onCommittedHandler = + mockChrome.webNavigation.onCommitted.addListener.mock.calls[0]?.[0]; + expect(onCommittedHandler).toBeDefined(); + + // Simulate onCommitted firing (this triggers polyfill injection) + await onCommittedHandler({ tabId, frameId: 0, url: 'https://example.com' }); - // Polyfill is no longer injected programmatically — it comes from manifest content_scripts. - // Verify no executeScript call references the polyfill. + // Verify the polyfill was injected programmatically via onCommitted const allCalls = mockChrome.scripting.executeScript.mock.calls; const polyfillCalls = allCalls.filter( (call: any) => call[0]?.files?.[0] === 'content-scripts/webmcp-polyfill.js' ); - expect(polyfillCalls).toHaveLength(0); + expect(polyfillCalls).toHaveLength(1); + expect(polyfillCalls[0][0]).toEqual({ + target: { tabId, frameIds: [0] }, + world: 'MAIN', + injectImmediately: true, + files: ['content-scripts/webmcp-polyfill.js'], + }); }); it('should inject relay early for message passing setup', async () => { @@ -452,6 +466,9 @@ describe('WebMCP Script Injection Timing', () => { mockChrome.webNavigation.onBeforeNavigate.addListener = vi.fn((handler) => { navigationHandlers.onBeforeNavigate = handler; }); + mockChrome.webNavigation.onCommitted.addListener = vi.fn((handler) => { + navigationHandlers.onCommitted = handler; + }); mockChrome.webNavigation.onDOMContentLoaded.addListener = vi.fn((handler) => { navigationHandlers.onDOMContentLoaded = handler; }); @@ -476,6 +493,15 @@ describe('WebMCP Script Injection Timing', () => { }); } + // onCommitted fires — injects polyfill + if (navigationHandlers.onCommitted) { + await navigationHandlers.onCommitted({ + tabId, + frameId: 0, + url: 'https://example.com/new-page', + }); + } + // DOM ready triggers re-injection if (navigationHandlers.onDOMContentLoaded) { await navigationHandlers.onDOMContentLoaded({ @@ -485,13 +511,14 @@ describe('WebMCP Script Injection Timing', () => { }); } - // Should inject same number of scripts - expect(injectionLog.length).toBe(initialInjectionCount); + // Should inject: polyfill (from onCommitted) + same scripts as initial injection + expect(injectionLog.length).toBe(initialInjectionCount + 1); - // Order: relay → tools → bridge (polyfill via manifest content_scripts) - expect(injectionLog[0].files).toEqual(['content-scripts/relay.js']); - expect(injectionLog[1]?.files?.[0]).toMatch(/^tools\/.*\.js$/); - expect(injectionLog[2].files).toEqual(['content-scripts/page-bridge.js']); + // Order: polyfill (onCommitted) → relay → tools → bridge + expect(injectionLog[0].files).toEqual(['content-scripts/webmcp-polyfill.js']); + expect(injectionLog[1].files).toEqual(['content-scripts/relay.js']); + expect(injectionLog[2]?.files?.[0]).toMatch(/^tools\/.*\.js$/); + expect(injectionLog[3].files).toEqual(['content-scripts/page-bridge.js']); }); it('should handle rapid navigations without double injection', async () => { @@ -501,6 +528,9 @@ describe('WebMCP Script Injection Timing', () => { mockChrome.webNavigation.onBeforeNavigate.addListener = vi.fn((handler) => { navigationHandlers.onBeforeNavigate = handler; }); + mockChrome.webNavigation.onCommitted.addListener = vi.fn((handler) => { + navigationHandlers.onCommitted = handler; + }); mockChrome.webNavigation.onDOMContentLoaded.addListener = vi.fn((handler) => { navigationHandlers.onDOMContentLoaded = handler; }); diff --git a/tests/integration/webmcp-tool-execution.test.ts b/tests/integration/webmcp-tool-execution.test.ts index 798b6d8..5bc4698 100644 --- a/tests/integration/webmcp-tool-execution.test.ts +++ b/tests/integration/webmcp-tool-execution.test.ts @@ -50,6 +50,9 @@ describe('WebMCP Tool Execution Integration', () => { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, diff --git a/tests/webmcp-lifecycle.test.ts b/tests/webmcp-lifecycle.test.ts index 03de394..7a1bfef 100644 --- a/tests/webmcp-lifecycle.test.ts +++ b/tests/webmcp-lifecycle.test.ts @@ -31,6 +31,9 @@ const mockChrome = { onBeforeNavigate: { addListener: vi.fn(), }, + onCommitted: { + addListener: vi.fn(), + }, onDOMContentLoaded: { addListener: vi.fn(), }, @@ -85,6 +88,10 @@ describe('TabManager', () => { navHandlers.onBeforeNavigate = handler; }); + mockChrome.webNavigation.onCommitted.addListener = vi.fn((handler) => { + navHandlers.onCommitted = handler; + }); + mockChrome.webNavigation.onDOMContentLoaded.addListener = vi.fn((handler) => { navHandlers.onDOMContentLoaded = handler; });