diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 8cc5b47..72eadeb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -28,8 +28,8 @@ "description": "about" }, "back": { - "message": "Back", - "description": "Back" + "message": "Back", + "description": "Back" }, "version": { "message": "Version", @@ -354,5 +354,9 @@ "hidden_content_note": { "message": "NOTE: When Hidden content? is checked, the extension will search through all elements regardless of their visibility, which may result in a higher match count.", "description": "Note about hidden content behavior" + }, + "pageUnreachable": { + "message": "Search and Replace cannot reach this page. Reload the page and try again.", + "description": "pageUnreachable" } } diff --git a/src/constants.ts b/src/constants.ts index b840716..01912d7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,6 +13,16 @@ export const RICH_TEXT_EDITORS = { */ export const ELEMENT_FILTER = /^(HTML|HEAD|SCRIPT|STYLE|IFRAME)$/i export const INPUT_TEXTAREA_CONTENT_EDITABLE_SELECTOR = 'input,textarea,*[contenteditable="true"]' + +/** The bundle listed under content_scripts in manifest.json, and injected on demand */ +export const CONTENT_SCRIPT_FILE = 'searchreplace.js' + +/** + * Chrome's wording when `tabs.sendMessage` finds nobody listening in the target tab, which + * happens when the page was loaded before the extension, or when the extension has been + * reloaded or auto-updated since the page was loaded, orphaning its content script. + */ +export const NO_RECEIVER_MESSAGE = 'Receiving end does not exist' export const HINTS: Record = { gmail: { hint: 'Hint: Gmail detected. Check "Input fields only?" when editing draft emails.', diff --git a/src/cypress/fixtures/example.json b/src/cypress/fixtures/example.json new file mode 100644 index 0000000..519902d --- /dev/null +++ b/src/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/src/popup.ts b/src/popup.ts index 5615160..32ab3af 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -28,6 +28,7 @@ import { manifest, tabConnect, } from './util' +import { deliverToContentScript } from './popup/deliver' import { getRemainingMatchCount } from './popup/count' // Enum for content types @@ -382,6 +383,11 @@ async function formSubmitHandler( await storeTerms(true) // do the search replace await contentScriptCall(action, searchReplaceInstance, historyItems) + if (!reachedContentScript) { + // No reply is coming, so take the spinner down and say why + removeLoader() + setPageUnreachableHint(translationFn) + } } /** Send the search and replace instance to the content script for processing, clearing any saved responses first. @@ -408,12 +414,27 @@ export async function contentScriptCall( url: tab.url, instanceId, } - await chrome.tabs.sendMessage(tab.id, message) + // Injects the content script and retries if the page does not have one. Never rejects + // for a missing content script, so a page the extension cannot reach leaves the popup + // usable instead of hanging on a spinner. + const delivery = await deliverToContentScript(tab.id, message) + if (delivery === 'no-content-script') { + reachedContentScript = false + return undefined + } + reachedContentScript = true url = tab.url } return url } +/** + * Whether the last contentScriptCall reached a content script. The response arrives + * asynchronously through chrome.runtime.onMessage, so the popup has to remember that the + * request never got anywhere in order to stop waiting for a reply. + */ +let reachedContentScript = true + /** The callback function for the content script * @param msg {SearchReplaceResponse} * @param translationFn @@ -467,6 +488,24 @@ function setHints(hints?: Hint[]) { } } +/** + * Tell the user the page cannot be reached, in the same place hints appear. Reached when a page + * has no content script and one cannot be injected — a chrome:// page, the Web Store, a PDF, or + * a file:// URL without file access. + */ +function setPageUnreachableHint(translationFn: TranslationProxy) { + const hintsElement = document.getElementById('hints') + if (!hintsElement) { + return + } + hintsElement.innerHTML = '' + const alert = document.createElement('div') + alert.className = 'hint alert alert-warning' + alert.setAttribute('role', 'alert') + alert.innerText = translationFn('pageUnreachable') + hintsElement.appendChild(alert) +} + function removeLoader() { const loader = document.getElementById('loader') if (loader) loader.style.display = 'none' diff --git a/src/popup/deliver.test.ts b/src/popup/deliver.test.ts new file mode 100644 index 0000000..78a3c9d --- /dev/null +++ b/src/popup/deliver.test.ts @@ -0,0 +1,110 @@ +import { ChromeMock, installChromeMock } from '../testing/chromeMock' +import { SearchReplaceContentMessage } from '../types' +import { beforeEach, describe, expect, test } from 'vitest' +import { deliverToContentScript, isMissingContentScript } from './deliver' + +const TAB_ID = 7 + +function message(): SearchReplaceContentMessage { + return { + action: 'searchReplace', + instance: { + searchTerm: 'find', + replaceTerm: 'replace', + options: { + matchCase: false, + inputFieldsOnly: false, + hiddenContent: false, + wholeWord: false, + isRegex: false, + replaceHTML: false, + replaceAll: true, + save: false, + }, + }, + } +} + +let mock: ChromeMock + +beforeEach(() => { + mock = installChromeMock() +}) + +describe('isMissingContentScript', () => { + test('recognises the error Chrome raises when nothing is listening', () => { + // The wording the reporter of issue #126 saw in the extension's error log + expect(isMissingContentScript(new Error('Could not establish connection. Receiving end does not exist.'))).toBe( + true + ) + }) + + test('does not swallow unrelated errors', () => { + expect(isMissingContentScript(new Error('The tab was closed'))).toBe(false) + expect(isMissingContentScript(new Error('Extension context invalidated'))).toBe(false) + }) + + test('copes with something thrown that is not an Error', () => { + expect(isMissingContentScript('Receiving end does not exist')).toBe(true) + expect(isMissingContentScript(undefined)).toBe(false) + }) +}) + +describe('deliverToContentScript', () => { + test('sends straight through when the content script is present', async () => { + expect(await deliverToContentScript(TAB_ID, message())).toBe('delivered') + expect(mock.sentTabMessages).toHaveLength(1) + // No need to inject when the page already has it + expect(mock.executedScripts).toEqual([]) + }) + + test('injects the content script and retries when nothing is listening', async () => { + mock.tabsWithoutContentScript.add(TAB_ID) + + expect(await deliverToContentScript(TAB_ID, message())).toBe('delivered-after-injecting') + expect(mock.executedScripts).toEqual([{ tabId: TAB_ID, files: ['searchreplace.js'] }]) + // The first attempt failed, the second succeeded + expect(mock.sentTabMessages).toHaveLength(1) + }) + + test('reports no content script when the page cannot be injected into', async () => { + // chrome://, the Web Store, a PDF viewer, or file:// without file access + mock.tabsWithoutContentScript.add(TAB_ID) + mock.blockedTabIds.add(TAB_ID) + + expect(await deliverToContentScript(TAB_ID, message())).toBe('no-content-script') + expect(mock.sentTabMessages).toEqual([]) + }) + + test('does not reject when the page cannot be reached, so the popup stays usable', async () => { + mock.tabsWithoutContentScript.add(TAB_ID) + mock.blockedTabIds.add(TAB_ID) + + await expect(deliverToContentScript(TAB_ID, message())).resolves.toBe('no-content-script') + }) + + test('reports no content script when injection succeeds but the retry still fails', async () => { + // The tab navigated away between injecting and sending + mock.tabsWithoutContentScript.add(TAB_ID) + mock.injectionDoesNotHelp.add(TAB_ID) + + expect(await deliverToContentScript(TAB_ID, message())).toBe('no-content-script') + expect(mock.executedScripts).toHaveLength(1) + }) + + test('passes other errors on rather than hiding them behind an injection', async () => { + mock.tabMessageError = new Error('Extension context invalidated') + + await expect(deliverToContentScript(TAB_ID, message())).rejects.toThrow('Extension context invalidated') + expect(mock.executedScripts).toEqual([]) + }) + + test('delivers the message unchanged', async () => { + mock.tabsWithoutContentScript.add(TAB_ID) + const msg = message() + + await deliverToContentScript(TAB_ID, msg) + + expect(mock.sentTabMessages[0]).toEqual({ tabId: TAB_ID, message: msg }) + }) +}) diff --git a/src/popup/deliver.ts b/src/popup/deliver.ts new file mode 100644 index 0000000..6176f4e --- /dev/null +++ b/src/popup/deliver.ts @@ -0,0 +1,55 @@ +import { CONTENT_SCRIPT_FILE, NO_RECEIVER_MESSAGE } from '../constants' +import { SearchReplaceContentMessage } from '../types' + +/** Whether a rejection from `tabs.sendMessage` means no content script was listening */ +export function isMissingContentScript(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes(NO_RECEIVER_MESSAGE) +} + +export type DeliveryResult = 'delivered' | 'delivered-after-injecting' | 'no-content-script' + +/** + * Send a message to the content script, injecting it first if it is not there. + * + * Chrome only injects content scripts into pages loaded *after* the extension is installed, + * updated or reloaded. Because Chrome updates extensions silently in the background, a + * long-lived tab — a dashboard someone leaves open for days — loses its content script without + * anything visibly happening. Every later `tabs.sendMessage` then rejects with "Receiving end + * does not exist", the popup does nothing at all, and the rejection surfaces as an uncaught + * error. That is the failure reported in issue #126 on Meta Ads Manager, and it explains a + * report of "it worked in the past, and a year ago it stopped". + * + * Rather than asking the user to reload, inject the content script and try again. + */ +export async function deliverToContentScript( + tabId: number, + message: SearchReplaceContentMessage +): Promise { + try { + await chrome.tabs.sendMessage(tabId, message) + return 'delivered' + } catch (error) { + if (!isMissingContentScript(error)) { + throw error + } + } + + try { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: [CONTENT_SCRIPT_FILE], + }) + } catch { + // A page the extension has no access to: chrome://, the Web Store, a PDF viewer, or a + // file:// URL without file access granted + return 'no-content-script' + } + + try { + await chrome.tabs.sendMessage(tabId, message) + return 'delivered-after-injecting' + } catch { + return 'no-content-script' + } +} diff --git a/src/testing/chromeMock.ts b/src/testing/chromeMock.ts index 37c20d9..d1986f9 100644 --- a/src/testing/chromeMock.ts +++ b/src/testing/chromeMock.ts @@ -94,6 +94,12 @@ export interface ChromeMock { executedScripts: { tabId: number; files: string[] }[] /** Tab ids for which `chrome.scripting.executeScript` should reject, as restricted pages do */ blockedTabIds: Set + /** Tab ids with no content script listening, as a page loaded before the extension has */ + tabsWithoutContentScript: Set + /** Tab ids where injecting does not help, e.g. the tab navigated away mid-flight */ + injectionDoesNotHelp: Set + /** Set to make `chrome.tabs.sendMessage` reject with something other than a missing receiver */ + tabMessageError?: Error chrome: typeof chrome } @@ -107,6 +113,9 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock const tabs: { id?: number; url?: string }[] = [{ id: 1, url: 'https://example.com/' }] const executedScripts: { tabId: number; files: string[] }[] = [] const blockedTabIds = new Set() + const tabsWithoutContentScript = new Set() + const injectionDoesNotHelp = new Set() + const state: { tabMessageError?: Error } = {} function respondTo(message: unknown): unknown { const action = (message as { action?: string } | undefined)?.action @@ -145,6 +154,13 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock tabs: { query: () => Promise.resolve(tabs), sendMessage: (tabId: number, message: unknown) => { + if (state.tabMessageError) { + return Promise.reject(state.tabMessageError) + } + if (tabsWithoutContentScript.has(tabId)) { + // Mirrors Chrome's wording when no content script is listening + return Promise.reject(new Error('Could not establish connection. Receiving end does not exist.')) + } sentTabMessages.push({ tabId, message }) return Promise.resolve() }, @@ -159,6 +175,10 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock return Promise.reject(new Error('Cannot access contents of the page')) } executedScripts.push({ tabId: injection.target.tabId, files: injection.files }) + if (!injectionDoesNotHelp.has(injection.target.tabId)) { + // Injecting gives the tab a listener, so a retry now succeeds + tabsWithoutContentScript.delete(injection.target.tabId) + } return Promise.resolve([]) }, }, @@ -180,6 +200,14 @@ export function createChromeMock(options: { version?: string } = {}): ChromeMock tabs, executedScripts, blockedTabIds, + tabsWithoutContentScript, + injectionDoesNotHelp, + get tabMessageError() { + return state.tabMessageError + }, + set tabMessageError(error: Error | undefined) { + state.tabMessageError = error + }, // The mock deliberately implements only the surface this codebase uses chrome: mock as unknown as typeof chrome, }