diff --git a/packages/@n8n/mcp-browser-extension/src/documentPreparation.script.test.ts b/packages/@n8n/mcp-browser-extension/src/documentPreparation.script.test.ts new file mode 100644 index 00000000000..6708e87c538 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/documentPreparation.script.test.ts @@ -0,0 +1,181 @@ +import { applyOptOutAttributes, OPT_OUT_SCRIPT } from './documentPreparation'; + +const run = applyOptOutAttributes; + +function html(markup: string) { + document.body.innerHTML = markup; +} + +beforeEach(() => { + Reflect.deleteProperty(window, '__n8nExtensionOptOut'); + document.body.innerHTML = ''; + document.body.removeAttribute('data-1p-ignore'); +}); + +describe('OPT_OUT_SCRIPT', () => { + it('ships the function as a self-invoking source string', () => { + expect(OPT_OUT_SCRIPT.startsWith('(function')).toBe(true); + expect(OPT_OUT_SCRIPT.endsWith(')()')).toBe(true); + expect(OPT_OUT_SCRIPT).toContain('data-bwignore'); + }); + + it('marks every form field with the password manager opt-out attributes', () => { + html(''); + + run(); + + for (const id of ['a', 'b', 'c']) { + const field = document.getElementById(id); + expect(field?.hasAttribute('data-1p-ignore')).toBe(true); + expect(field?.getAttribute('data-lpignore')).toBe('true'); + expect(field?.hasAttribute('data-bwignore')).toBe(true); + expect(field?.hasAttribute('data-protonpass-ignore')).toBe(true); + expect(field?.getAttribute('data-form-type')).toBe('other'); + } + }); + + it('marks the body with the 1Password whole-page switch only', () => { + html(''); + + run(); + + expect(document.body.hasAttribute('data-1p-ignore')).toBe(true); + expect(document.body.hasAttribute('data-lpignore')).toBe(false); + expect(document.body.hasAttribute('data-form-type')).toBe(false); + }); + + it('marks editable text with the writing assistant opt-out attributes', () => { + html('
'); + + run(); + + for (const id of ['a', 'b']) { + const field = document.getElementById(id); + expect(field?.getAttribute('data-gramm')).toBe('false'); + expect(field?.getAttribute('data-gramm_editor')).toBe('false'); + expect(field?.getAttribute('data-enable-grammarly')).toBe('false'); + expect(field?.getAttribute('data-lt-active')).toBe('false'); + } + }); + + it('leaves plain inputs out of the writing assistant group', () => { + html(''); + + run(); + + expect(document.getElementById('a')?.hasAttribute('data-gramm')).toBe(false); + }); + + it('skips explicitly non-editable contenteditable elements', () => { + html(''); + + run(); + + expect(document.getElementById('a')?.hasAttribute('data-gramm')).toBe(false); + }); + + it('leaves attributes the page already set untouched', () => { + html(''); + + run(); + + expect(document.getElementById('a')?.getAttribute('data-form-type')).toBe('password'); + }); + + it('reaches fields inside open shadow roots', () => { + html(''); + const shadow = document.getElementById('host')!.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + + run(); + + expect(shadow.getElementById('inner')?.hasAttribute('data-1p-ignore')).toBe(true); + }); + + it('does not throw on a page with no fields', () => { + html('nothing here
'); + + expect(() => run()).not.toThrow(); + }); + + it('marks a field inserted after it ran', async () => { + html(''); + run(); + + const input = document.createElement('input'); + document.getElementById('app')!.appendChild(input); + + await vi.waitFor(() => expect(input.hasAttribute('data-bwignore')).toBe(true)); + }); + + it('marks a field nested inside an inserted subtree', async () => { + html(''); + run(); + + const modal = document.createElement('div'); + modal.innerHTML = ''; + document.getElementById('app')!.appendChild(modal); + + await vi.waitFor(() => + expect(document.getElementById('nested')?.hasAttribute('data-bwignore')).toBe(true), + ); + }); + + it('marks a field inserted into a shadow root it has already walked', async () => { + html(''); + const shadow = document.getElementById('host')!.attachShadow({ mode: 'open' }); + run(); + + const input = document.createElement('input'); + shadow.appendChild(input); + + await vi.waitFor(() => expect(input.hasAttribute('data-bwignore')).toBe(true)); + }); + + it('installs one observer per root however often it runs', () => { + html(''); + let observers = 0; + const Native = window.MutationObserver; + class Counting extends Native { + constructor(onMutation: MutationCallback) { + super(onMutation); + observers++; + } + } + vi.stubGlobal('MutationObserver', Counting); + + run(); + run(); + run(); + + expect(observers).toBe(1); + vi.unstubAllGlobals(); + }); + + // As an init script this runs before the parser has created , let alone , so the + // empty document is the real entry state — not the populated one the cases above start from. + it('marks a body and field parsed after it ran against an empty document', async () => { + const documentElement = document.documentElement; + documentElement.remove(); + + expect(() => run()).not.toThrow(); + + const root = document.createElement('html'); + const body = document.createElement('body'); + const input = document.createElement('input'); + body.appendChild(input); + root.appendChild(body); + document.appendChild(root); + + try { + await vi.waitFor(() => { + expect(input.hasAttribute('data-bwignore')).toBe(true); + expect(body.hasAttribute('data-1p-ignore')).toBe(true); + }); + } finally { + // jsdom is per file, not per test, so every later beforeEach needs a document back. + document.documentElement?.remove(); + document.appendChild(documentElement); + } + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/documentPreparation.test.ts b/packages/@n8n/mcp-browser-extension/src/documentPreparation.test.ts new file mode 100644 index 00000000000..9a25c9a2cb8 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/documentPreparation.test.ts @@ -0,0 +1,108 @@ +import { DocumentPreparation, OPT_OUT_SCRIPT } from './documentPreparation'; + +const { warn, debug } = vi.hoisted(() => ({ warn: vi.fn(), debug: vi.fn() })); + +vi.mock('./logger', () => ({ + createLogger: () => ({ warn, debug, info: vi.fn(), error: vi.fn() }), +})); + +const sendCommand = vi.fn(); + +beforeEach(() => { + sendCommand.mockReset().mockResolvedValue({}); + warn.mockReset(); + debug.mockReset(); + vi.stubGlobal('chrome', { debugger: { sendCommand } }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function callsTo(method: string) { + return sendCommand.mock.calls.filter((call) => call[1] === method); +} + +describe('DocumentPreparation.applyToTab', () => { + it('registers the script for every document the tab opens from now on', async () => { + await new DocumentPreparation().applyToTab(7); + + expect(sendCommand).toHaveBeenCalledWith( + { tabId: 7 }, + 'Page.addScriptToEvaluateOnNewDocument', + { source: OPT_OUT_SCRIPT }, + ); + }); + + it('marks the document that already exists', async () => { + await new DocumentPreparation().applyToTab(7); + + expect(sendCommand).toHaveBeenCalledWith( + { tabId: 7 }, + 'Runtime.evaluate', + expect.objectContaining({ expression: OPT_OUT_SCRIPT }), + ); + }); + + it('does not touch the Runtime domain state the client owns', async () => { + await new DocumentPreparation().applyToTab(7); + + // A second Runtime.enable reports no contexts, which would starve the client's own wait. + expect(callsTo('Runtime.enable')).toHaveLength(0); + expect(callsTo('Runtime.disable')).toHaveLength(0); + }); + + it('still marks the current document when registration fails', async () => { + sendCommand.mockImplementation(async (_d: unknown, method: string) => { + if (method === 'Page.addScriptToEvaluateOnNewDocument') throw new Error('detached'); + return await Promise.resolve({}); + }); + + await new DocumentPreparation().applyToTab(7); + + expect(callsTo('Runtime.evaluate')).toHaveLength(1); + }); + + it('does not reject when the tab is gone', async () => { + sendCommand.mockRejectedValue(new Error('No tab with given id')); + + await expect(new DocumentPreparation().applyToTab(7)).resolves.toBeUndefined(); + }); + + it('gives up on a renderer that never answers, so attach is not held up', async () => { + vi.useFakeTimers(); + try { + sendCommand.mockImplementation(async (_d: unknown, method: string) => + method === 'Runtime.evaluate' ? await new Promise(() => {}) : await Promise.resolve({}), + ); + + const applied = new DocumentPreparation().applyToTab(7); + await vi.advanceTimersByTimeAsync(10_000); + + await expect(applied).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('reports a page that rejected the script instead of swallowing it', async () => { + sendCommand.mockImplementation(async (_d: unknown, method: string) => + method === 'Runtime.evaluate' + ? await Promise.resolve({ exceptionDetails: { text: 'blocked by policy' } }) + : await Promise.resolve({}), + ); + + await new DocumentPreparation().applyToTab(7); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('rejected'), 'blocked by policy'); + }); + + it('keeps an expected race quiet', async () => { + sendCommand.mockRejectedValue(new Error('No tab with given id')); + + await new DocumentPreparation().applyToTab(7); + + expect(warn).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/mcp-browser-extension/src/documentPreparation.ts b/packages/@n8n/mcp-browser-extension/src/documentPreparation.ts new file mode 100644 index 00000000000..a3cf24c7860 --- /dev/null +++ b/packages/@n8n/mcp-browser-extension/src/documentPreparation.ts @@ -0,0 +1,140 @@ +// Lives here rather than server-side so a renamed vendor attribute ships on the extension's +// cadence instead of waiting for an instance upgrade. + +import { createLogger } from './logger'; + +const log = createLogger('document-prep'); + +const EVALUATE_TIMEOUT_MS = 5_000; + +/** + * Runs in the page context, once per document. Not covered: a field that only becomes eligible + * via a later attribute change, a shadow root attached after its host was observed, and child + * frames of a document that was already loaded when we attached. + */ +export function applyOptOutAttributes(): void { + const groups: Array<{ selector: string; attributes: Array<[string, string]> }> = [ + { + // 1Password honors this at as a whole-page switch; the rest are per-field. + selector: 'body', + attributes: [['data-1p-ignore', '']], + }, + { + selector: 'input, textarea, select', + attributes: [ + ['data-1p-ignore', ''], // 1Password + ['data-lpignore', 'true'], // LastPass + ['data-bwignore', ''], // Bitwarden + ['data-protonpass-ignore', ''], // Proton Pass + ['data-form-type', 'other'], // Dashlane + ], + }, + { + selector: 'textarea, [contenteditable]:not([contenteditable="false"])', + attributes: [ + ['data-gramm', 'false'], // Grammarly + ['data-gramm_editor', 'false'], // Grammarly, legacy + ['data-enable-grammarly', 'false'], // Grammarly, current + ['data-lt-active', 'false'], // LanguageTool + ], + }, + ]; + + function isElement(node: Node): node is Element { + return node.nodeType === 1; + } + + function isRegistry(value: unknown): value is WeakSet