Skip to content
Merged
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
8 changes: 6 additions & 2 deletions _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
"description": "about"
},
"back": {
"message": "Back",
"description": "Back"
"message": "Back",
"description": "Back"
},
"version": {
"message": "Version",
Expand Down Expand Up @@ -354,5 +354,9 @@
"hidden_content_note": {
"message": "NOTE: When <code>Hidden content?</code> 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"
}
}
10 changes: 10 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Hint> = {
gmail: {
hint: 'Hint: Gmail detected. Check "Input fields only?" when editing draft emails.',
Expand Down
5 changes: 5 additions & 0 deletions src/cypress/fixtures/example.json
Original file line number Diff line number Diff line change
@@ -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"
}
41 changes: 40 additions & 1 deletion src/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
manifest,
tabConnect,
} from './util'
import { deliverToContentScript } from './popup/deliver'
import { getRemainingMatchCount } from './popup/count'

// Enum for content types
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down
110 changes: 110 additions & 0 deletions src/popup/deliver.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
55 changes: 55 additions & 0 deletions src/popup/deliver.ts
Original file line number Diff line number Diff line change
@@ -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<DeliveryResult> {
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'
}
}
28 changes: 28 additions & 0 deletions src/testing/chromeMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>
/** Tab ids with no content script listening, as a page loaded before the extension has */
tabsWithoutContentScript: Set<number>
/** Tab ids where injecting does not help, e.g. the tab navigated away mid-flight */
injectionDoesNotHelp: Set<number>
/** Set to make `chrome.tabs.sendMessage` reject with something other than a missing receiver */
tabMessageError?: Error
chrome: typeof chrome
}

Expand All @@ -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<number>()
const tabsWithoutContentScript = new Set<number>()
const injectionDoesNotHelp = new Set<number>()
const state: { tabMessageError?: Error } = {}

function respondTo(message: unknown): unknown {
const action = (message as { action?: string } | undefined)?.action
Expand Down Expand Up @@ -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()
},
Expand All @@ -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([])
},
},
Expand All @@ -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,
}
Expand Down
Loading