Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/content-scripts/webmcp-polyfill.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
42 changes: 40 additions & 2 deletions src/lib/webmcp/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/integration/webmcp-basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('WebMCP Basic Integration', () => {
},
webNavigation: {
onBeforeNavigate: { addListener: vi.fn() },
onCommitted: { addListener: vi.fn() },
onDOMContentLoaded: { addListener: vi.fn() },
},
tabs: {
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/webmcp-e2e-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ describe('WebMCP E2E Message Flow', () => {
onBeforeNavigate: {
addListener: vi.fn(),
},
onCommitted: {
addListener: vi.fn(),
},
onDOMContentLoaded: {
addListener: vi.fn(),
},
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/webmcp-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ describe('WebMCP Integration - Sidebar ↔ Tab Communication', () => {
onBeforeNavigate: {
addListener: vi.fn(),
},
onCommitted: {
addListener: vi.fn(),
},
onDOMContentLoaded: {
addListener: vi.fn(),
},
Expand Down
6 changes: 6 additions & 0 deletions tests/integration/webmcp-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('WebMCP Navigation Integration', () => {
let lifecycleManager: TabManager;
let navigationHandlers: {
onBeforeNavigate?: Function;
onCommitted?: Function;
onDOMContentLoaded?: Function;
};

Expand Down Expand Up @@ -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;
Expand Down
52 changes: 41 additions & 11 deletions tests/integration/webmcp-script-injection-timing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ describe('WebMCP Script Injection Timing', () => {
onBeforeNavigate: {
addListener: vi.fn(),
},
onCommitted: {
addListener: vi.fn(),
},
onDOMContentLoaded: {
addListener: vi.fn(),
},
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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;
});
Expand All @@ -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({
Expand All @@ -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 () => {
Expand All @@ -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;
});
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/webmcp-tool-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ describe('WebMCP Tool Execution Integration', () => {
onBeforeNavigate: {
addListener: vi.fn(),
},
onCommitted: {
addListener: vi.fn(),
},
onDOMContentLoaded: {
addListener: vi.fn(),
},
Expand Down
7 changes: 7 additions & 0 deletions tests/webmcp-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const mockChrome = {
onBeforeNavigate: {
addListener: vi.fn(),
},
onCommitted: {
addListener: vi.fn(),
},
onDOMContentLoaded: {
addListener: vi.fn(),
},
Expand Down Expand Up @@ -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;
});
Expand Down