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,
}